TypeScript `private` is compile-time only, so JSON.stringify or console.log of a KeySource printed the mnemonic or signing key in full - contradicting this module's own "never via a log line" invariant, and one natural mistake away in a package that already logs diagnostics to stderr. Native #private fields plus toJSON/inspect overrides. Also pins the default account index, which no test previously required to be 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
486 lines
26 KiB
TypeScript
486 lines
26 KiB
TypeScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import util from 'node:util';
|
|
import { MnemonicKeySource, PrivateKeyKeySource } from '../src/keysource.ts';
|
|
|
|
// Throwaway test mnemonic. Controls no funds. Never use outside tests.
|
|
const TEST_MNEMONIC =
|
|
'test walk nut penalty hip pave soap entry language right filter choice';
|
|
|
|
// A second throwaway fixture at the OTHER end of BIP39's valid word-count
|
|
// range (24, vs TEST_MNEMONIC's 12). Every other test in this file only ever
|
|
// exercises a 12-word mnemonic, which leaves VALID_WORD_COUNTS's other four
|
|
// entries (15, 18, 21, 24) completely unexercised as *accepted* input — a
|
|
// mutation dropping all of them but 12 would pass every other test in this
|
|
// file. Deterministically generated from fixed, obviously-fake entropy
|
|
// (bytes 0x00..0x1f) rather than randomly, so it is reproducible from that
|
|
// description alone. Controls no funds. Never use outside tests.
|
|
const TEST_MNEMONIC_24 =
|
|
'abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware';
|
|
|
|
describe('MnemonicKeySource', () => {
|
|
test('derives a bech32 private key and an addr1 address', () => {
|
|
const id = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
|
assert.match(id.address, /^addr1/);
|
|
});
|
|
|
|
test('derivation is deterministic', () => {
|
|
const a = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const b = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
assert.equal(a.address, b.address);
|
|
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
|
});
|
|
|
|
test('a different account index yields a different address', () => {
|
|
const a = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
|
const b = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
|
assert.notEqual(a.address, b.address);
|
|
});
|
|
|
|
test('tolerates surrounding whitespace and irregular spacing', () => {
|
|
const messy = ` ${TEST_MNEMONIC.split(' ').join(' ')} \n`;
|
|
assert.equal(new MnemonicKeySource(messy).load().address, new MnemonicKeySource(TEST_MNEMONIC).load().address);
|
|
});
|
|
|
|
test('rejects a wrong word count', () => {
|
|
assert.throws(() => new MnemonicKeySource('one two three'), /12, 15, 18, 21 or 24 words/);
|
|
});
|
|
|
|
test('describe() never reveals the mnemonic', () => {
|
|
const d = new MnemonicKeySource(TEST_MNEMONIC).describe();
|
|
assert.ok(!d.includes('walk'), 'describe() must not leak key material');
|
|
assert.match(d, /mnemonic/i);
|
|
});
|
|
});
|
|
|
|
describe('PrivateKeyKeySource', () => {
|
|
test('returns the key and address it was constructed with', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const id = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).load();
|
|
assert.equal(id.privateKeyBech32, derived.privateKeyBech32);
|
|
assert.equal(id.address, derived.address);
|
|
});
|
|
|
|
test('rejects a key that is not bech32 ed25519', () => {
|
|
assert.throws(() => new PrivateKeyKeySource('nonsense', 'addr1abc'), /private key/i);
|
|
});
|
|
|
|
test('rejects an address that is not addr1', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, 'nope'), /address/i);
|
|
});
|
|
|
|
test('describe() never reveals the key', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const d = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address).describe();
|
|
assert.ok(!d.includes(derived.privateKeyBech32), 'describe() must not leak key material');
|
|
});
|
|
});
|
|
|
|
// --- Edge-case pass (beyond the brief) --------------------------------------
|
|
//
|
|
// The brief's 10 tests are a floor. Everything below hunts specifically for
|
|
// the failure shape the last three tasks each shipped once: a defect that
|
|
// produces a plausible-looking success instead of an error. For this module
|
|
// that means: does any input ever derive a WRONG BUT VALID-LOOKING address or
|
|
// key, rather than either the correct one or a loud failure?
|
|
|
|
describe('MnemonicKeySource — case is not part of BIP39, so normalising it must be safe', () => {
|
|
// bip39's wordlist lookup (`wordlist.indexOf(word)`) is exact and
|
|
// case-sensitive — confirmed directly against this workspace's installed
|
|
// bip39 before writing this fix: an uppercase or mixed-case rendering of
|
|
// TEST_MNEMONIC throws 'Invalid mnemonic' with NO normalisation applied.
|
|
// That is a safe failure (loud, no derivation) but an unhelpful one, since
|
|
// casing carries no BIP39 meaning — a word is identified purely by which of
|
|
// the 2048 fixed wordlist entries it matches, never by how it happens to be
|
|
// capitalised, so no two distinct valid mnemonics differ only by case.
|
|
// Lowercasing before derivation therefore cannot make this module derive a
|
|
// DIFFERENT wallet than the one the correctly-cased mnemonic would — pinned
|
|
// below as deriving the SAME address, not merely "some" address.
|
|
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
|
|
test('an uppercase mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
|
const upper = new MnemonicKeySource(TEST_MNEMONIC.toUpperCase()).load();
|
|
assert.equal(upper.address, CANONICAL.address);
|
|
assert.equal(upper.privateKeyBech32, CANONICAL.privateKeyBech32);
|
|
});
|
|
|
|
test('a mixed-case mnemonic derives the identical address and key as the canonical lowercase form', () => {
|
|
const mixedWords = TEST_MNEMONIC.split(' ').map((w, i) => (i % 2 === 0 ? w.toUpperCase() : w));
|
|
const mixed = new MnemonicKeySource(mixedWords.join(' ')).load();
|
|
assert.equal(mixed.address, CANONICAL.address);
|
|
assert.equal(mixed.privateKeyBech32, CANONICAL.privateKeyBech32);
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource — a wrong-but-plausible mnemonic', () => {
|
|
// Right word count (12), every word individually present in the BIP39
|
|
// wordlist, but the checksum encoded in the final word's spare bits does
|
|
// not match the entropy in the first eleven words. Constructed by
|
|
// replacing TEST_MNEMONIC's FIRST word only: the checksum bits live in the
|
|
// last word, so tampering with any of the other eleven changes the entropy
|
|
// while (for a non-adversarially-chosen replacement) almost certainly
|
|
// failing the checksum — confirmed directly: bip39's own validateMnemonic()
|
|
// rejects this exact string. Throwaway, controls nothing; it does not even
|
|
// pass standard BIP39 validation, so no compliant wallet would derive from
|
|
// it at all.
|
|
const BAD_CHECKSUM_MNEMONIC =
|
|
'bulk walk nut penalty hip pave soap entry language right filter choice';
|
|
|
|
test('a right-word-count, invalid-checksum mnemonic does not throw at construction time', () => {
|
|
// Construction only checks word COUNT. Documents that the checksum
|
|
// problem is invisible until load() is actually called.
|
|
assert.doesNotThrow(() => new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC));
|
|
});
|
|
|
|
test('...but throws at load() time, with a checksum-specific message', () => {
|
|
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
|
assert.throws(() => source.load(), /checksum/i);
|
|
});
|
|
|
|
test('the checksum failure message reveals no word from the mnemonic', () => {
|
|
const source = new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC);
|
|
try {
|
|
source.load();
|
|
assert.fail('expected load() to throw');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
for (const word of BAD_CHECKSUM_MNEMONIC.split(' ')) {
|
|
assert.ok(!message.includes(word), `error message must not contain the word "${word}"`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a mnemonic of real English words not all found in the BIP39 wordlist also throws, not derives', () => {
|
|
// Distinct failure path from a checksum mismatch: this fails the
|
|
// wordlist lookup itself (some of these words are not among the 2048
|
|
// BIP39 entries), which is arguably the single most likely real mistake
|
|
// — fat-fingering or misremembering a word — a user actually makes.
|
|
const notAllInWordlist = 'apple banana cherry dragon eagle falcon guitar hammer island jungle kitten lantern';
|
|
assert.throws(() => new MnemonicKeySource(notAllInWordlist).load(), /invalid mnemonic/i);
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource — invisible Unicode characters between words', () => {
|
|
// This exact class of bug hit policy.ts twice: .trim() and split(/\s+/) do
|
|
// not treat every invisible character alike. NBSP (U+00A0) IS part of JS's
|
|
// \s and IS covered by ECMAScript's WhiteSpace production, so it is already
|
|
// handled correctly by .trim().split(/\s+/).join(' ') with no special
|
|
// casing — confirmed below, not assumed. ZERO WIDTH SPACE (U+200B) is
|
|
// Unicode category Cf (format), not Zs/WhiteSpace, so it is NOT matched by
|
|
// \s and survives normalisation glued onto whichever word it sits next to.
|
|
const NBSP = String.fromCharCode(0xa0);
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
const CANONICAL = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
|
|
test('a non-breaking space between every word normalises exactly like a regular space', () => {
|
|
const withNbsp = TEST_MNEMONIC.split(' ').join(NBSP);
|
|
const derived = new MnemonicKeySource(withNbsp).load();
|
|
assert.equal(derived.address, CANONICAL.address);
|
|
assert.equal(derived.privateKeyBech32, CANONICAL.privateKeyBech32);
|
|
});
|
|
|
|
test('a ZERO WIDTH SPACE glued onto a word is not silently ignored — it fails the wordlist match and throws', () => {
|
|
// Unlike policy.ts's address Set (an exact-equality lookup against
|
|
// caller-controlled strings, where a corrupted entry can silently miss a
|
|
// match and get misclassified instead of rejected), BIP39 wordlist
|
|
// lookup is an exact match against a small, fixed 2048-word list that
|
|
// contains no invisible characters. A corrupted word cannot coincidentally
|
|
// equal a different real word, so this fails loud instead of deriving a
|
|
// different, plausible-looking wallet — confirmed here, not assumed.
|
|
const words = TEST_MNEMONIC.split(' ');
|
|
const corrupted = [words[0] + ZERO_WIDTH_SPACE, ...words.slice(1)].join(' ');
|
|
const source = new MnemonicKeySource(corrupted);
|
|
assert.throws(() => source.load(), /invalid mnemonic/i);
|
|
});
|
|
|
|
test('a ZERO WIDTH SPACE used as the ONLY separator between two words fuses them, changing the word count and throwing at construction', () => {
|
|
const words = TEST_MNEMONIC.split(' ');
|
|
// Replace the space between word[0] and word[1] with a ZWSP: \s+ cannot
|
|
// split on it, so the two words fuse into a single non-whitespace token,
|
|
// dropping the apparent count from 12 to 11.
|
|
const fused = [words[0] + ZERO_WIDTH_SPACE + words[1], ...words.slice(2)].join(' ');
|
|
assert.throws(() => new MnemonicKeySource(fused), /got 11\./);
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource — the empty-string-split gotcha (same shape as policy.ts\'s knownAddresses bug)', () => {
|
|
// ''.split(' ') yields [''] (length 1), not [] (length 0). Both before and
|
|
// after accounting for this, empty/blank input is correctly REJECTED
|
|
// either way — this only pins that the reported count is accurate (0, not
|
|
// the misleading 1), not a change in whether it fails.
|
|
test('an empty string is reported as 0 words, not the misleading 1 a naive split would report', () => {
|
|
assert.throws(() => new MnemonicKeySource(''), /got 0\./);
|
|
});
|
|
|
|
test('a whitespace-only string is also reported as 0 words', () => {
|
|
assert.throws(() => new MnemonicKeySource(' \n\t '), /got 0\./);
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource — word-count breadth beyond 12', () => {
|
|
// Every test above this one exercises only a 12-word mnemonic as VALID
|
|
// input (12 also appears as an invalid COUNT case via 3/11/0-word inputs,
|
|
// but never as one of the other three accepted lengths). A mutation that
|
|
// narrowed VALID_WORD_COUNTS to just [12] would pass every test above this
|
|
// one. TEST_MNEMONIC_24 closes that gap at the opposite end of the range.
|
|
test('a valid 24-word mnemonic derives a bech32 private key and an addr1 address', () => {
|
|
const id = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
|
assert.match(id.privateKeyBech32, /^ed25519e?_sk1/);
|
|
assert.match(id.address, /^addr1/);
|
|
});
|
|
|
|
test('the 24-word mnemonic is deterministic, same as the 12-word case', () => {
|
|
const a = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
|
const b = new MnemonicKeySource(TEST_MNEMONIC_24).load();
|
|
assert.equal(a.address, b.address);
|
|
assert.equal(a.privateKeyBech32, b.privateKeyBech32);
|
|
});
|
|
});
|
|
|
|
describe('load() is idempotent — calling it twice on the SAME instance returns consistent results', () => {
|
|
test('MnemonicKeySource.load() called twice on one instance agrees with itself', () => {
|
|
const source = new MnemonicKeySource(TEST_MNEMONIC, 0);
|
|
const first = source.load();
|
|
const second = source.load();
|
|
assert.deepEqual(first, second);
|
|
});
|
|
|
|
test('PrivateKeyKeySource.load() called twice on one instance agrees with itself', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const source = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address);
|
|
const first = source.load();
|
|
const second = source.load();
|
|
assert.deepEqual(first, second);
|
|
});
|
|
});
|
|
|
|
describe('PrivateKeyKeySource — whitespace tolerance mirrors MnemonicKeySource, without weakening validation', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
|
|
test('tolerates surrounding whitespace on both the key and the address, and stores the trimmed form', () => {
|
|
// Regression guard for the exact shape of policy.ts's knownAddresses()
|
|
// bug: validating the trimmed value but STORING the original padded one
|
|
// would pass this constructor yet never match anything downstream by
|
|
// exact string equality. Asserting the loaded value equals the clean,
|
|
// untrimmed-source derivation (not just "does not throw") is what would
|
|
// catch that.
|
|
const padded = new PrivateKeyKeySource(` ${derived.privateKeyBech32} `, `\t${derived.address}\n`).load();
|
|
assert.equal(padded.privateKeyBech32, derived.privateKeyBech32);
|
|
assert.equal(padded.address, derived.address);
|
|
});
|
|
|
|
test('a trailing ZERO WIDTH SPACE on the address is rejected, not silently accepted — trim() cannot strip it, and the regex is anchored', () => {
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
assert.throws(
|
|
() => new PrivateKeyKeySource(derived.privateKeyBech32, derived.address + ZERO_WIDTH_SPACE),
|
|
/address/i
|
|
);
|
|
});
|
|
|
|
test('a trailing ZERO WIDTH SPACE on the private key is rejected, not silently accepted', () => {
|
|
const ZERO_WIDTH_SPACE = String.fromCharCode(0x200b);
|
|
assert.throws(
|
|
() => new PrivateKeyKeySource(derived.privateKeyBech32 + ZERO_WIDTH_SPACE, derived.address),
|
|
/private key/i
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('PrivateKeyKeySource — validation boundaries a loose regex mutation could silently pass', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
|
|
test('accepts the non-extended ed25519_sk1 form, not just ed25519e_sk1', () => {
|
|
// walletFromSeed only ever produces the EXTENDED form (ed25519e_sk1...),
|
|
// so nothing above this test ever exercises the regex's `e?` — every
|
|
// prior key came from walletFromSeed. A power user importing a raw key
|
|
// generated by e.g. `cardano-cli address key-gen` gets a NON-extended
|
|
// key (ed25519_sk1...) instead. Constructed here by stripping the 'e'
|
|
// from a real derived key: this only needs to be shaped like a
|
|
// non-extended bech32 key for the regex under test, not a
|
|
// cryptographically valid one, since the constructor performs no
|
|
// cryptographic check (see the documented address/key gap above) — same
|
|
// "fake but correctly shaped" approach the brief itself uses for
|
|
// 'addr1abc'.
|
|
const nonExtended = derived.privateKeyBech32.replace(/^ed25519e_sk1/, 'ed25519_sk1');
|
|
assert.doesNotThrow(() => new PrivateKeyKeySource(nonExtended, derived.address));
|
|
});
|
|
|
|
test('rejects a testnet-shaped address (addr_test1...) even though it starts with "addr"', () => {
|
|
// Vector runs mainnet-only (see MnemonicKeySource, which hardcodes
|
|
// network: 'Mainnet'), so an address for any other network is wrong
|
|
// for this system, not merely differently formatted. A regex loosened
|
|
// to `/^addr/` (matching only the four literal letters, not `addr1`
|
|
// specifically) would let this through — a realistic operator mistake
|
|
// (pasting a testnet address into a mainnet-only signer config), not
|
|
// just a contrived string.
|
|
const testnetShaped = 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w';
|
|
assert.throws(() => new PrivateKeyKeySource(derived.privateKeyBech32, testnetShaped), /address/i);
|
|
});
|
|
});
|
|
|
|
describe('PrivateKeyKeySource — address/key correspondence is NOT verified (documented, deliberate gap)', () => {
|
|
// See the long comment on PrivateKeyKeySource in keysource.ts for the full
|
|
// reasoning. Short version: verifying this would need to derive a public
|
|
// key (and, for a Base address, a stake credential too — a Base address is
|
|
// not a function of the payment key alone) from the private key and
|
|
// compare it against the address's embedded credential, which needs the
|
|
// Cardano Multiplatform Library. This module deliberately imports ONLY
|
|
// @lucid-evolution/wallet, and that package's one function taking a raw
|
|
// private key (makeWalletFromPrivateKey) requires a live Provider — the
|
|
// network capability this module exists to not have. Pinned here so the
|
|
// gap is a visible, intentional decision, not a silent assumption.
|
|
test('a syntactically valid key and a syntactically valid but unrelated address construct and load() without error', () => {
|
|
const accountA = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
|
const accountB = new MnemonicKeySource(TEST_MNEMONIC, 1).load();
|
|
// accountA's key paired with accountB's address: independently valid,
|
|
// mutually unrelated. Nothing in this module can detect that mismatch.
|
|
const mismatched = new PrivateKeyKeySource(accountA.privateKeyBech32, accountB.address).load();
|
|
assert.equal(mismatched.privateKeyBech32, accountA.privateKeyBech32);
|
|
assert.equal(mismatched.address, accountB.address);
|
|
});
|
|
});
|
|
|
|
describe('error messages never echo the rejected input, even when the input itself is invalid', () => {
|
|
// Distinct from the describe()-leak tests above: this pins that the
|
|
// CONSTRUCTOR's own validation errors never interpolate the value that was
|
|
// rejected, on the principle that a string purporting to be a private key
|
|
// should never be echoed back, valid or not.
|
|
test('the rejected private key value itself never appears in the constructor error message', () => {
|
|
const rejected = 'this-string-must-never-appear-in-the-thrown-message';
|
|
try {
|
|
new PrivateKeyKeySource(rejected, 'addr1abc');
|
|
assert.fail('expected constructor to throw');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
assert.ok(!message.includes(rejected));
|
|
}
|
|
});
|
|
|
|
test('the rejected address value itself never appears in the constructor error message', () => {
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const rejected = 'this-address-string-must-never-appear-in-the-thrown-message';
|
|
try {
|
|
new PrivateKeyKeySource(derived.privateKeyBech32, rejected);
|
|
assert.fail('expected constructor to throw');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
assert.ok(!message.includes(rejected));
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Coordinator review pass ------------------------------------------------
|
|
//
|
|
// Independent review found one real leak the tests above did not cover, and
|
|
// one unpinned default. Both reproduced first against the pre-fix code
|
|
// (confirmed RED — see signer-task-5-report.md's addendum for the transcript)
|
|
// before the fix landed.
|
|
|
|
describe('key material is unreachable at RUNTIME, not just hidden from the compiler', () => {
|
|
// TypeScript `private` erases at compile time only. At runtime an instance
|
|
// built with plain `private readonly mnemonic: string` is an ordinary
|
|
// object with an ordinary enumerable `mnemonic` property — JSON.stringify,
|
|
// util.inspect (and therefore console.log, which calls it internally),
|
|
// Object.keys, Object.getOwnPropertyNames, spread, and for...in all see it.
|
|
// Reproduced directly before writing this fix: JSON.stringify() on the
|
|
// pre-fix class printed the mnemonic in full. This package already writes
|
|
// diagnostics to stderr elsewhere (audit.ts, index.ts), so this is one
|
|
// `console.log(keySource)` away from a real leak, not a theoretical one.
|
|
const mnemonicSource = new MnemonicKeySource(TEST_MNEMONIC, 2);
|
|
const derived = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const privateKeySource = new PrivateKeyKeySource(derived.privateKeyBech32, derived.address);
|
|
|
|
// TEST_MNEMONIC's words, checked individually: a substring check for the
|
|
// whole phrase would miss a leak that reformats or partially reproduces it
|
|
// (e.g. an inspect call that line-wraps between words).
|
|
const MNEMONIC_WORDS = TEST_MNEMONIC.split(' ');
|
|
|
|
function assertNoLeak(rendered: string, label: string) {
|
|
for (const word of MNEMONIC_WORDS) {
|
|
assert.ok(!rendered.includes(word), `${label} must not contain the mnemonic word "${word}"`);
|
|
}
|
|
assert.ok(!rendered.includes(derived.privateKeyBech32), `${label} must not contain the private key`);
|
|
}
|
|
|
|
test('JSON.stringify(MnemonicKeySource instance) leaks nothing', () => {
|
|
assertNoLeak(JSON.stringify(mnemonicSource), 'JSON.stringify');
|
|
});
|
|
|
|
test('util.inspect(MnemonicKeySource instance) leaks nothing (covers console.log too, which calls util.inspect internally)', () => {
|
|
assertNoLeak(util.inspect(mnemonicSource), 'util.inspect');
|
|
});
|
|
|
|
test('Object.keys(MnemonicKeySource instance) no longer exposes the "mnemonic" property name, and accountIndex (not secret) remains', () => {
|
|
// Object.keys returns property NAMES, which for the secret field are
|
|
// never themselves derived from the mnemonic's VALUE — the meaningful
|
|
// assertion is that the #private field is not enumerable at all.
|
|
// accountIndex is explicitly not secret and is allowed to stay visible.
|
|
assert.deepEqual(Object.keys(mnemonicSource), ['accountIndex']);
|
|
});
|
|
|
|
test('Object.values(MnemonicKeySource instance) leaks nothing (the value-exposure half Object.keys alone does not test)', () => {
|
|
assertNoLeak(JSON.stringify(Object.values(mnemonicSource)), 'Object.values');
|
|
});
|
|
|
|
test('JSON.stringify(PrivateKeyKeySource instance) leaks nothing', () => {
|
|
assertNoLeak(JSON.stringify(privateKeySource), 'JSON.stringify');
|
|
});
|
|
|
|
test('util.inspect(PrivateKeyKeySource instance) leaks nothing (covers console.log too)', () => {
|
|
assertNoLeak(util.inspect(privateKeySource), 'util.inspect');
|
|
});
|
|
|
|
test('Object.keys(PrivateKeyKeySource instance) exposes no property names at all — both fields are secret', () => {
|
|
assert.deepEqual(Object.keys(privateKeySource), []);
|
|
});
|
|
|
|
test('Object.values(PrivateKeyKeySource instance) leaks nothing', () => {
|
|
assertNoLeak(JSON.stringify(Object.values(privateKeySource)), 'Object.values');
|
|
});
|
|
|
|
test('JSON.stringify still produces something useful (describe()\'s string), not an uninformative {}', () => {
|
|
// Defence-in-depth check: toJSON()/inspect.custom exist so an accidental
|
|
// serialisation attempt is still legible, not merely safe. Confirms the
|
|
// override actually engages rather than JSON.stringify silently falling
|
|
// back to `{}` because every enumerable property happened to be gone.
|
|
assert.equal(JSON.stringify(mnemonicSource), JSON.stringify(mnemonicSource.describe()));
|
|
assert.equal(JSON.stringify(privateKeySource), JSON.stringify(privateKeySource.describe()));
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource — the default account index is pinned to 0', () => {
|
|
// keysource.test.ts previously only ever compared an EXPLICIT 0 against an
|
|
// EXPLICIT 1 ("a different account index yields a different address");
|
|
// nothing tied the OMITTED-argument default itself to 0. A silent
|
|
// regression changing the default would derive a different, validly
|
|
// formatted wallet for every caller that omits the argument — and since
|
|
// policy.ts matches change outputs by exact address, that would make
|
|
// every real transaction look like a full outflow and get refused.
|
|
test('a default-constructed instance derives the identical address as an explicit accountIndex: 0', () => {
|
|
const defaulted = new MnemonicKeySource(TEST_MNEMONIC).load();
|
|
const explicit = new MnemonicKeySource(TEST_MNEMONIC, 0).load();
|
|
assert.equal(defaulted.address, explicit.address);
|
|
assert.equal(defaulted.privateKeyBech32, explicit.privateKeyBech32);
|
|
});
|
|
});
|
|
|
|
describe('MnemonicKeySource.load() — the error-wrap prefix is pinned', () => {
|
|
// Removing the try/catch around walletFromSeed entirely still lets the
|
|
// underlying bip39 error ("Invalid mnemonic checksum") through unwrapped,
|
|
// which satisfied every /checksum/i-style assertion above without the
|
|
// wrap actually being present. This pins the wrap itself.
|
|
const BAD_CHECKSUM_MNEMONIC =
|
|
'bulk walk nut penalty hip pave soap entry language right filter choice';
|
|
|
|
test('load() failure is prefixed with the source-attributing wrap message', () => {
|
|
// assert.throws matches against error.toString() ('Error: <message>'),
|
|
// not error.message directly, hence no leading ^ anchor here.
|
|
assert.throws(
|
|
() => new MnemonicKeySource(BAD_CHECKSUM_MNEMONIC).load(),
|
|
/Mnemonic could not be derived into a wallet: Invalid mnemonic checksum/
|
|
);
|
|
});
|
|
});
|