diff --git a/packages/signer/src/keysource.ts b/packages/signer/src/keysource.ts new file mode 100644 index 0000000..bdf4b39 --- /dev/null +++ b/packages/signer/src/keysource.ts @@ -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 }; + } +} diff --git a/packages/signer/test/keysource.test.ts b/packages/signer/test/keysource.test.ts new file mode 100644 index 0000000..3529883 --- /dev/null +++ b/packages/signer/test/keysource.test.ts @@ -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)); + } + }); +});