From a566fbb82991ba6c6738a61d5fd390b009701d30 Mon Sep 17 00:00:00 2001 From: Rodrigo Speller Date: Sat, 27 Jun 2026 18:36:42 -0300 Subject: [PATCH 1/4] Add async support for argon2id --- README.md | 20 ++++++++++ index.d.ts | 6 +-- index.js | 3 +- lib/argon2id.js | 74 ++++++++++++++++++++++++++++++++++--- lib/setup.d.ts | 14 +++++++ lib/setup.js | 4 +- test/argon2id.spec.ts | 60 ++++++++++++++++++++++++++++-- test/helpers/node-loader.ts | 6 ++- 8 files changed, 171 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 26821d0..09f7932 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,26 @@ const argon2id = await setupWasm( Using the same principle, for browsers you can use bundlers with simple base-64 file loaders. +### Async usage + +If you need a Promise-based and non-blocking method, you can pass the `async: true` option to `argon2id`: + +```js +import loadArgon2idWasm from 'argon2id'; + +const argon2id = await loadArgon2idWasm(); +const hash = await argon2id({ + async: true, + password: new Uint8Array(...), + salt: crypto.getRandomValues(new Uint8Array(32)), + parallelism: 4, + passes: 3, + memorySize: 2**16, +}); +``` + +The resolution of the yielding frequency can be controlled by passing the `async.yieldResolution` option to `argon2id` or to `loadArgon2idWasm` (default is 2048). The `async` option can be passed as `true` or as an object with async options, e.g. `{ async: { yieldResolution: 1024 } }`. The `yieldResolution` option of `loadArgon2idWasm` will be used as default for all calls to `argon2id`, unless overridden by the `yieldResolution` option of `argon2id`. + ## Compiling **The npm package already includes the compiled binaries.**
diff --git a/index.d.ts b/index.d.ts index 1878084..28d87ea 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -import type { computeHash, Argon2idParams } from "./lib/setup"; +import type { computeHash, Argon2idParams, Argon2idAsyncParams, Argon2idSetupOptions } from "./lib/setup"; /** * Setup an `argon2id` instance, by loading the Wasm module that is used under the hood. The SIMD version is used as long as the platform supports it. @@ -8,6 +8,6 @@ import type { computeHash, Argon2idParams } from "./lib/setup"; * and some of them require considerably more memory than the rest. * @returns argon2id function */ -export function loadWasm(): Promise; +export function loadWasm(options?: Argon2idSetupOptions): Promise; export default loadWasm; -export type { Argon2idParams, computeHash }; +export type { Argon2idParams, computeHash, Argon2idAsyncParams, Argon2idSetupOptions }; diff --git a/index.js b/index.js index f6d395e..192d48d 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,10 @@ import setupWasm from './lib/setup.js'; import wasmSIMD from './dist/simd.wasm'; import wasmNonSIMD from './dist/no-simd.wasm'; -const loadWasm = async () => setupWasm( +const loadWasm = async (options) => setupWasm( (instanceObject) => wasmSIMD(instanceObject), (instanceObject) => wasmNonSIMD(instanceObject), + options, ); export default loadWasm; diff --git a/lib/argon2id.js b/lib/argon2id.js index 0bdbb8d..1219d83 100644 --- a/lib/argon2id.js +++ b/lib/argon2id.js @@ -14,6 +14,8 @@ const SECRETBYTES_MAX = 32; // key (optional) const ARGON2_BLOCK_SIZE = 1024; const ARGON2_PREHASH_DIGEST_LENGTH = 64; +const DEFAULT_ASYNC_YIELD_RESOLUTION = 2048; + const isLittleEndian = new Uint8Array(new Uint16Array([0xabcd]).buffer)[0] === 0xcd; // store n as a little-endian 32-bit Uint8Array inside buf (at buf[i:i+3]) @@ -153,11 +155,13 @@ function* makePRNG(wasmContext, pass, lane, slice, m_, totalPasses, segmentLengt return []; } -function validateParams({ type, version, tagLength, password, salt, ad, secret, parallelism, memorySize, passes }) { +function validateParams({ type, version, tagLength, password, salt, ad, secret, parallelism, memorySize, passes, async }, { async: globalAsync } = {}) { const assertLength = (name, value, min, max) => { if (value < min || value > max) { throw new Error(`${name} size should be between ${min} and ${max} bytes`); } } + if (!isLittleEndian) throw new Error('BigEndian system not supported'); // optmisations assume LE system + if (type !== TYPE || version !== VERSION) throw new Error('Unsupported type or version'); assertLength('password', password, passwordBYTES_MIN, passwordBYTES_MAX); assertLength('salt', salt, SALTBYTES_MIN, SALTBYTES_MAX); @@ -167,17 +171,74 @@ function validateParams({ type, version, tagLength, password, salt, ad, secret, ad && assertLength('associated data', ad, 0, ADBYTES_MAX); secret && assertLength('secret', secret, 0, SECRETBYTES_MAX); - return { type, version, tagLength, password, salt, ad, secret, lanes: parallelism, memorySize, passes }; + // async options + let yieldResolution; + if (async) { + let asyncOptions = { ...globalAsync, ...async }; + async = true; + + yieldResolution ??= asyncOptions.yieldResolution ?? DEFAULT_ASYNC_YIELD_RESOLUTION; + if (yieldResolution < 1) throw new Error('yieldResolution must be at least 1'); + } + + return { type, version, tagLength, password, salt, ad, secret, lanes: parallelism, memorySize, passes, async, yieldResolution }; } const KB = 1024; const WASM_PAGE_SIZE = 64 * KB; -export default function argon2id(params, { memory, instance: wasmInstance }) { - if (!isLittleEndian) throw new Error('BigEndian system not supported'); // optmisations assume LE system +export default function argon2id(params, options) { + // Validate parameters and set up the context. + const ctx = validateParams({ type: TYPE, version: VERSION, ...params }, options); + + return ctx.async + // Call the asynchronous version. + ? argon2idAsync(ctx, options) + // Call the synchronous version. + : argon2idSync(ctx, options) + ; +} - const ctx = validateParams({ type: TYPE, version: VERSION, ...params }); +function argon2idSync(ctx, options) { + // Run the generator to completion synchronously. + const tasks = argon2idTasksGenerator(ctx, options); + let step = tasks.next(); + while (!step.done) { + step = tasks.next(); + } + + // Return the final result of the generator, which is the computed Argon2id hash. + return step.value; +} +async function argon2idAsync(ctx, options) { + // Yielding resolution control parameters. + const yieldResolution = ctx.yieldResolution; + let operations = 0; + let nextYield = yieldResolution; + + // Run the generator and yield to the event loop as needed to avoid blocking + // the main thread. + const tasks = argon2idTasksGenerator(ctx, options); + let step = tasks.next(); + while (!step.done) { + // Yield resolution control: yield to the event loop every `yieldResolution` operations. + if (++operations >= nextYield) { + nextYield += yieldResolution; + // yield to the event loop to allow other tasks to run + await new Promise(resolve => setTimeout(resolve, 0)); + } + step = tasks.next(); + } + + // Return the final result of the generator, which is the computed Argon2id hash. + return step.value; +} + +// Internal generator that contains the single shared implementation of the Argon2id algorithm. +// Each iteration of the innermost block-computation loop yields once, allowing the scheduler to +// decide whether (and how) to pause execution between blocks. +function* argon2idTasksGenerator(ctx, { memory, instance: wasmInstance }) { const { G:wasmG, G2:wasmG2, xor:wasmXOR, getLZ:wasmLZ } = wasmInstance.exports; const wasmRefs = {}; const wasmFn = {}; @@ -248,6 +309,9 @@ export default function argon2id(params, { memory, instance: wasmInstance }) { // no need to generate all J1J2s, use iterator/generator that creates the value on the fly (to save memory) const PRNG = isDataIndependent ? makePRNG(wasmContext, pass, i, sl, m_, ctx.passes, segmentLength, segmentOffset) : null; for (segmentOffset; segmentOffset < segmentLength; segmentOffset++) { + // Yield to the loop control to allow other tasks to run. + yield; + const j = sl * segmentLength + segmentOffset; const prevBlock = j > 0 ? B[i][j-1] : B[i][q-1]; // B[i][(j-1) mod q] diff --git a/lib/setup.d.ts b/lib/setup.d.ts index 51c1428..f574ed7 100644 --- a/lib/setup.d.ts +++ b/lib/setup.d.ts @@ -15,7 +15,19 @@ export interface Argon2idParams { secret?: Uint8Array; } +export interface Argon2idAsyncParams extends Argon2idParams { + async: true | { + /** Number of iterations after which the function yields control to the event loop */ + yieldResolution?: number + } +} + +export interface Argon2idSetupOptions { + async?: { yieldResolution?: number }; +} + declare function argon2id(params: Argon2idParams): Uint8Array; +declare function argon2id(params: Argon2idAsyncParams): Promise; export type computeHash = typeof argon2id; type MaybePromise = T | Promise; @@ -27,9 +39,11 @@ declare function customInstanceLoader(importObject: WebAssembly.Imports): MaybeP * It is platform-independent and it relies on the two functions given in input to instatiate the Wasm instances. * @param getSIMD - function instantiating and returning the SIMD Wasm instance * @param getNonSIMD - function instantiating and returning the non-SIMD Wasm instance + * @param options - optional parameters * @returns {computeHash} */ export default function setupWasm( getSIMD: typeof customInstanceLoader, getNonSIMD: typeof customInstanceLoader, + options?: Argon2idSetupOptions, ): Promise; diff --git a/lib/setup.js b/lib/setup.js index 65c1670..cf16a7e 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -17,7 +17,7 @@ async function wasmLoader(memory, getSIMD, getNonSIMD) { return loader(importObject); } -export default async function setupWasm(getSIMD, getNonSIMD) { +export default async function setupWasm(getSIMD, getNonSIMD, options) { const memory = new WebAssembly.Memory({ // in pages of 64KiB each // these values need to be compatible with those declared when building in `build-wasm` @@ -40,7 +40,7 @@ export default async function setupWasm(getSIMD, getNonSIMD) { * @param {Uint8Array} [params.secret] - secret data (optional) * @return {Uint8Array} argon2id hash */ - const computeHash = (params) => argon2id(params, { instance: wasmModule.instance, memory }); + const computeHash = (params) => argon2id(params, { ...options, instance: wasmModule.instance, memory }); return computeHash; } diff --git a/test/argon2id.spec.ts b/test/argon2id.spec.ts index f925b92..f8a15eb 100644 --- a/test/argon2id.spec.ts +++ b/test/argon2id.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { isNode, hexToUint8Array, uint8ArrayToHex } from './helpers/utils.js'; -import type { computeHash } from '../index'; +import type { computeHash, computeHashAsync } from '../index'; describe('argon2id tests', () => { let argon2id: computeHash; @@ -9,8 +9,7 @@ describe('argon2id tests', () => { // Node tests do not use a bundler, so we need an alternative entry-point // @ts-ignore const { default: loadWasm } = isNode ? await import(/* webpackIgnore: true */ './helpers/node-loader.ts') : await import('../index.js'); - argon2id = await loadWasm(); - + argon2id = await loadWasm({ async: { yieldResolution: 64 } }); }); it('Test vector, 3 passes', function () { @@ -23,6 +22,7 @@ describe('argon2id tests', () => { ad: hexToUint8Array('040404040404040404040404'), tagLength: 32 }); + expect(tagT3).to.be.instanceOf(Uint8Array); expect(uint8ArrayToHex(tagT3)).to.equal(expected); }); @@ -62,6 +62,60 @@ describe('argon2id tests', () => { expect(uint8ArrayToHex(tag)).to.equal(expected); }); + + it('Async test vector, 3 passes', async function () { + const expected = '0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659'; + const tagT3 = await argon2id({ + async: true, + password: hexToUint8Array('0101010101010101010101010101010101010101010101010101010101010101'), + salt: hexToUint8Array('02020202020202020202020202020202'), + passes: 3, memorySize:32, parallelism: 4, + secret: hexToUint8Array('0303030303030303'), + ad: hexToUint8Array('040404040404040404040404'), + tagLength: 32 + }); + expect(uint8ArrayToHex(tagT3)).to.equal(expected); + }); + + it('Async method yields back to the event loop', async function () { + this.timeout(10000); + let ticks = 0; + let completed = false; + + let timerPromise: Promise | null = null; + const tick = (resolve: () => void) => { + setTimeout(() => { + ticks++; + resolve(); + + if (!completed) { + timerPromise = new Promise(tick); + } + }, 0); + + }; + + timerPromise = new Promise(tick); + + const hashPromise = argon2id({ + async: { yieldResolution: 1024 }, + password: hexToUint8Array('0101010101010101010101010101010101010101010101010101010101010101'), + salt: hexToUint8Array('0202020202020202020202020202020202020202020202020202020202020202'), + passes: 2, memorySize: Math.pow(2, 15), parallelism: 4, tagLength: 32, + }); + + expect(hashPromise).to.be.a('promise'); + + const asyncTag = await hashPromise; + completed = true; + + expect(ticks).to.be.greaterThan(0); + expect(asyncTag.length).to.equal(32); + + await timerPromise; + + expect(ticks).to.be.equal(64); + }); }) diff --git a/test/helpers/node-loader.ts b/test/helpers/node-loader.ts index ec69038..c78ecc5 100644 --- a/test/helpers/node-loader.ts +++ b/test/helpers/node-loader.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'fs'; -import setupWasm from '../../lib/setup.js'; +import setupWasm, { Argon2idSetupOptions } from '../../lib/setup.js'; const SIMD_FILENAME = './dist/simd.wasm'; const NON_SIMD_FILENAME = './dist/no-simd.wasm'; @@ -7,12 +7,14 @@ const NON_SIMD_FILENAME = './dist/no-simd.wasm'; /** * Simple wasm loader for Node, that does not require bundlers: * it reads the wasm binaries from disk and instantiates them. + * @param options - optional parameters * @returns argon2id function */ -export default async function load() { +export default async function load(options?: Argon2idSetupOptions) { return setupWasm( (importObject) => WebAssembly.instantiate(readFileSync(SIMD_FILENAME), importObject), (importObject) => WebAssembly.instantiate(readFileSync(NON_SIMD_FILENAME), importObject), + options ); } From abb0f8864fa22357c1c73fb6220da9885ab0ecbc Mon Sep 17 00:00:00 2001 From: Rodrigo Speller Date: Sat, 27 Jun 2026 22:33:56 -0300 Subject: [PATCH 2/4] Remove unused async import from argon2id tests --- test/argon2id.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/argon2id.spec.ts b/test/argon2id.spec.ts index f8a15eb..283a91d 100644 --- a/test/argon2id.spec.ts +++ b/test/argon2id.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { isNode, hexToUint8Array, uint8ArrayToHex } from './helpers/utils.js'; -import type { computeHash, computeHashAsync } from '../index'; +import type { computeHash } from '../index'; describe('argon2id tests', () => { let argon2id: computeHash; From b49e2a34a91c89b7820ef9ca740b070a5978fbbd Mon Sep 17 00:00:00 2001 From: Rodrigo Speller Date: Sat, 27 Jun 2026 22:42:06 -0300 Subject: [PATCH 3/4] Add abort signal support for async operations --- lib/argon2id.js | 35 ++++++++++++++++++++++++++++++++--- lib/setup.d.ts | 2 ++ test/argon2id.spec.ts | 23 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/argon2id.js b/lib/argon2id.js index 1219d83..dbf3121 100644 --- a/lib/argon2id.js +++ b/lib/argon2id.js @@ -172,16 +172,22 @@ function validateParams({ type, version, tagLength, password, salt, ad, secret, secret && assertLength('secret', secret, 0, SECRETBYTES_MAX); // async options + let abortSignal; let yieldResolution; if (async) { let asyncOptions = { ...globalAsync, ...async }; async = true; - yieldResolution ??= asyncOptions.yieldResolution ?? DEFAULT_ASYNC_YIELD_RESOLUTION; + abortSignal = asyncOptions.abortSignal; + if (abortSignal) { + abortSignal = normalizeAbortSignal(abortSignal); + } + + yieldResolution = asyncOptions.yieldResolution ?? DEFAULT_ASYNC_YIELD_RESOLUTION; if (yieldResolution < 1) throw new Error('yieldResolution must be at least 1'); } - return { type, version, tagLength, password, salt, ad, secret, lanes: parallelism, memorySize, passes, async, yieldResolution }; + return { type, version, tagLength, password, salt, ad, secret, lanes: parallelism, memorySize, passes, async, abortSignal, yieldResolution }; } const KB = 1024; @@ -212,8 +218,11 @@ function argon2idSync(ctx, options) { } async function argon2idAsync(ctx, options) { + const { abortSignal, yieldResolution } = ctx; + + abortSignal?.throwIfAborted(); + // Yielding resolution control parameters. - const yieldResolution = ctx.yieldResolution; let operations = 0; let nextYield = yieldResolution; @@ -224,6 +233,9 @@ async function argon2idAsync(ctx, options) { while (!step.done) { // Yield resolution control: yield to the event loop every `yieldResolution` operations. if (++operations >= nextYield) { + // Check for abort signal before yielding to the event loop. + abortSignal?.throwIfAborted(); + // Update the next yield threshold. nextYield += yieldResolution; // yield to the event loop to allow other tasks to run await new Promise(resolve => setTimeout(resolve, 0)); @@ -416,3 +428,20 @@ function concatArrays(arrays) { return result; } + +function normalizeAbortSignal(abortSignal) { + if (!(abortSignal instanceof AbortSignal)) throw new Error('abortSignal must be an instance of AbortSignal'); + + if (abortSignal.throwIfAborted) { + return abortSignal; + } + + // Legacy support. + return { + throwIfAborted: () => { + if (abortSignal.aborted) { + throw abortSignal.reason ?? new Error('Aborted'); + } + } + } +} diff --git a/lib/setup.d.ts b/lib/setup.d.ts index f574ed7..3b11386 100644 --- a/lib/setup.d.ts +++ b/lib/setup.d.ts @@ -17,6 +17,8 @@ export interface Argon2idParams { export interface Argon2idAsyncParams extends Argon2idParams { async: true | { + /** AbortSignal to cancel the computation */ + abortSignal?: AbortSignal, /** Number of iterations after which the function yields control to the event loop */ yieldResolution?: number } diff --git a/test/argon2id.spec.ts b/test/argon2id.spec.ts index 283a91d..35ba426 100644 --- a/test/argon2id.spec.ts +++ b/test/argon2id.spec.ts @@ -116,6 +116,29 @@ describe('argon2id tests', () => { expect(ticks).to.be.equal(64); }); + + it('Async test vector, abort', async function () { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, 10) + + let error; + try { + await argon2id({ + async: { abortSignal: abortController.signal }, + password: hexToUint8Array('0101010101010101010101010101010101010101010101010101010101010101'), + salt: hexToUint8Array('02020202020202020202020202020202'), + passes: 2, memorySize: Math.pow(2, 15), parallelism: 4, tagLength: 32, + }); + } + catch (e) { + error = e; + } + + expect(error).to.equal(abortController.signal.reason); + }); }) From c489bbd5ad1cd74797fca218dd25b25176a23ef1 Mon Sep 17 00:00:00 2001 From: Rodrigo Speller Date: Sat, 27 Jun 2026 22:45:32 -0300 Subject: [PATCH 4/4] Refactor yielding logic in argon2idAsync to improve operation control --- lib/argon2id.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/argon2id.js b/lib/argon2id.js index dbf3121..890e4c6 100644 --- a/lib/argon2id.js +++ b/lib/argon2id.js @@ -222,9 +222,8 @@ async function argon2idAsync(ctx, options) { abortSignal?.throwIfAborted(); - // Yielding resolution control parameters. + // Operation counter to control yielding. let operations = 0; - let nextYield = yieldResolution; // Run the generator and yield to the event loop as needed to avoid blocking // the main thread. @@ -232,11 +231,11 @@ async function argon2idAsync(ctx, options) { let step = tasks.next(); while (!step.done) { // Yield resolution control: yield to the event loop every `yieldResolution` operations. - if (++operations >= nextYield) { + if (++operations >= yieldResolution) { // Check for abort signal before yielding to the event loop. abortSignal?.throwIfAborted(); - // Update the next yield threshold. - nextYield += yieldResolution; + // Reset the operation counter. + operations = 0; // yield to the event loop to allow other tasks to run await new Promise(resolve => setTimeout(resolve, 0)); }