Files
SeedPass/js/packages/core/test/parity.test.ts
T
thePR0M3TH3ANandClaude Fable 5 b4f20d417a cross-impl: full feature coverage (30 checks) and two more compatibility fixes
Expanded the cross-implementation suite from 19 to 30 checks so every
feature is exercised against the other implementation, not just the ones
that were convenient:

  G  edits (modify / archive / links) made by either side are seen by the other
  H  deterministic conflict merge produces byte-identical output on both sides
  I  an Argon2id-protected Python profile unlocks in TS
  J  the encrypted config file interoperates both directions

Entry coverage in phases A/B now spans every creatable kind, including a
policy-constrained password and an imported (non-deterministic) TOTP.

Two real bugs this surfaced, both fixed:

- Password entries carrying a `policy` block derived the WRONG password in
  TS. Python's _generate_password_for_entry merges the entry's overrides
  onto the base policy before deriving; TS ignored the block entirely, so
  any entry created with policy flags produced a different password.
  core now exposes passwordPolicyFromRecord and the CLI applies it.

- TS could not read a Python-written config file: Python wraps it in the
  kdf/ct JSON envelope (same class as the snapshot bug fixed last commit).
  loadConfig now parses the envelope; regression test included.

One behavioral divergence found and deliberately NOT papered over:
Python generates TOTP codes with pyotp defaults (6 digits / 30s) even when
the entry records different values, so an imported 8-digit/45s secret
yields different codes in each implementation -- and Python's would be
rejected by the issuing service. The suite now reports it as a [DIVERGE]
line rather than a pass or a failure, and the matrix records it as awaiting
a product decision. TS behavior is believed correct.

173 core + 47 CLI tests green; 30/30 cross-implementation checks green
with 2 documented divergences.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:31:49 -04:00

193 lines
6.3 KiB
TypeScript

/**
* Python/TypeScript parity tests. Every expected value comes from
* Python-generated fixtures in @seedpass/test-vectors; a failure here means
* the port diverges from the reference implementation.
*/
import { describe, expect, it } from "vitest";
import { mnemonicToSeedSync } from "@scure/bip39";
import {
bip39Cases,
bip85Cases,
passwordV1Cases,
passwordV2Cases,
totpCases,
nostrKeyCases,
managedSeedCases,
fingerprintCases,
indexKeyCases,
entriesIndex,
vaultV3Payload,
mnemonics,
type PasswordCase,
} from "@seedpass/test-vectors";
import { sha256 } from "@noble/hashes/sha2.js";
import { base64 } from "@scure/base";
import {
Bip85,
generatePassword,
passwordPolicyFromRecord,
deriveTotpSecret,
totpCodeAt,
deriveNostrKeys,
generateFingerprint,
deriveIndexKey,
deriveIndexKeyBytes,
decryptV3,
bytesToHex,
type PasswordPolicy,
} from "@seedpass/core";
function bip85For(mnemonicId: string): Bip85 {
const mnemonic = mnemonics[mnemonicId];
if (!mnemonic) throw new Error(`unknown mnemonic id ${mnemonicId}`);
return Bip85.fromMnemonic(mnemonic);
}
function toPolicy(c: PasswordCase): PasswordPolicy {
const p = c.policy_params;
return {
...(p.min_uppercase !== undefined && { minUppercase: p.min_uppercase }),
...(p.min_lowercase !== undefined && { minLowercase: p.min_lowercase }),
...(p.min_digits !== undefined && { minDigits: p.min_digits }),
...(p.min_special !== undefined && { minSpecial: p.min_special }),
...(p.include_special_chars !== undefined && {
includeSpecialChars: p.include_special_chars,
}),
...(p.allowed_special_chars !== undefined && {
allowedSpecialChars: p.allowed_special_chars,
}),
...(p.special_mode !== undefined && { specialMode: p.special_mode }),
...(p.exclude_ambiguous !== undefined && { excludeAmbiguous: p.exclude_ambiguous }),
};
}
describe("BIP-39 seed", () => {
it.each(bip39Cases)("$id", (c) => {
expect(bytesToHex(mnemonicToSeedSync(c.mnemonic, c.passphrase))).toBe(c.seed_hex);
});
});
describe("BIP-85 entropy", () => {
it.each(bip85Cases)(
"$mnemonic_id app=$app_no index=$index bytes=$entropy_bytes",
(c) => {
const entropy = bip85For(c.mnemonic_id).deriveEntropy({
index: c.index,
entropyBytes: c.entropy_bytes,
appNo: c.app_no,
...(c.word_count !== null && { wordCount: c.word_count }),
});
expect(bytesToHex(entropy)).toBe(c.entropy_hex);
},
);
});
describe("password derivation v1 (frozen)", () => {
it.each(passwordV1Cases)("$policy len=$length idx=$index", (c) => {
const password = generatePassword(bip85For(c.mnemonic_id), {
length: c.length,
index: c.index,
genVersion: 1,
policy: toPolicy(c),
});
expect(password).toBe(c.password);
});
});
describe("password derivation v2", () => {
it.each(passwordV2Cases)("$policy len=$length idx=$index", (c) => {
const password = generatePassword(bip85For(c.mnemonic_id), {
length: c.length,
index: c.index,
genVersion: 2,
policy: toPolicy(c),
});
expect(password).toBe(c.password);
});
});
describe("entry policy blocks", () => {
it("derives via the entry's stored policy, matching Python", () => {
// Python's _generate_password_for_entry merges an entry's `policy` block
// over the base policy; ignoring it silently yields a different password.
const cases = passwordV2Cases.filter((c) => Object.keys(c.policy_params).length > 0);
expect(cases.length).toBeGreaterThan(0);
for (const c of cases) {
const fromRecord = passwordPolicyFromRecord(c.policy_params);
const password = generatePassword(bip85For(c.mnemonic_id), {
length: c.length,
index: c.index,
genVersion: 2,
policy: fromRecord,
});
expect(password).toBe(c.password);
}
});
it("ignores unknown keys and a non-object policy", () => {
expect(passwordPolicyFromRecord({ nonsense: 1 })).toEqual({});
expect(passwordPolicyFromRecord(null)).toEqual({});
expect(passwordPolicyFromRecord("nope")).toEqual({});
});
});
describe("TOTP", () => {
it.each(totpCases)("$mnemonic_id idx=$index", (c) => {
const mnemonic = mnemonics[c.mnemonic_id]!;
expect(deriveTotpSecret(mnemonic, c.index)).toBe(c.secret_b32);
for (const [ts, code] of Object.entries(c.codes_at)) {
expect(totpCodeAt(c.secret_b32, Number(ts), c.period, c.digits)).toBe(code);
}
});
});
describe("Nostr keys", () => {
it.each(nostrKeyCases)("$mnemonic_id idx=$account_index", (c) => {
const keys = deriveNostrKeys(bip85For(c.mnemonic_id), c.account_index);
expect(keys.privateKeyHex).toBe(c.private_key_hex);
expect(keys.publicKeyHex).toBe(c.public_key_hex);
expect(keys.npub).toBe(c.npub);
expect(keys.nsec).toBe(c.nsec);
});
});
describe("managed seeds (BIP-85 child mnemonics)", () => {
it.each(managedSeedCases)("$mnemonic_id words=$words idx=$index", (c) => {
const child = bip85For(c.mnemonic_id).deriveMnemonic(c.index, c.words as 12 | 18 | 24);
expect(child).toBe(c.child_mnemonic);
expect(generateFingerprint(child)).toBe(c.child_fingerprint);
});
});
describe("fingerprints", () => {
it.each(fingerprintCases)("$mnemonic_id", (c) => {
expect(generateFingerprint(c.mnemonic)).toBe(c.fingerprint);
});
});
describe("vault index key", () => {
it.each(indexKeyCases)("$mnemonic_id", (c) => {
expect(deriveIndexKey(mnemonics[c.mnemonic_id]!)).toBe(c.index_key_urlsafe_b64);
});
});
describe("vault V3 payload", () => {
it("decrypts the Python-encrypted fixture", async () => {
const key = deriveIndexKeyBytes(mnemonics[vaultV3Payload.mnemonic_id]!);
const payload = base64.decode(vaultV3Payload.payload_b64);
const plaintext = await decryptV3(key, payload);
expect(bytesToHex(sha256(plaintext))).toBe(vaultV3Payload.plaintext_sha256);
const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as Record<string, unknown>;
expect(Object.keys(parsed).sort()).toEqual(Object.keys(entriesIndex.entries).sort());
});
it("rejects tampered ciphertext", async () => {
const key = deriveIndexKeyBytes(mnemonics[vaultV3Payload.mnemonic_id]!);
const payload = base64.decode(vaultV3Payload.payload_b64);
payload[payload.length - 1]! ^= 0x01;
await expect(decryptV3(key, payload)).rejects.toThrow();
});
});