Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/tools/bcrypt/bcrypt.models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { compare, hash } from 'bcryptjs';
import { assert, describe, expect, test } from 'vitest';
import { type Update, bcryptWithProgressUpdates } from './bcrypt.models';

// simplified polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync
async function fromAsync<T>(iter: AsyncIterable<T>) {
const out: T[] = [];
for await (const val of iter) {
out.push(val);
}
return out;
}

function checkProgressAndGetResult<T>(updates: Update<T>[]) {
const first = updates.at(0);
const penultimate = updates.at(-2);
const last = updates.at(-1);
const allExceptLast = updates.slice(0, -1);

expect(allExceptLast.every(x => x.kind === 'progress')).toBeTruthy();
expect(first).toEqual({ kind: 'progress', progress: 0 });
expect(penultimate).toEqual({ kind: 'progress', progress: 1 });

assert(last != null && last.kind === 'success');

return last;
}

describe('bcrypt models', () => {
describe(bcryptWithProgressUpdates.name, () => {
test('with bcrypt hash function', async () => {
const updates = await fromAsync(bcryptWithProgressUpdates(hash, ['abc', 5]));
const result = checkProgressAndGetResult(updates);

expect(result.value).toMatch(/^\$2a\$05\$.{53}$/);
expect(result.timeTakenMs).toBeGreaterThan(0);
});

test('with bcrypt compare function', async () => {
const updates = await fromAsync(
bcryptWithProgressUpdates(compare, ['abc', '$2a$05$FHzYelm8Qn.IhGP.N8V1TOWFlRTK.8cphbxZSvSFo9B6HGscnQdhy']),
);
const result = checkProgressAndGetResult(updates);

expect(result.value).toBe(true);
expect(result.timeTakenMs).toBeGreaterThan(0);
});
});
});
93 changes: 93 additions & 0 deletions src/tools/bcrypt/bcrypt.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export type Update<Result> =
| {
kind: 'progress'
progress: number
}
| {
kind: 'success'
value: Result
timeTakenMs: number
}
| {
kind: 'error'
message: string
};

export class TimedOutError extends Error {
name = 'TimedOutError';
}
export class InvalidatedError extends Error {
name = 'InvalidatedError';
}

// generic type for the callback versions of bcryptjs's `hash` and `compare`
export type BcryptFn<Param, Result> = (
arg1: string,
arg2: Param,
callback: (err: Error | null, hash: Result) => void,
progressCallback: (percent: number) => void,
) => void;

interface BcryptWithProgressOptions {
controller: AbortController
timeoutMs: number
}

export async function* bcryptWithProgressUpdates<Param, Result>(
fn: BcryptFn<Param, Result>,
args: [string, Param],
options?: Partial<BcryptWithProgressOptions>,
): AsyncGenerator<Update<Result>, undefined, undefined> {
const { controller = new AbortController(), timeoutMs = 10_000 } = options ?? {};

let res = (_: Update<Result>) => {};
const nextPromise = () =>
new Promise<Update<Result>>((resolve) => {
res = resolve;
});
const promises = [nextPromise()];
const nextValue = (value: Update<Result>) => {
res(value);
promises.push(nextPromise());
};

const start = Date.now();

fn(
args[0],
args[1],
(err, value) => {
nextValue(
err == null
? { kind: 'success', value, timeTakenMs: Date.now() - start }
: { kind: 'error', message: err.message },
);
},
(progress) => {
if (controller.signal.aborted) {
nextValue({ kind: 'progress', progress: 0 });
if (controller.signal.reason instanceof TimedOutError) {
nextValue({ kind: 'error', message: controller.signal.reason.message });
}

// throw inside callback to cancel execution of hashing/comparing
throw controller.signal.reason;
}
else {
nextValue({ kind: 'progress', progress });
}
},
);

setTimeout(() => {
controller.abort(new TimedOutError(`Timed out after ${(timeoutMs / 1000).toLocaleString('en-US')}\xA0seconds`));
}, timeoutMs);

for await (const value of promises) {
yield value;

if (value.kind === 'success' || value.kind === 'error') {
return;
}
}
}
113 changes: 98 additions & 15 deletions src/tools/bcrypt/bcrypt.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,76 @@
<script setup lang="ts">
import { compareSync, hashSync } from 'bcryptjs';
import { compare, hash } from 'bcryptjs';
import { useThemeVars } from 'naive-ui';
import { type BcryptFn, InvalidatedError, bcryptWithProgressUpdates } from './bcrypt.models';
import { useCopy } from '@/composable/copy';

const themeVars = useThemeVars();

interface ExecutionState<T> {
result: T | null
percentage: number
error: string | null
timeTakenMs: number | null
}

const blankState = () => ({ result: null, percentage: 0, error: null, timeTakenMs: null });

async function exec<Param, Result>(
fn: BcryptFn<Param, Result>,
args: [string | null, Param | null],
controller: AbortController,
state: ExecutionState<Result>,
) {
const [arg0, arg1] = args;
if (arg0 == null || arg1 == null) {
return;
}

for await (const update of bcryptWithProgressUpdates(fn, [arg0, arg1], { controller, timeoutMs: 10_000 })) {
switch (update.kind) {
case 'progress': {
state.percentage = Math.round(update.progress * 100);
break;
}
case 'success': {
state.result = update.value;
state.timeTakenMs = update.timeTakenMs;
break;
}
case 'error': {
state.error = update.message;
break;
}
}
}
}

function initWatcher<Param, Result>(
fn: BcryptFn<Param, Result>,
inputs: [Ref<string | null>, Ref<Param | null>],
state: Ref<ExecutionState<Result>>,
) {
let controller = new AbortController();
watch(inputs, (inputs) => {
controller.abort(new InvalidatedError());
controller = new AbortController();
state.value = blankState();
exec(fn, inputs, controller, state.value);
});
}

const hashState = ref<ExecutionState<string>>(blankState());
const input = ref('');
const saltCount = ref(10);
const hashed = computed(() => hashSync(input.value, saltCount.value));
const { copy } = useCopy({ source: hashed, text: 'Hashed string copied to the clipboard' });
initWatcher(hash, [input, saltCount], hashState);

const source = computed(() => hashState.value.result ?? '');
const { copy } = useCopy({ source, text: 'Hashed string copied to the clipboard' });

const compareState = ref<ExecutionState<boolean>>(blankState());
const compareString = ref('');
const compareHash = ref('');
const compareMatch = computed(() => compareSync(compareString.value, compareHash.value));
initWatcher(compare, [compareString, compareHash], compareState);
</script>

<template>
Expand All @@ -28,10 +86,19 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash
mb-2
/>
<n-form-item label="Salt count: " label-placement="left" label-width="120">
<n-input-number v-model:value="saltCount" placeholder="Salt rounds..." :max="100" :min="0" w-full />
<n-input-number v-model:value="saltCount" placeholder="Salt rounds..." :max="20" :min="0" w-full />
</n-form-item>

<c-input-text :value="hashed" readonly text-center />
<n-progress :percentage="hashState.percentage" :show-indicator="false" />
<c-input-text
:value="hashState.result ?? undefined"
:placeholder="hashState.error ?? 'Hashed string'"
readonly
text-center
/>
<div mt-1 h-3 op-60>
{{ hashState.timeTakenMs == null ? '' : `Hashed in ${hashState.timeTakenMs}\xA0ms` }}
</div>

<div mt-5 flex justify-center>
<c-button @click="copy()">
Expand All @@ -48,21 +115,37 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash
<n-form-item label="Your hash: " label-placement="left">
<c-input-text v-model:value="compareHash" placeholder="Your hash to compare..." raw-text />
</n-form-item>
<n-form-item label="Do they match ? " label-placement="left" :show-feedback="false">
<div class="compare-result" :class="{ positive: compareMatch }">
{{ compareMatch ? 'Yes' : 'No' }}
</div>
</n-form-item>

<n-progress :percentage="compareState.percentage" :show-indicator="false" />
<div>
<c-input-text
id="bcrypt-compare-result"
:value="compareState.result == null ? undefined : compareState.result ? 'Matched' : 'No match'"
:placeholder="compareState.error ?? 'Comparison result'"
readonly
text-center
class="compare-result"
:class="compareState.result == null ? undefined : compareState.result ? 'positive' : 'negative'"
/>
</div>
<div mb-1 mt-1 h-3 op-60>
{{ compareState.timeTakenMs == null ? '' : `Compared in ${compareState.timeTakenMs}\xA0ms` }}
</div>
</n-form>
</c-card>
</template>

<style lang="less" scoped>
<style lang="less">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't get the styles to work due to CSS precedence rules while retaining scoped, so I passed an id to c-input-text, which is set on the <input> itself, allowing easier CSS targeting. It's not a great solution though... Open to suggestions of how to do it better without resorting to global styles or !important. Maybe c-input-text just needs finer-grained ways of styling it?

.compare-result {
color: v-bind('themeVars.errorColor');

&.negative {
input#bcrypt-compare-result {
color: v-bind('themeVars.errorColor');
}
}
&.positive {
color: v-bind('themeVars.successColor');
input#bcrypt-compare-result {
color: v-bind('themeVars.successColor');
}
}
}
</style>