cross-impl: prove Python/TS profile interop, fix two compatibility bugs

Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.

scripts/cross_impl_check.py (12 checks, wired into CI):
  A. Python creates a profile -> TS opens the index, derives every secret
     identically, and unlocks it with the master password alone
  B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
     recovers the same seed, derives the same secrets
  C. Portable backups round-trip both directions (Python verifies the
     checksum of a TS-written export)
  D. Both implementations reject a bad-checksum mnemonic

Two real compatibility bugs it caught, both fixed:

- TS-created profiles were unopenable by Python. Python only honors the
  recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
  TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
  Python fell back to its config defaults (200k, fingerprint salt) and
  derived a different key. TS now writes exactly what
  PasswordManager._build_seed_kdf_config does, and its own unlock path
  honors the recorded salt and iteration count.

- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
  so a typo'd phrase silently produced a *different* vault -- unopenable
  by Python and unrecoverable by the user. Mnemonics are now validated
  wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
  error that says why it matters.

Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.

Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 16:15:19 -04:00
1 parent ddde7c5e52
commit 0f8ce35c47
8 files changed
+730 -11

No files matched your search

+47 -7
View File
@@ -16,16 +16,31 @@ import process from "node:process";
import { base64url } from "@scure/base";
import {
generateFingerprint,
assertValidMnemonic,
deriveKeyFromPassword,
deriveKeyFromPasswordArgon2,
deriveIndexKeyBytes,
decryptPayload,
encryptV3,
parseEncryptedFile,
sha256Hex,
hexToBytes,
utf8,
type KdfConfig,
} from "@seedpass/core";
/**
* Python's ConfigManager default. Profile KDF metadata must match what the
* Python app writes, or Python falls back to its config defaults and derives
* a different key — making TS-created profiles unreadable there.
*/
export const DEFAULT_PBKDF2_ITERATIONS = 200_000;
/** Python's seed-KDF salt: sha256(fingerprint)[:16]. */
function seedKdfSalt(fingerprint: string): Uint8Array {
return hexToBytes(sha256Hex(utf8(fingerprint))).slice(0, 16);
}
export const INDEX_FILENAME = "seedpass_entries_db.json.enc";
export const CONFIG_FILENAME = "seedpass_config.json.enc";
export const PARENT_SEED_FILENAME = "parent_seed.enc";
@@ -72,6 +87,11 @@ export class AppDir {
/** Create a profile from a mnemonic; returns its fingerprint. */
async createProfile(mnemonic: string, password: string, name?: string): Promise<string> {
// Reject typo'd phrases here: an invalid mnemonic still derives *a* seed,
// so accepting one creates a vault that cannot be recovered or opened by
// any other implementation.
assertValidMnemonic(mnemonic, "parent seed");
if (!password) throw new Error("a master password is required");
const fingerprint = generateFingerprint(mnemonic);
const data = await this.readFingerprints();
if (data.fingerprints.includes(fingerprint)) {
@@ -80,14 +100,19 @@ export class AppDir {
const dir = this.profileDir(fingerprint);
await mkdir(dir, { recursive: true, mode: 0o700 });
// parent_seed.enc: kdf/ct wrapper around a V3 blob under the password key
const seedKey = base64url.decode(deriveKeyFromPassword(password, fingerprint));
// parent_seed.enc: kdf/ct wrapper around a V3 blob under the password key.
// Shape and parameters mirror PasswordManager._build_seed_kdf_config so
// the Python implementation can open this profile.
const salt = seedKdfSalt(fingerprint);
const seedKey = base64url.decode(
deriveKeyFromPassword(password, salt, DEFAULT_PBKDF2_ITERATIONS),
);
const ct = await encryptV3(seedKey, utf8(mnemonic));
const kdf: KdfConfig = {
name: "pbkdf2-sha256",
name: "pbkdf2",
version: 1,
params: { iterations: 100000 },
salt_b64: "",
params: { iterations: DEFAULT_PBKDF2_ITERATIONS },
salt_b64: Buffer.from(salt).toString("base64"),
};
const wrapper = JSON.stringify({
kdf,
@@ -141,12 +166,27 @@ export class AppDir {
const blob = new Uint8Array(await readFile(path));
const { kdf, ciphertext } = parseEncryptedFile(blob);
// Mirrors PasswordManager._derive_seed_key: argon2 when the metadata says
// so, otherwise PBKDF2 with the recorded salt (falling back to the
// fingerprint-derived salt) and iteration count, then legacy counts.
const candidates: Uint8Array[] = [];
if (kdf.name === "argon2id" && kdf.salt_b64) {
if (kdf.name.startsWith("argon2") && kdf.salt_b64) {
candidates.push(base64url.decode(deriveKeyFromPasswordArgon2(password, kdf)));
}
const recordedSalt = kdf.salt_b64
? new Uint8Array(Buffer.from(kdf.salt_b64, "base64"))
: seedKdfSalt(fingerprint);
const iterations = Number((kdf.params as { iterations?: number }).iterations ?? 0);
for (const iters of [...(iterations ? [iterations] : []), 100_000, 50_000]) {
const iterationCandidates = [
...(iterations ? [iterations] : []),
DEFAULT_PBKDF2_ITERATIONS,
100_000,
50_000,
];
for (const iters of iterationCandidates) {
candidates.push(base64url.decode(deriveKeyFromPassword(password, recordedSalt, iters)));
// Legacy files may predate the recorded salt; the fingerprint string
// path derives the same bytes, but keep it explicit for clarity.
candidates.push(base64url.decode(deriveKeyFromPassword(password, fingerprint, iters)));
}
let lastError: unknown;
+7 -1
View File
@@ -18,6 +18,7 @@ import {
importBackup,
exportBackup,
generateFingerprint,
assertValidMnemonic,
modifyEntry,
archiveEntry,
restoreEntry,
@@ -87,7 +88,12 @@ async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise<string
*/
async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise<string> {
const env = process.env["SEEDPASS_MNEMONIC"];
if (env) return env;
if (env) {
// Catch a typo'd phrase at the boundary rather than deriving a silently
// different vault from it.
assertValidMnemonic(env, "SEEDPASS_MNEMONIC");
return env;
}
// A process that authenticates with a scoped token has declared itself a
// constrained principal: it must never escalate to owner access by
// pulling the mnemonic from the unlocked agent.