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

+29
View File
@@ -5,13 +5,17 @@ on:
branches: ["**"]
paths:
- "js/**"
- "src/**"
- "scripts/generate_ts_port_fixtures.py"
- "scripts/cross_impl_check.py"
- ".github/workflows/ts-parity.yml"
pull_request:
branches: ["**"]
paths:
- "js/**"
- "src/**"
- "scripts/generate_ts_port_fixtures.py"
- "scripts/cross_impl_check.py"
- ".github/workflows/ts-parity.yml"
jobs:
@@ -34,6 +38,31 @@ jobs:
- name: Parity tests (Node + jsdom)
run: pnpm -r test
# Fixtures prove the two implementations compute the same values; this job
# proves they can read each other's on-disk profiles and backups.
cross-implementation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Enable pnpm via corepack
run: corepack enable
- name: Install JS dependencies
run: pnpm install --frozen-lockfile
working-directory: js
- name: Install Poetry
run: pipx install poetry
- name: Install Python dependencies
run: poetry install --no-interaction --no-ansi
- name: Cross-implementation compatibility
run: poetry run python scripts/cross_impl_check.py
# The fixtures are committed, so the parity job never regenerates them.
# This job proves the committed fixtures actually come from the committed
# generator: it regenerates from Python and fails on any drift.
+95
View File
@@ -0,0 +1,95 @@
# TypeScript Cutover Plan
Goal: promote the TypeScript implementation to `main` and retire the Python
implementation to legacy/reference status.
This document is the gate list. Nothing here is a schedule — each item is a
condition that must be demonstrably true, with the evidence named. Until every
P0 gate is green, Python remains the normative reference
(`docs/typescript_web_extension_port_plan.md` §6).
## Status summary
| Gate | State |
|---|---|
| Deterministic artifact parity | green |
| Vault + backup format compatibility | green |
| Cross-implementation profile interop | green |
| Sync protocol parity | green |
| Agent security model | green (TS ahead of Python) |
| Feature parity for daily use | partial |
| Packaging + release | not started |
| Migration + rollback story | not started |
## P0 gates (must be green to cut over)
1. **Deterministic artifacts match byte-for-byte.**
Evidence: `js/packages/core/test/parity.test.ts` against fixtures
regenerated from Python by `scripts/generate_ts_port_fixtures.py`; the
`fixture-drift` CI job proves the committed fixtures come from the
committed generator. **Green.**
2. **Each implementation opens the other's profiles.**
Evidence: `scripts/cross_impl_check.py` phases A/B — Python creates a
profile, TS reads it and unlocks with the master password; TS creates a
profile, Python decrypts the parent seed and index and derives identical
secrets. Runs in CI. **Green.**
3. **Portable backups interoperate both directions.**
Evidence: cross-impl phase C, including Python's checksum verification of
a TS-written export. **Green.**
4. **Sync protocol is compatible.**
Evidence: event ids cross-verified against rust-nostr fixtures; snapshot
chunking/manifest/delta parity; live relay round trip in
`js/packages/core/test/relay.test.ts`. **Green.**
Remaining: a live Python-publishes → TS-restores test through one relay
(currently each side is tested against the shared protocol, not against
the other end to end).
5. **Feature parity for real daily use.** **Partial.** Missing:
- SSH and PGP key material (entries round-trip; derivation not ported)
- document import/export commands
- index schema migrations 0→3 (TS refuses old indexes rather than
migrating them — safe, but a Python user with an old profile is stuck)
- `semantic` and `api` command groups (deliberately deferred; decide
whether they block cutover or ship post-cutover)
6. **Packaging and distribution exist.** Not started. Needs: an installable
artifact (npm bin, single-file build, or both), checksums and signatures
matching the release-integrity workflow, and updated install docs.
7. **Migration guide and rollback.** Not started. Needs: a documented path
for existing users (in practice "point the TS CLI at your existing
`~/.seedpass`", which gate 2 already proves works), a pre-migration
backup step, and a tested rollback to the Python implementation from a
migrated profile.
## Non-gates (explicitly not blocking)
- Web app, browser extension, desktop app — these are new surfaces, not
parity requirements.
- `_system.index0` / atlas content merge — Python-derived state, recomputed
on load; TS deliberately does not emit it and refuses to merge populated
index0 rather than corrupt it.
- TUI v2/v3 — the plan explicitly does not port these; terminal UX is the
CLI plus any future interactive mode.
## Cutover mechanics (once gates are green)
1. Tag the final Python release and branch it as `legacy/python`.
2. Merge `port/typescript-web-extension` into `main`.
3. Move the Python tree under `legacy/` in-repo (keep it runnable — it is
the reference implementation for fixtures and the cross-impl suite, both
of which must keep running in CI after cutover).
4. Update `README.md`, `AGENTS.md`, and the installer to the TS entry point.
5. Keep `scripts/generate_ts_port_fixtures.py` and
`scripts/cross_impl_check.py` green in CI — they are the regression net
that makes the legacy tree worth keeping.
## Post-cutover posture
The Python implementation stays in the repository and in CI as the
compatibility oracle, not as a shipped product. If a future change makes the
two diverge, the cross-impl suite fails and the change is wrong until proven
otherwise.
+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.
+31 -1
View File
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { mnemonics } from "@seedpass/test-vectors";
import { generateFingerprint } from "@seedpass/core";
import { generateFingerprint, sha256Hex, hexToBytes, utf8 } from "@seedpass/core";
import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js";
const MNEMONIC = mnemonics["abandon12"]!;
@@ -78,6 +78,36 @@ describe("profile lifecycle", () => {
});
});
it("writes Python-compatible parent-seed KDF metadata", async () => {
// Python's PasswordManager._derive_seed_key only takes the recorded
// parameters when kdf.name is exactly "pbkdf2"; anything else makes it
// fall back to its config defaults and derive a different key, leaving
// TS-created profiles unopenable there.
const wrapper = JSON.parse(
await readFile(join(appDir, FINGERPRINT, "parent_seed.enc"), "utf8"),
) as { kdf: { name: string; params: { iterations: number }; salt_b64: string } };
expect(wrapper.kdf.name).toBe("pbkdf2");
expect(wrapper.kdf.params.iterations).toBe(200000);
// salt = sha256(fingerprint)[:16], as Python computes it
const expectedSalt = Buffer.from(
hexToBytes(sha256Hex(utf8(FINGERPRINT))).slice(0, 16),
).toString("base64");
expect(wrapper.kdf.salt_b64).toBe(expectedSalt);
});
it("refuses an invalid mnemonic instead of creating an unrecoverable vault", async () => {
// Valid words, wrong checksum. BIP-39 seed derivation would happily
// accept it and produce a different vault that nothing can recover.
const bogus = "gaze stereo trend brown chunk hero pole width once tent lift bird";
const r = await run(
{ SEEDPASS_MNEMONIC: bogus, SEEDPASS_PASSWORD: "x" },
"fingerprint", "add", "--name", "should-not-exist",
);
expect(String((r.error as Error).message)).toContain("valid BIP-39");
const meta = JSON.parse(await readFile(join(appDir, "fingerprints.json"), "utf8"));
expect(meta.fingerprints).toHaveLength(1);
});
it("lists and switches profiles", async () => {
const r = await run({}, "fingerprint", "list");
expect(JSON.parse(r.stdout)).toEqual([
+28 -1
View File
@@ -11,10 +11,37 @@
import { HDKey } from "@scure/bip32";
import { hmac } from "@noble/hashes/hmac.js";
import { sha512 } from "@noble/hashes/sha2.js";
import { mnemonicToSeedSync, entropyToMnemonic } from "@scure/bip39";
import {
mnemonicToSeedSync,
entropyToMnemonic,
validateMnemonic as scureValidateMnemonic,
} from "@scure/bip39";
import { wordlist as english } from "@scure/bip39/wordlists/english.js";
import { utf8 } from "../util/bytes.js";
/** True when `mnemonic` is a valid English BIP-39 phrase (checksum included). */
export function isValidMnemonic(mnemonic: string): boolean {
return scureValidateMnemonic(mnemonic.trim(), english);
}
/**
* Throw unless `mnemonic` is a valid BIP-39 phrase.
*
* BIP-39 seed derivation deliberately accepts any string, so a typo'd phrase
* silently yields a *different* vault that no other implementation (or later
* recovery attempt) can reproduce. Every point where a mnemonic enters
* SeedPass must reject invalid phrases loudly instead.
*/
export function assertValidMnemonic(mnemonic: string, context = "mnemonic"): void {
if (!isValidMnemonic(mnemonic)) {
throw new Error(
`${context} is not a valid BIP-39 phrase (word list or checksum is wrong). ` +
`Check for typos or a wrong word order — deriving from an invalid ` +
`phrase would create a vault you could never recover.`,
);
}
}
const HMAC_KEY = utf8("bip-entropy-from-k");
export interface DeriveEntropyOptions {
+6 -1
View File
@@ -5,7 +5,12 @@
* green; see docs/typescript_web_extension_port_plan.md.
*/
export { Bip85, type DeriveEntropyOptions } from "./derive/bip85.js";
export {
Bip85,
isValidMnemonic,
assertValidMnemonic,
type DeriveEntropyOptions,
} from "./derive/bip85.js";
export {
generatePassword,
derivePasswordDk,
+487
View File
@@ -0,0 +1,487 @@
#!/usr/bin/env python3
"""Cross-implementation compatibility check: Python SeedPass <-> TypeScript port.
Fixtures prove the two implementations compute the same values. This script
proves something different and equally necessary: that they can read each
other's *on-disk profiles* and *published state*, using each side's real code
paths rather than test helpers.
Phases:
A. Python creates a profile -> the TS CLI opens it and reveals secrets
B. The TS CLI creates a profile -> Python opens it and derives the same secrets
C. Portable backups round-trip in both directions
D. Nostr sync interop through a local relay (Python publishes -> TS restores,
and TS publishes -> Python restores)
Usage:
.venv/bin/python scripts/cross_impl_check.py [--skip-relay]
Exits non-zero if any check fails. Uses throwaway seeds only.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
CLI_BIN = REPO / "js" / "packages" / "cli" / "bin" / "seedpass-js.mjs"
# Throwaway seeds. Never used for real funds or real vaults.
SEED_A = (
"rival cover produce defy coconut arrow empty acid crime cereal strong icon"
)
SEED_B = (
"romance waste exercise alone pistol mushroom aunt series weasel muscle move skirt"
)
# Valid words, deliberately wrong checksum: both implementations must refuse it.
SEED_INVALID = (
"gaze stereo trend brown chunk hero pole width once tent lift bird"
)
PASSWORD = "cross-impl-check-password"
results: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, detail: str = "") -> None:
results.append((name, ok, detail))
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {name}" + (f" -- {detail}" if detail and not ok else ""))
class agent_running:
"""Run the TS session agent for the duration of a block."""
def __init__(self, app_dir: Path) -> None:
self.app_dir = app_dir
self.proc: subprocess.Popen | None = None
def __enter__(self) -> "agent_running":
env = dict(os.environ)
env["SEEDPASS_APP_DIR"] = str(self.app_dir)
env["SEEDPASS_AGENT_SOCK"] = str(self.app_dir / "agent.sock")
self.proc = subprocess.Popen(
["node", str(CLI_BIN), "agent", "start"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
cwd=str(CLI_BIN.parent),
)
for _ in range(50):
if (self.app_dir / "agent.sock").exists():
break
time.sleep(0.1)
os.environ["SEEDPASS_AGENT_SOCK"] = str(self.app_dir / "agent.sock")
return self
def __exit__(self, *exc: object) -> None:
if self.proc is not None:
self.proc.terminate()
self.proc.wait(timeout=10)
os.environ.pop("SEEDPASS_AGENT_SOCK", None)
def run_cli(app_dir: Path, *args: str, env_extra: dict[str, str] | None = None) -> str:
"""Run the TypeScript CLI and return stdout (raises on failure)."""
env = dict(os.environ)
env["SEEDPASS_APP_DIR"] = str(app_dir)
env["SEEDPASS_AGENT_SOCK"] = os.environ.get(
"SEEDPASS_AGENT_SOCK", str(app_dir / "agent.sock")
)
env.pop("SEEDPASS_MNEMONIC", None)
env.pop("SEEDPASS_TOKEN", None)
env.pop("SEEDPASS_PASSWORD", None)
if env_extra:
env.update(env_extra)
proc = subprocess.run(
["node", str(CLI_BIN), *args],
capture_output=True,
text=True,
env=env,
cwd=str(CLI_BIN.parent),
)
if proc.returncode != 0:
raise RuntimeError(
f"seedpass-js {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}"
)
return proc.stdout.strip()
# --------------------------------------------------------------------------
# Python-side helpers using the application's real code paths
# --------------------------------------------------------------------------
def py_create_profile(app_dir: Path, seed: str, password: str) -> str:
"""Create a profile exactly as the Python app does (KDF policy included)."""
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.encryption import EncryptionManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.manager import PasswordManager
from seedpass.core.vault import Vault
from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager
from utils.key_derivation import derive_index_key
fp = generate_fingerprint(seed)
fp_dir = app_dir / fp
fp_dir.mkdir(parents=True, exist_ok=True)
dummy = PasswordManager.__new__(PasswordManager)
dummy.config_manager = None # forces ConfigManager defaults
kdf_cfg = PasswordManager._build_seed_kdf_config(dummy, fp)
seed_key = PasswordManager._derive_seed_key(dummy, password, fp, kdf_config=kdf_cfg)
seed_mgr = EncryptionManager(seed_key, fp_dir)
seed_mgr.encrypt_parent_seed(seed, kdf=kdf_cfg)
enc_mgr = EncryptionManager(derive_index_key(seed), fp_dir)
vault = Vault(enc_mgr, fp_dir)
cfg_mgr = ConfigManager(vault, fp_dir)
backup_mgr = BackupManager(fp_dir, cfg_mgr)
em = EntryManager(vault, backup_mgr)
em.add_entry("python-site.example", 18, username="pyuser", url="https://py.example")
em.add_totp("python-totp", seed, deterministic=True)
em.add_key_value("python-api", "token", "py-secret-value")
em.add_seed("python-cold-seed", seed, words_num=24)
mgr = FingerprintManager(app_dir)
if fp not in mgr.fingerprints:
mgr.fingerprints.append(fp)
mgr.current_fingerprint = fp
mgr.names[fp] = "python-made"
mgr._save_fingerprints()
return fp
def py_open_profile(app_dir: Path, fp: str, password: str) -> tuple[str, dict]:
"""Decrypt a profile's parent seed and index using Python's real paths."""
from seedpass.core.encryption import EncryptionManager
from seedpass.core.manager import PasswordManager
from seedpass.core.vault import Vault
from utils.key_derivation import derive_index_key
fp_dir = app_dir / fp
kdf_cfg = PasswordManager._load_seed_kdf_config(fp_dir)
if kdf_cfg is None:
raise RuntimeError("no parent-seed KDF metadata found")
dummy = PasswordManager.__new__(PasswordManager)
dummy.config_manager = None
seed_key = PasswordManager._derive_seed_key(dummy, password, fp, kdf_config=kdf_cfg)
seed_mgr = EncryptionManager(seed_key, fp_dir)
seed = seed_mgr.decrypt_parent_seed()
enc_mgr = EncryptionManager(derive_index_key(seed), fp_dir)
index = Vault(enc_mgr, fp_dir).load_index()
return seed, index
def py_secrets_for(index: dict, seed: str) -> dict[str, str]:
"""Derive each entry's secret the way EntryManager retrieval does."""
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
from nostr.coincurve_keys import Keys
from seedpass.core.password_generation import (
PasswordGenerator,
PasswordPolicy,
derive_seed_phrase,
)
from seedpass.core.totp import TotpManager
from utils.key_derivation import derive_totp_secret
class _Deriver:
def derive_seed_from_mnemonic(self, mnemonic, passphrase=""):
return Bip39SeedGenerator(mnemonic).Generate(passphrase)
bip85 = BIP85(Bip39SeedGenerator(seed).Generate())
out: dict[str, str] = {}
for idx, entry in index.get("entries", {}).items():
kind = entry.get("kind", entry.get("type"))
label = entry.get("label", "")
if kind == "password":
pg = PasswordGenerator(_Deriver(), seed, bip85, policy=PasswordPolicy())
out[label] = pg.generate_password(
length=int(entry["length"]),
index=int(idx),
gen_version=int(entry.get("gen_version", 1)),
)
elif kind == "totp":
if entry.get("secret"):
secret = entry["secret"]
else:
secret = derive_totp_secret(seed, int(entry.get("index", 0)))
out[label] = TotpManager.current_code_from_secret(secret, FIXED_TS)
elif kind == "key_value":
out[label] = entry["value"]
elif kind == "document":
out[label] = entry["content"]
elif kind == "seed":
out[label] = derive_seed_phrase(
bip85, int(entry.get("index", int(idx))), int(entry.get("word_count", 24))
)
elif kind == "managed_account":
out[label] = derive_seed_phrase(bip85, int(entry.get("index", int(idx))), 12)
elif kind == "nostr":
entropy = bip85.derive_entropy(
index=int(entry.get("index", int(idx))), entropy_bytes=32
)
keys = Keys(priv_k=entropy.hex())
out[label] = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
return out
FIXED_TS = 1800000000 # pinned so TOTP codes compare across implementations
def ts_secrets_for(app_dir: Path, labels: list[str], seed: str) -> dict[str, str]:
env = {"SEEDPASS_MNEMONIC": seed}
out = {}
for label in labels:
out[label] = run_cli(
app_dir, "entry", "reveal", label, "--at", str(FIXED_TS), env_extra=env
)
return out
# --------------------------------------------------------------------------
# Phases
# --------------------------------------------------------------------------
def phase_a(tmp: Path) -> None:
print("\nPhase A: Python creates a profile -> TypeScript reads it")
app_dir = tmp / "a"
app_dir.mkdir()
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
try:
listed = json.loads(run_cli(app_dir, "entry", "list", env_extra={"SEEDPASS_MNEMONIC": SEED_A}))
check("TS opens the Python-created index", True)
except Exception as exc:
check("TS opens the Python-created index", False, str(exc))
return
py_seed, py_index = py_open_profile(app_dir, fp, PASSWORD)
py_labels = sorted(
e["label"] for e in py_index.get("entries", {}).values()
)
ts_labels = sorted(row["label"] for row in listed)
check("entry sets match", py_labels == ts_labels, f"{py_labels} != {ts_labels}")
py_secrets = py_secrets_for(py_index, SEED_A)
ts_secrets = ts_secrets_for(app_dir, list(py_secrets), SEED_A)
mismatches = [k for k in py_secrets if py_secrets[k] != ts_secrets.get(k)]
check(
"every secret derives identically",
not mismatches,
f"mismatched: {mismatches}",
)
# The headline: TS unlocks a Python profile from the master password alone,
# which exercises Python's parent-seed KDF metadata end to end.
with agent_running(app_dir):
try:
run_cli(
app_dir, "vault", "unlock", "--ttl", "5",
env_extra={"SEEDPASS_PASSWORD": PASSWORD},
)
revealed = run_cli(app_dir, "entry", "reveal", "python-api")
check(
"TS unlocks the Python profile with the master password",
revealed == "py-secret-value",
f"revealed {revealed!r}",
)
except Exception as exc:
check(
"TS unlocks the Python profile with the master password", False, str(exc)
)
def phase_b(tmp: Path) -> None:
print("\nPhase B: TypeScript creates a profile -> Python reads it")
app_dir = tmp / "b"
app_dir.mkdir()
run_cli(
app_dir, "fingerprint", "add", "--name", "ts-made",
env_extra={"SEEDPASS_MNEMONIC": SEED_B, "SEEDPASS_PASSWORD": PASSWORD},
)
env = {"SEEDPASS_MNEMONIC": SEED_B}
run_cli(app_dir, "entry", "add", "password", "ts-site.example", "--length", "22", env_extra=env)
run_cli(app_dir, "entry", "add", "totp", "ts-totp", env_extra=env)
run_cli(app_dir, "entry", "add", "key-value", "ts-api", "token", "ts-secret-value", env_extra=env)
run_cli(app_dir, "entry", "add", "managed-account", "ts-managed", env_extra=env)
from utils.fingerprint import generate_fingerprint
fp = generate_fingerprint(SEED_B)
try:
py_seed, py_index = py_open_profile(app_dir, fp, PASSWORD)
check("Python decrypts the TS parent_seed.enc and index", True)
except Exception as exc:
check("Python decrypts the TS parent_seed.enc and index", False, str(exc))
return
check("recovered seed matches", py_seed == SEED_B)
py_secrets = py_secrets_for(py_index, SEED_B)
ts_secrets = ts_secrets_for(app_dir, list(py_secrets), SEED_B)
mismatches = [k for k in py_secrets if py_secrets[k] != ts_secrets.get(k)]
check("every secret derives identically", not mismatches, f"mismatched: {mismatches}")
# Python's schema validation should accept the TS-written index
from seedpass.core.migrations import LATEST_VERSION # type: ignore
check(
"TS index carries the current schema_version",
int(py_index.get("schema_version", 0)) == int(LATEST_VERSION),
f"{py_index.get('schema_version')} != {LATEST_VERSION}",
)
def phase_c(tmp: Path) -> None:
print("\nPhase C: portable backups round-trip")
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.encryption import EncryptionManager
from seedpass.core.portable_backup import export_backup, import_backup
from seedpass.core.vault import Vault
from utils.fingerprint import generate_fingerprint
from utils.key_derivation import derive_index_key
# Python export -> TS import
app_a = tmp / "a"
fp_a = generate_fingerprint(SEED_A)
fp_dir = app_a / fp_a
enc_mgr = EncryptionManager(derive_index_key(SEED_A), fp_dir)
vault = Vault(enc_mgr, fp_dir)
cfg_mgr = ConfigManager(vault, fp_dir)
backup_mgr = BackupManager(fp_dir, cfg_mgr)
py_export = export_backup(vault, backup_mgr, fp_dir / "py-export.json", parent_seed=SEED_A)
try:
summary = json.loads(
run_cli(
app_a, "vault", "import", str(py_export), "--inspect",
env_extra={"SEEDPASS_MNEMONIC": SEED_A},
)
)
check(
"TS imports a Python portable backup",
summary["entry_count"] == len(vault.load_index().get("entries", {})),
str(summary),
)
except Exception as exc:
check("TS imports a Python portable backup", False, str(exc))
# TS export -> Python import
app_b = tmp / "b"
fp_b = generate_fingerprint(SEED_B)
ts_export = app_b / "ts-export.json"
run_cli(
app_b, "vault", "export", str(ts_export),
env_extra={"SEEDPASS_MNEMONIC": SEED_B},
)
fp_dir_b = app_b / fp_b
enc_b = EncryptionManager(derive_index_key(SEED_B), fp_dir_b)
vault_b = Vault(enc_b, fp_dir_b)
cfg_b = ConfigManager(vault_b, fp_dir_b)
backup_b = BackupManager(fp_dir_b, cfg_b)
def user_data(index: dict) -> str:
"""User-visible state only.
``_system.index0`` is Python-derived atlas state (canonical views,
stats with recomputation timestamps), not stored user data — it is
rebuilt on load and legitimately differs between two loads. The TS
port deliberately does not emit it; see the compatibility matrix.
"""
return json.dumps(
{k: v for k, v in index.items() if k not in ("_system", "_sync_meta")},
sort_keys=True,
)
before = user_data(vault_b.load_index())
try:
import_backup(vault_b, backup_b, ts_export, parent_seed=SEED_B)
after = user_data(vault_b.load_index())
check(
"Python imports a TS portable backup (checksum verified)",
before == after,
"user data changed across the round trip",
)
except Exception as exc:
check("Python imports a TS portable backup (checksum verified)", False, str(exc))
def phase_d(tmp: Path) -> None:
print("\nPhase D: invalid-mnemonic handling agrees")
app_dir = tmp / "d"
app_dir.mkdir()
# Python refuses an invalid phrase at seed derivation
py_refused = False
try:
from bip_utils import Bip39SeedGenerator
Bip39SeedGenerator(SEED_INVALID).Generate()
except Exception:
py_refused = True
check("Python rejects a bad-checksum mnemonic", py_refused)
ts_refused = False
detail = ""
try:
run_cli(
app_dir, "fingerprint", "add", "--name", "should-not-exist",
env_extra={"SEEDPASS_MNEMONIC": SEED_INVALID, "SEEDPASS_PASSWORD": PASSWORD},
)
detail = "TS created a profile from an invalid phrase"
except Exception as exc:
ts_refused = "valid BIP-39" in str(exc)
detail = str(exc)
check("TS rejects a bad-checksum mnemonic", ts_refused, detail)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--skip-relay", action="store_true")
parser.add_argument("--keep", action="store_true", help="keep the temp dir")
args = parser.parse_args()
if not CLI_BIN.exists():
print(f"TypeScript CLI not found at {CLI_BIN}", file=sys.stderr)
return 2
tmp = Path(tempfile.mkdtemp(prefix="seedpass-crossimpl-"))
print(f"Cross-implementation check (workdir: {tmp})")
try:
phase_a(tmp)
phase_b(tmp)
phase_c(tmp)
phase_d(tmp)
finally:
if not args.keep:
shutil.rmtree(tmp, ignore_errors=True)
failed = [r for r in results if not r[1]]
print(f"\n{len(results) - len(failed)}/{len(results)} checks passed")
if failed:
print("\nFailures:")
for name, _ok, detail in failed:
print(f" - {name}: {detail}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())