mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
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]>
193 lines
6.3 KiB
TypeScript
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();
|
|
});
|
|
});
|