Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
sealSeed,
|
|
generateSeed,
|
|
deriveAccountXpubs,
|
|
UnlockSession,
|
|
verifyPassphrase,
|
|
exportMnemonic,
|
|
changePassphrase,
|
|
} from './keys';
|
|
|
|
/**
|
|
* The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
|
|
* merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
|
|
* properties below are asserted rather than assumed.
|
|
*
|
|
* scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
|
|
* parameter and also why this file carries a raised timeout instead of a smaller N.
|
|
*/
|
|
|
|
// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
|
|
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
const ROOT_FINGERPRINT = '73c5da0a';
|
|
const PASS = 'correct horse battery staple';
|
|
|
|
const SLOW = 60_000;
|
|
|
|
describe('sealSeed / deriveAccountXpubs', () => {
|
|
test(
|
|
'seals the vector and derives all four accounts',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
|
|
|
expect(env.v).toBe(1);
|
|
expect(fingerprint).toBe(ROOT_FINGERPRINT);
|
|
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'the envelope never contains the plaintext mnemonic',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
expect(JSON.stringify(env)).not.toContain('abandon');
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'sealing is non-deterministic but derivation is stable',
|
|
async () => {
|
|
// Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
|
|
// DB dump would reveal which wallets share a seed.
|
|
const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
|
|
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
|
|
|
|
const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
|
|
expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'rejects a mnemonic that fails its checksum',
|
|
async () => {
|
|
const bad = VECTOR.replace(/about$/, 'abandon');
|
|
await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'rejects a passphrase under the minimum length',
|
|
async () => {
|
|
await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'testnet derives a different account under the same root',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const [main, test_] = [
|
|
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
|
await deriveAccountXpubs(env, PASS, 'testnet'),
|
|
];
|
|
// Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
|
|
expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
|
|
expect(test_.fingerprint).toBe(main.fingerprint);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|
|
|
|
/**
|
|
* A throwaway wallet id per call. Every passphrase check is now rate-limited per wallet id in module
|
|
* state, so tests that share one would start locking each other out after five wrong guesses.
|
|
*/
|
|
let idSeq = 0;
|
|
const wid = () => ++idSeq;
|
|
|
|
describe('passphrase handling', () => {
|
|
test(
|
|
'accepts the right passphrase and rejects a wrong one',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
expect(await verifyPassphrase(wid(), env, PASS)).toBe(true);
|
|
expect(await verifyPassphrase(wid(), env, 'not the passphrase')).toBe(false);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'locks out after five wrong guesses, on export as well as unlock',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const id = wid();
|
|
|
|
// The backoff used to live inside UnlockSession.unlock only, so export-seed — which returns the
|
|
// words in the clear — took unlimited guesses. Exercised through exportMnemonic for that reason.
|
|
for (let i = 0; i < 5; i++) {
|
|
await expect(exportMnemonic(id, env, `wrong ${i}`)).rejects.toThrow(/incorrect passphrase/);
|
|
}
|
|
await expect(exportMnemonic(id, env, `wrong 5`)).rejects.toThrow(/too many failed attempts/);
|
|
// The right passphrase is refused too — otherwise the lockout would only slow a guesser down.
|
|
await expect(exportMnemonic(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
|
|
// And it is not folded into a bland `false` by the boolean-returning sibling.
|
|
await expect(verifyPassphrase(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'round-trips the mnemonic exactly',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
expect(await exportMnemonic(wid(), env, PASS)).toBe(VECTOR);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'rotation swaps the passphrase without disturbing the seed',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const next = 'an entirely different passphrase';
|
|
const rotated = await changePassphrase(wid(), env, PASS, next);
|
|
|
|
expect(await verifyPassphrase(wid(), rotated, next)).toBe(true);
|
|
expect(await verifyPassphrase(wid(), rotated, PASS)).toBe(false);
|
|
expect(await exportMnemonic(wid(), rotated, next)).toBe(VECTOR);
|
|
|
|
// The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
|
|
// start watching a different account and report a zero balance.
|
|
const [before, after] = [
|
|
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
|
await deriveAccountXpubs(rotated, next, 'bitcoin'),
|
|
];
|
|
expect(after.xpubs[84]).toBe(before.xpubs[84]);
|
|
expect(after.fingerprint).toBe(before.fingerprint);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|
|
|
|
describe('UnlockSession', () => {
|
|
test('holds no key material until unlocked', async () => {
|
|
const s = new UnlockSession(1);
|
|
expect(s.isUnlocked()).toBe(false);
|
|
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
|
});
|
|
|
|
test(
|
|
'unlocks, derives the stored xpub, then relocks',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
|
|
|
const s = new UnlockSession(1);
|
|
await s.unlock(env, PASS, 60);
|
|
expect(s.isUnlocked()).toBe(true);
|
|
expect(s.secondsRemaining()).toBeGreaterThan(0);
|
|
expect(s.secondsRemaining()).toBeLessThanOrEqual(60);
|
|
|
|
// Proves the unlocked root is the same key the watch-only xpub came from.
|
|
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
|
|
|
s.lock();
|
|
expect(s.isUnlocked()).toBe(false);
|
|
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'a failed unlock leaves the session locked',
|
|
async () => {
|
|
const env = await sealSeed(VECTOR, PASS);
|
|
const s = new UnlockSession(1);
|
|
await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
|
|
expect(s.isUnlocked()).toBe(false);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|
|
|
|
describe('aezeed seeds', () => {
|
|
// The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy
|
|
// is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third
|
|
// specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer.
|
|
const AEZEED =
|
|
'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
|
|
const BIP32_VECTOR_1_FINGERPRINT = '3442193e';
|
|
|
|
test(
|
|
'seals as v2 and derives the BIP32 vector root',
|
|
async () => {
|
|
const env = await sealSeed(AEZEED, PASS);
|
|
expect(env.v).toBe(2);
|
|
|
|
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
|
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
|
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'does NOT derive what the BIP39 path would have',
|
|
async () => {
|
|
// The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist
|
|
// words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely
|
|
// wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan.
|
|
const { mnemonicToSeedSync } = await import('@scure/bip39');
|
|
const { HDKey } = await import('@scure/bip32');
|
|
const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED));
|
|
const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer))
|
|
.reverse()
|
|
.toString('hex');
|
|
expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT);
|
|
|
|
const env = await sealSeed(AEZEED, PASS);
|
|
const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
|
expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'the words survive an export round trip',
|
|
async () => {
|
|
const env = await sealSeed(AEZEED, PASS);
|
|
expect(await exportMnemonic(wid(), env, PASS)).toBe(AEZEED);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'unlocking derives the same root as the stored xpub',
|
|
async () => {
|
|
const env = await sealSeed(AEZEED, PASS);
|
|
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
|
|
|
const s = new UnlockSession(2);
|
|
await s.unlock(env, PASS, 60);
|
|
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
|
s.lock();
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'a wrong seed passphrase is refused at seal time, not silently accepted',
|
|
async () => {
|
|
// AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal
|
|
// itself later as an empty balance — the single most expensive way to find out.
|
|
await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'a phrase that is neither BIP39 nor aezeed is still rejected',
|
|
async () => {
|
|
const bad = AEZEED.split(' ');
|
|
[bad[3], bad[4]] = [bad[4]!, bad[3]!];
|
|
await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
test(
|
|
'rotation preserves the aezeed kind and its derivation',
|
|
async () => {
|
|
const env = await sealSeed(AEZEED, PASS);
|
|
const next = 'an entirely different passphrase';
|
|
const rotated = await changePassphrase(wid(), env, PASS, next);
|
|
|
|
expect(rotated.v).toBe(2);
|
|
const after = await deriveAccountXpubs(rotated, next, 'bitcoin');
|
|
expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|
|
|
|
describe('generateSeed', () => {
|
|
test(
|
|
'produces a distinct, valid mnemonic that survives a round trip',
|
|
async () => {
|
|
const mnemonic = generateSeed();
|
|
expect([12, 24]).toContain(mnemonic.split(' ').length);
|
|
expect(generateSeed()).not.toBe(mnemonic);
|
|
|
|
const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
|
|
expect(await exportMnemonic(wid(), env, 'a sufficiently long passphrase')).toBe(mnemonic);
|
|
},
|
|
SLOW,
|
|
);
|
|
});
|