Skip to content
Merged
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions packages/payments/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions packages/payments/src/crypto/argon2.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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 });
});
});
16 changes: 10 additions & 6 deletions packages/payments/src/crypto/argon2.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string> {
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,
Expand All @@ -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<MasterPasswordHashes> {
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,
Expand Down
100 changes: 100 additions & 0 deletions packages/payments/src/crypto/argon2Worker.cts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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);
});
}
28 changes: 16 additions & 12 deletions packages/payments/src/crypto/decryptAdminMacaroon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
);
Expand Down
4 changes: 2 additions & 2 deletions packages/payments/src/crypto/decryptAdminMacaroon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const { password, teamId, encryptedSymmetricKey, encryptedMacaroon } = params;
try {
const masterKey = deriveMasterKey(password, teamId);
const masterKey = await deriveMasterKey(password, teamId);
return decryptAdminMacaroonWithMasterKey({
masterKey,
encryptedSymmetricKey,
Expand Down
4 changes: 2 additions & 2 deletions packages/payments/src/resources/transactions.send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
9 changes: 4 additions & 5 deletions packages/payments/src/resources/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const { walletId } = params;
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/payments/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.cts"]
}
Loading