feat(signer): pluggable key sources (mnemonic and raw private key)
walletFromSeed is pure derivation and needs no Provider, keeping this
module network-free. MnemonicKeySource is the familiar default;
PrivateKeyKeySource is strictly safer since it controls one account
rather than the whole HD tree. describe() is asserted never to leak key
material.
Verified rather than assumed: walletFromSeed's private key prefix is
ed25519e_sk1 and its address is addr1... (Base, Mainnet default) - the
brief's regex already matched. The brief's test mnemonic passes BIP39
checksum validation.
Hardened beyond the brief after an edge-case pass:
- Mnemonic case: bip39's wordlist lookup is case-sensitive, so an
uppercase or mixed-case mnemonic threw outright. Casing carries no
BIP39 meaning, so lowercasing is safe - now applied, and verified to
derive the identical address rather than merely "some" address.
- PrivateKeyKeySource validated the trimmed key/address but stored the
original untrimmed value - the same knownAddresses() bug shape as
policy.ts (validate trimmed, store padded, silently fail every later
exact-string comparison). Now stores what was validated.
- Empty mnemonic input reported "got 1" word (''.split(' ') === ['']),
not "got 0" - message accuracy only, both branches already rejected.
Documented, not fixed: PrivateKeyKeySource cannot verify that `address`
is actually controlled by `privateKeyBech32`. That needs CML, and this
module deliberately imports only @lucid-evolution/wallet, whose one
raw-private-key function requires a live Provider. The failure is
bounded if misused - signing needs no address, so a mismatch surfaces as
an on-chain rejection, and policy.ts's own-address matching only ever
over-refuses on a wrong address, never under-refuses.
29 tests beyond the brief's 10. 5 of the fixes above were mutation-tested
(each reverted and confirmed to fail red before restoring the fix).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
186
packages/signer/src/keysource.ts
Normal file
186
packages/signer/src/keysource.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// Key material sources. Pure derivation only: walletFromSeed needs no Provider,
|
||||
// so nothing here can reach the network.
|
||||
//
|
||||
// A raw private key is strictly safer than a mnemonic: it controls one account
|
||||
// rather than the entire HD tree, so a leak is bounded.
|
||||
//
|
||||
// SECURITY: this module handles the most sensitive data in the system. Key
|
||||
// material (mnemonics, private keys) must never reach describe(), a thrown
|
||||
// error, or a log line -- only the *source* of a failure ("mnemonic could not
|
||||
// be derived", "invalid private key"), never the value. Every catch block
|
||||
// below is commented with why the upstream message it surfaces is safe to
|
||||
// include verbatim.
|
||||
import { walletFromSeed } from '@lucid-evolution/wallet';
|
||||
|
||||
export interface SigningIdentity {
|
||||
privateKeyBech32: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface KeySource {
|
||||
/** Human-readable description for logs. MUST NOT include key material. */
|
||||
describe(): string;
|
||||
load(): SigningIdentity;
|
||||
}
|
||||
|
||||
const VALID_WORD_COUNTS = [12, 15, 18, 21, 24];
|
||||
|
||||
export class MnemonicKeySource implements KeySource {
|
||||
private readonly mnemonic: string;
|
||||
|
||||
constructor(mnemonic: string, private readonly accountIndex: number = 0) {
|
||||
// Whitespace normalisation: collapse any run of whitespace (including
|
||||
// tabs/newlines and Unicode space separators like NBSP, which JS `\s`
|
||||
// already covers) to a single space, and trim the ends. This is the
|
||||
// ONLY normalisation that can be done safely with a denylist-free,
|
||||
// positive approach for whitespace -- see the residual note below for
|
||||
// what it deliberately does not catch.
|
||||
//
|
||||
// Case normalisation: BIP39 wordlist lookup is case-sensitive
|
||||
// (`wordlist.indexOf(word)`), but casing carries no BIP39 semantic
|
||||
// weight -- a word is identified purely by which of the 2048 fixed
|
||||
// wordlist entries it matches, never by how it happens to be
|
||||
// capitalised. There is no such thing as two different valid mnemonics
|
||||
// that differ only in case, so lowercasing here cannot turn one legitimate
|
||||
// mnemonic into a different, also-legitimate one -- it can only turn an
|
||||
// otherwise-correct mnemonic that a client capitalised (autocapitalize,
|
||||
// pasted from a sentence, typed by hand) from a hard, unhelpful throw
|
||||
// into the working derivation the caller actually intended. Verified
|
||||
// empirically before relying on this: lowercasing an uppercase or
|
||||
// mixed-case rendering of a valid mnemonic reproduces the exact
|
||||
// canonical lowercase string byte-for-byte, and therefore derives the
|
||||
// exact same address -- never a different, also-plausible one.
|
||||
this.mnemonic = mnemonic.trim().split(/\s+/).join(' ').toLowerCase();
|
||||
|
||||
// `''.split(' ')` yields `['']` (length 1), not `[]` (length 0) -- the
|
||||
// same empty-string-split gotcha documented at length in policy.ts's
|
||||
// knownAddresses(). Handled explicitly so a blank/whitespace-only
|
||||
// mnemonic is reported as "got 0 words", not the misleading "got 1".
|
||||
// Either way this throws and derives nothing; this only fixes what the
|
||||
// message says.
|
||||
const words = this.mnemonic.length === 0 ? [] : this.mnemonic.split(' ');
|
||||
if (!VALID_WORD_COUNTS.includes(words.length)) {
|
||||
throw new Error(
|
||||
`Invalid mnemonic: expected 12, 15, 18, 21 or 24 words, got ${words.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Residual, deliberately not chased further (same shape as policy.ts's
|
||||
// own documented residual): a character that is invisible but is NOT
|
||||
// matched by `\s` -- e.g. U+200B ZERO WIDTH SPACE or U+2060 WORD
|
||||
// JOINER, neither of which are in ECMAScript's WhiteSpace production --
|
||||
// survives `.split(/\s+/)` glued onto whichever word it sits next to.
|
||||
// Unlike policy.ts's address matching (an exact-equality check against
|
||||
// caller-supplied strings, where a corrupted entry can silently fail to
|
||||
// match and be misclassified), this does not create a silent-wrong-
|
||||
// derivation risk: BIP39 word lookup is an exact match against a fixed,
|
||||
// closed 2048-word list, so a corrupted word can (for all practical,
|
||||
// non-adversarially-constructed input) never happen to equal a
|
||||
// different real wordlist word. The corrupted word simply fails to
|
||||
// match anything, and load() throws "Invalid mnemonic" -- confirmed
|
||||
// empirically, see keysource.test.ts. The failure is safe (loud, no
|
||||
// derivation), just not always caught at construction time the way a
|
||||
// word-count mismatch is, because the corrupted string still counts as
|
||||
// one "word" by count alone.
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return `mnemonic (account index ${this.accountIndex})`;
|
||||
}
|
||||
|
||||
load(): SigningIdentity {
|
||||
// Vector runs with the --mainnet flag, so addresses are addr1...
|
||||
let w;
|
||||
try {
|
||||
w = walletFromSeed(this.mnemonic, { network: 'Mainnet', accountIndex: this.accountIndex });
|
||||
} catch (err) {
|
||||
// Never interpolate `this.mnemonic` here. Read bip39's source
|
||||
// (mnemonicToEntropy, which walletFromSeed calls directly) to confirm
|
||||
// this before relying on it: every throw site in that function uses
|
||||
// one of four FIXED constant strings -- 'Invalid mnemonic' (wrong
|
||||
// word count, or a word absent from the wordlist), 'Invalid mnemonic
|
||||
// checksum' (right words, wrong combination), 'Invalid entropy', or a
|
||||
// wordlist-config error -- and none of them ever interpolates the
|
||||
// input. err.message is therefore safe to surface verbatim. This
|
||||
// also confirms the safety property the constructor's comment
|
||||
// depends on: a bad mnemonic throws here rather than silently
|
||||
// resolving to different, plausible-looking entropy.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Mnemonic could not be derived into a wallet: ${message}`);
|
||||
}
|
||||
return { privateKeyBech32: w.paymentKey, address: w.address };
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateKeyKeySource implements KeySource {
|
||||
private readonly privateKeyBech32: string;
|
||||
private readonly address: string;
|
||||
|
||||
// NOTE on a gap this constructor does NOT close: it validates that each
|
||||
// argument is independently well-formed, but never checks that `address`
|
||||
// is actually the address controlled by `privateKeyBech32`. A caller that
|
||||
// supplies a syntactically valid key and a syntactically valid but
|
||||
// UNRELATED address constructs and load()s successfully.
|
||||
//
|
||||
// This is a structural limit, not an oversight. Confirming the
|
||||
// correspondence would need to derive a public key (and, for the Base
|
||||
// addresses walletFromSeed produces, a stake credential too -- a Base
|
||||
// address is not a function of the payment key alone) from
|
||||
// privateKeyBech32 and compare it against the address's embedded
|
||||
// credential, which needs the Cardano Multiplatform Library. This module
|
||||
// deliberately imports ONLY `@lucid-evolution/wallet` (see
|
||||
// boundary.test.ts and the package-level import allowlist), and that
|
||||
// package's only Provider-free export is walletFromSeed, which takes a
|
||||
// MNEMONIC, not a raw key. Its one function that accepts a raw private
|
||||
// key, makeWalletFromPrivateKey, requires a live Provider -- exactly the
|
||||
// network capability this module exists to not have. There is no way to
|
||||
// perform this check without either an out-of-scope import or a network
|
||||
// round-trip, so it is left undone, deliberately, rather than partially
|
||||
// done via an import this module should not have.
|
||||
//
|
||||
// What this gap does NOT do: it does not let a mismatch pass unnoticed
|
||||
// forever, and it fails in a bounded direction. Signing itself does not
|
||||
// need the address (raw Ed25519 signing over a transaction body needs
|
||||
// only the private key), so a mismatch does not corrupt the signature --
|
||||
// it produces a witness for a DIFFERENT credential than the one the
|
||||
// transaction's inputs actually require, which is exactly the shape of
|
||||
// failure a UTxO chain is built to reject at submission. Separately, and
|
||||
// verified by reading the already-implemented policy.ts: `address` is
|
||||
// also the value fed into policy.ts's own-address change detection, which
|
||||
// only ever uses it to decide what does NOT count as outflow. A wrong
|
||||
// `address` there cannot make policy.ts treat a foreign payment as safe
|
||||
// change -- at worst it fails to recognise real change as change, which
|
||||
// policy.ts's own documentation already establishes as its deliberate
|
||||
// bias (over-refuse, never under-refuse). Both consequences are pinned as
|
||||
// tests below rather than left as assumptions.
|
||||
constructor(privateKeyBech32: string, address: string) {
|
||||
// Trimmed for the same reason MnemonicKeySource tolerates surrounding
|
||||
// whitespace: a bech32 string has no meaningful leading/trailing
|
||||
// whitespace, so trimming is pure UX (copy-paste noise) with no
|
||||
// ambiguity risk. Validate AND store the trimmed value, not the
|
||||
// original -- storing the untrimmed original after validating the
|
||||
// trimmed one is precisely the bug policy.ts's knownAddresses() had
|
||||
// (validated the trimmed form, kept the padded one, which then never
|
||||
// matched anything downstream by exact string equality). Both fields
|
||||
// here feed later exact-string comparisons too (this address flows into
|
||||
// policy.ts's ownAddresses), so the same failure shape applies.
|
||||
const trimmedKey = privateKeyBech32.trim();
|
||||
const trimmedAddress = address.trim();
|
||||
if (!/^ed25519e?_sk1[0-9a-z]+$/.test(trimmedKey)) {
|
||||
throw new Error('Invalid private key: expected a bech32 ed25519_sk / ed25519e_sk value.');
|
||||
}
|
||||
if (!/^addr1[0-9a-z]+$/.test(trimmedAddress)) {
|
||||
throw new Error('Invalid address: expected a bech32 addr1 value.');
|
||||
}
|
||||
this.privateKeyBech32 = trimmedKey;
|
||||
this.address = trimmedAddress;
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return 'raw private key';
|
||||
}
|
||||
|
||||
load(): SigningIdentity {
|
||||
return { privateKeyBech32: this.privateKeyBech32, address: this.address };
|
||||
}
|
||||
}
|
||||
371
packages/signer/test/keysource.test.ts
Normal file
371
packages/signer/test/keysource.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user