diff --git a/AGENTS.md b/AGENTS.md index c87ca05..e51ffde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,8 +112,8 @@ Resource getters are lazy and call `requireServiceApiKey`: the map. - `forgetSend` mid-preparation wins: a result landing afterwards is discarded rather than resurrecting the macaroon. -- Argon2id is **synchronous** and blocks the event loop for seconds — prepare is - `async` because of the API calls, not because key derivation yields. +- Argon2id runs on a shared worker thread, so it does not block the event loop. + Prepare is still asynchronous because it includes API calls and CPU work. #### Webhooks diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 60cf777..9670b58 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -180,10 +180,9 @@ Three things to plan around: - **Drop the `password` from prepared sends.** It is what makes them fast: a `send` that carries a password derives from scratch every time, prepared or not. Keep passing it only where you have not prepared the wallet. -- **Argon2id blocks the event loop.** It is synchronous CPU work; `await` does - not make it yield. Prepare during startup or a warm-up hook, never inside a - request handler. The constructor `send` option starts it in the background but - the block still happens — just early, while you have no traffic. +- **Argon2id runs on a shared worker thread.** Its CPU work does not block the + event loop. Prepare during startup or a warm-up hook to avoid that latency on + the first payment request. The constructor `send` option starts it in the background. - **You are holding node admin credentials in memory** for as long as the wallet stays prepared, which is what makes sends fast. Call `payments.transactions.forgetSend(walletId)` to release them, and to pick up diff --git a/packages/payments/README.md b/packages/payments/README.md index 3c7a47c..5c8d1c0 100644 --- a/packages/payments/README.md +++ b/packages/payments/README.md @@ -262,8 +262,9 @@ to see the error at startup. Notes: -- **Argon2id blocks the event loop** while it runs; it is synchronous, CPU-bound - work that no amount of `await` yields on. Prepare at startup, not mid-request. +- **Argon2id runs on a shared worker thread**, so its CPU-bound work does not + block the event loop. Prepare at startup to avoid its latency on the first + payment request. - Each wallet costs its own derivation, so a long `send` list takes a while. Entries are prepared one at a time (running them concurrently would not overlap anything). diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index 335923c..df88a99 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -40,8 +40,8 @@ export class Payments extends AmbossClient { /** * Fire-and-forget pre-warm of the configured wallets. Sequential on purpose: - * Argon2id is synchronous and CPU-bound, so running the wallets concurrently - * would interleave nothing and only delay the first one becoming ready. + * each wallet needs two memory-hard Argon2id passes, so limiting work to one + * wallet at a time avoids excessive memory pressure during startup. * * Poll `transactions.isSendReady(walletId)` to see when a wallet is done, or * `await transactions.prepareSend(...)` instead of using this option when you diff --git a/packages/payments/src/crypto/argon2.test.ts b/packages/payments/src/crypto/argon2.test.ts new file mode 100644 index 0000000..c0f5d2b --- /dev/null +++ b/packages/payments/src/crypto/argon2.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { ARGON2_PARAMS, createMasterPasswordHash, deriveMasterKey } from './argon2.js'; + +const PASSWORD = 'correct horse'; +const TEAM_ID = 'Team-XYZ'; +const EXPECTED_MASTER_KEY = 'f63aa9f891f1708717a8a77e3d50f71d5230013d96957c239959689dda858265'; +const EXPECTED_MASTER_PASSWORD_HASH = + '45181dcc67b7646fc9f0a318dc30201c31cfefa3260fbcc71e31d2b3f256881b'; + +describe('Argon2id worker', () => { + it('preserves the master key and password hash outputs', async () => { + assert.equal(await deriveMasterKey(PASSWORD, TEAM_ID), EXPECTED_MASTER_KEY); + assert.deepEqual(await createMasterPasswordHash(PASSWORD, TEAM_ID), { + masterKey: EXPECTED_MASTER_KEY, + masterPasswordHash: EXPECTED_MASTER_PASSWORD_HASH, + }); + }); + + it('does not block the event loop while hashing', async () => { + const timerFired = new Promise((resolve) => { + const start = Date.now(); + setTimeout(() => resolve(Date.now() - start), 20); + }); + + const hashPromise = deriveMasterKey(PASSWORD, TEAM_ID); + assert.ok((await timerFired) < 500); + await hashPromise; + }); + + it('keeps the required Argon2id parameters', () => { + assert.deepEqual(ARGON2_PARAMS, { dkLen: 32, t: 3, m: 64000, p: 4 }); + }); +}); diff --git a/packages/payments/src/crypto/argon2.ts b/packages/payments/src/crypto/argon2.ts index 7c01077..ca07271 100644 --- a/packages/payments/src/crypto/argon2.ts +++ b/packages/payments/src/crypto/argon2.ts @@ -1,6 +1,7 @@ -import { argon2id } from '@noble/hashes/argon2'; import { bytesToHex } from '@noble/hashes/utils'; +import { runArgon2id } from './argon2Worker.cjs'; + /** * Argon2id parameters. These MUST match the values used by the amboss-rails UI * (`src/lib/utils/crypto/createMasterPasswordHash.ts`, via `argon2-browser`) and @@ -21,10 +22,10 @@ export const ARGON2_PARAMS = { * Derives the master key (hex) from the team password and team id. * The team id is used as the Argon2 salt (trimmed + lowercased), matching the UI. */ -export function deriveMasterKey(password: string, teamId: string): string { +export async function deriveMasterKey(password: string, teamId: string): Promise { const salt = teamId.trim().toLowerCase(); const key = password.trim(); - const hash = argon2id(key, salt, { + const hash = await runArgon2id(key, salt, { dkLen: ARGON2_PARAMS.dkLen, t: ARGON2_PARAMS.t, m: ARGON2_PARAMS.m, @@ -48,10 +49,13 @@ export interface MasterPasswordHashes { * `masterKey` decrypts the symmetric key locally; `masterPasswordHash` proves * knowledge of the password to the server when reading `node_permissions`. */ -export function createMasterPasswordHash(password: string, teamId: string): MasterPasswordHashes { - const masterKey = deriveMasterKey(password, teamId); +export async function createMasterPasswordHash( + password: string, + teamId: string, +): Promise { + const masterKey = await deriveMasterKey(password, teamId); const masterPasswordHash = bytesToHex( - argon2id(masterKey, password.trim(), { + await runArgon2id(masterKey, password.trim(), { dkLen: ARGON2_PARAMS.dkLen, t: ARGON2_PARAMS.t, m: ARGON2_PARAMS.m, diff --git a/packages/payments/src/crypto/argon2Worker.cts b/packages/payments/src/crypto/argon2Worker.cts new file mode 100644 index 0000000..6ec5677 --- /dev/null +++ b/packages/payments/src/crypto/argon2Worker.cts @@ -0,0 +1,100 @@ +import { Worker } from 'node:worker_threads'; + +export interface RunArgon2idOpts { + dkLen: number; + t: number; + m: number; + p: number; +} + +interface HashRequest { + id: number; + password: string; + salt: string; + opts: RunArgon2idOpts; +} + +interface HashResponse { + id: number; + hash?: Uint8Array; + error?: string; +} + +const WORKER_SOURCE = ` +const { parentPort, workerData } = require('node:worker_threads'); +const { argon2id } = require(workerData.argon2Path); +parentPort.on('message', (msg) => { + try { + const hash = argon2id(msg.password, msg.salt, msg.opts); + parentPort.postMessage({ id: msg.id, hash }); + } catch (err) { + parentPort.postMessage({ id: msg.id, error: err instanceof Error ? err.message : String(err) }); + } +}); +`; + +let worker: Worker | undefined; +let nextId = 0; +let idleTimer: NodeJS.Timeout | undefined; +const pending = new Map< + number, + { resolve: (hash: Uint8Array) => void; reject: (err: Error) => void } +>(); + +function failAllPending(err: Error): void { + for (const job of pending.values()) job.reject(err); + pending.clear(); + if (idleTimer) clearTimeout(idleTimer); + idleTimer = undefined; + worker = undefined; +} + +function scheduleUnref(created: Worker): void { + if (idleTimer) clearTimeout(idleTimer); + // Keep the worker briefly referenced so callers that chain KDFs (including + // Node's test runner) can start their next job before an otherwise-idle + // process exits. The worker is still released promptly for short-lived CLIs. + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (pending.size === 0) created.unref(); + }, 1_000); +} + +function getWorker(): Worker { + if (worker) return worker; + + const argon2Path = require.resolve('@noble/hashes/argon2'); + const created = new Worker(WORKER_SOURCE, { eval: true, workerData: { argon2Path } }); + + created.on('message', (res: HashResponse) => { + const job = pending.get(res.id); + if (!job) return; + pending.delete(res.id); + if (res.error) job.reject(new Error(res.error)); + else job.resolve(res.hash as Uint8Array); + if (pending.size === 0) scheduleUnref(created); + }); + created.on('error', failAllPending); + created.on('exit', () => failAllPending(new Error('Argon2 worker exited unexpectedly'))); + + worker = created; + return created; +} + +/** Runs Argon2id on a shared background worker so it never blocks Node's event loop. */ +export function runArgon2id( + password: string, + salt: string, + opts: RunArgon2idOpts, +): Promise { + return new Promise((resolve, reject) => { + const w = getWorker(); + if (idleTimer) clearTimeout(idleTimer); + idleTimer = undefined; + const id = nextId++; + pending.set(id, { resolve, reject }); + w.ref(); + const request: HashRequest = { id, password, salt, opts }; + w.postMessage(request); + }); +} diff --git a/packages/payments/src/crypto/decryptAdminMacaroon.test.ts b/packages/payments/src/crypto/decryptAdminMacaroon.test.ts index ffa1b94..f83bded 100644 --- a/packages/payments/src/crypto/decryptAdminMacaroon.test.ts +++ b/packages/payments/src/crypto/decryptAdminMacaroon.test.ts @@ -13,8 +13,8 @@ import { nip44Encrypt } from './nip44.js'; const SYMMETRIC_KEY = bytesToHex(new Uint8Array(64).map((_, i) => (i * 7 + 3) & 0xff)); const MACAROON = '0201036c6e640224030a1077656c636f6d652d746f2d616d626f7373'; -function buildFixture(password: string, teamId: string) { - const masterKey = deriveMasterKey(password, teamId); +async function buildFixture(password: string, teamId: string) { + const masterKey = await deriveMasterKey(password, teamId); return { encryptedSymmetricKey: nip44Encrypt(SYMMETRIC_KEY, masterKey), encryptedMacaroon: nip44Encrypt(MACAROON, SYMMETRIC_KEY), @@ -37,30 +37,34 @@ describe('argon2id (KDF)', () => { assert.equal(tag, '0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659'); }); - it('derives a stable 32-byte master key (regression lock)', () => { - const key = deriveMasterKey('correct horse', 'Team-XYZ'); + it('derives a stable 32-byte master key (regression lock)', async () => { + const key = await deriveMasterKey('correct horse', 'Team-XYZ'); assert.equal(hexToBytes(key).length, 32); assert.equal(key, 'f63aa9f891f1708717a8a77e3d50f71d5230013d96957c239959689dda858265'); }); - it('treats the team id as a trimmed, lowercased salt', () => { - assert.equal(deriveMasterKey('pw', 'Team-XYZ'), deriveMasterKey('pw', ' team-xyz ')); + it('treats the team id as a trimmed, lowercased salt', async () => { + const [a, b] = await Promise.all([ + deriveMasterKey('pw', 'Team-XYZ'), + deriveMasterKey('pw', ' team-xyz '), + ]); + assert.equal(a, b); }); }); describe('decryptAdminMacaroon', () => { - it('recovers the macaroon through the two-layer envelope', () => { + it('recovers the macaroon through the two-layer envelope', async () => { const password = 'hunter2'; const teamId = '11111111-1111-1111-1111-111111111111'; - const fixture = buildFixture(password, teamId); - const result = decryptAdminMacaroon({ password, teamId, ...fixture }); + const fixture = await buildFixture(password, teamId); + const result = await decryptAdminMacaroon({ password, teamId, ...fixture }); assert.equal(result, MACAROON); }); - it('throws DecryptionError on a wrong password', () => { + it('throws DecryptionError on a wrong password', async () => { const teamId = '11111111-1111-1111-1111-111111111111'; - const fixture = buildFixture('hunter2', teamId); - assert.throws( + const fixture = await buildFixture('hunter2', teamId); + await assert.rejects( () => decryptAdminMacaroon({ password: 'wrong', teamId, ...fixture }), DecryptionError, ); diff --git a/packages/payments/src/crypto/decryptAdminMacaroon.ts b/packages/payments/src/crypto/decryptAdminMacaroon.ts index f6e2c33..c230485 100644 --- a/packages/payments/src/crypto/decryptAdminMacaroon.ts +++ b/packages/payments/src/crypto/decryptAdminMacaroon.ts @@ -25,10 +25,10 @@ export interface DecryptAdminMacaroonParams { * authenticate directly against the node. Throws {@link DecryptionError} on any * failure (almost always a wrong password). */ -export function decryptAdminMacaroon(params: DecryptAdminMacaroonParams): string { +export async function decryptAdminMacaroon(params: DecryptAdminMacaroonParams): Promise { const { password, teamId, encryptedSymmetricKey, encryptedMacaroon } = params; try { - const masterKey = deriveMasterKey(password, teamId); + const masterKey = await deriveMasterKey(password, teamId); return decryptAdminMacaroonWithMasterKey({ masterKey, encryptedSymmetricKey, diff --git a/packages/payments/src/resources/transactions.send.test.ts b/packages/payments/src/resources/transactions.send.test.ts index 54f6a30..d7e27de 100644 --- a/packages/payments/src/resources/transactions.send.test.ts +++ b/packages/payments/src/resources/transactions.send.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { createServer, type Server, type ServerResponse } from 'node:http'; import { afterEach, describe, it } from 'node:test'; +import { argon2id } from '@noble/hashes/argon2'; import { bytesToHex } from '@noble/hashes/utils'; import type { GraphQLClient } from 'graphql-request'; -import { deriveMasterKey } from '../crypto/argon2.js'; import { nip44Encrypt } from '../crypto/nip44.js'; import { Transactions } from './transactions.js'; @@ -50,7 +50,7 @@ function fakeClient( createSendTransaction: object = { id: 'tx1', status: 'PENDING', payment_request: 'lnbc1xyz' }, walletTeamId: string = TEAM_ID, ): GraphQLClient { - const masterKey = deriveMasterKey(PASSWORD, TEAM_ID); + const masterKey = bytesToHex(argon2id(PASSWORD, TEAM_ID, { dkLen: 32, t: 3, m: 64000, p: 4 })); const encrypted_symmetric_key = nip44Encrypt(SYMMETRIC_KEY, masterKey); const encrypted_macaroon = nip44Encrypt(MACAROON_HEX, SYMMETRIC_KEY); diff --git a/packages/payments/src/resources/transactions.ts b/packages/payments/src/resources/transactions.ts index 6a1d365..5ca7414 100644 --- a/packages/payments/src/resources/transactions.ts +++ b/packages/payments/src/resources/transactions.ts @@ -97,10 +97,9 @@ export class Transactions { * and concurrent calls for one wallet share a single derivation. Call * {@link forgetSend} first to re-derive after credentials rotate. * - * **Blocks the event loop.** Argon2id is synchronous and CPU-bound - * (m=64 MiB, t=3, p=4); this method is `async` because of the API calls, not - * because the key derivation yields. Prepare during startup, not while - * serving requests. + * Argon2id is CPU-bound (m=64 MiB, t=3, p=4), but runs on a shared worker + * thread so it does not block the event loop. Preparing ahead of time still + * avoids that work on the first payment request. */ async prepareSend(params: PrepareSendParams): Promise { const { walletId } = params; @@ -283,7 +282,7 @@ export class Transactions { throw new PaymentSendError('A team password is required to send from a live wallet.'); } const teamId = params.teamId ?? walletCtx.team_id; - const { masterKey, masterPasswordHash } = createMasterPasswordHash(password, teamId); + const { masterKey, masterPasswordHash } = await createMasterPasswordHash(password, teamId); // 3. Resolve the node + its credentials — node_permissions is gated on the // password hash, so a wrong password is rejected here before any payment. diff --git a/packages/payments/tsconfig.json b/packages/payments/tsconfig.json index c99ec7b..ab8bded 100644 --- a/packages/payments/tsconfig.json +++ b/packages/payments/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.cts"] }