mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
The round-trip conformance check from spec section 8.6, now enforced in CI:
a vault holding a foreign record (unknown kind, nested foreign data, and
trap fields) plus an unknown top-level index key goes through an interleaved
Python -> TypeScript -> Python read-modify-write cycle, and every foreign
byte must come back exactly as planted. Allocation must skip past the
foreign id, and TS must refuse to reveal the record rather than guess.
Landing the check forced the normalization decision it existed to force.
Python's _normalize_entry_defaults ran its legacy renames on every entry,
foreign ones included -- and those renames are migrations for OUR old
shapes: the negative control shows the old code turning a foreign
"blacklisted" string into our "archived" flag, which is not backfill but
reinterpretation of another application's data. The normalizer now skips
unknown kinds entirely, so both implementations carry foreign records
byte-for-byte untouched and produce identical canonical state -- the
property sync convergence is defined by. Entries with neither kind nor
type are pre-kind legacy Python data and still normalize as before.
The spec's gap ledger entry for the divergence is resolved, and the Python
tolerance tests are tightened from field-subset to exact equality, plus a
trap-field test pinning that blacklisted/website/words on a foreign record
mean whatever the other application says they mean.
Verified: with the normalizer gate stashed, Phase L fails on the exact
mangling ("archived": "foreign meaning, not our archive flag"); with it in
place, 38/38 cross-impl checks and 1183 Python tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
1112 lines
42 KiB
Python
1112 lines
42 KiB
Python
#!/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]] = []
|
|
divergences: list[tuple[str, 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 ""))
|
|
|
|
|
|
def note_divergence(name: str, detail: str) -> None:
|
|
"""Record a known, deliberate behavioral difference.
|
|
|
|
Not a failure — but never silent either: every entry here must appear in
|
|
the compatibility matrix's divergence section with a decision attached.
|
|
"""
|
|
divergences.append((name, detail))
|
|
print(f" [DIVERGE] {name} -- {detail}")
|
|
|
|
|
|
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)
|
|
|
|
# Every creatable kind, plus the shapes most likely to expose drift:
|
|
# a policy-constrained password and an imported (non-deterministic) TOTP.
|
|
em.add_entry("python-site.example", 18, username="pyuser", url="https://py.example")
|
|
em.add_entry(
|
|
"python-policy-site",
|
|
24,
|
|
username="policyuser",
|
|
special_mode="safe",
|
|
min_digits=4,
|
|
min_uppercase=3,
|
|
exclude_ambiguous=True,
|
|
)
|
|
em.add_totp("python-totp", seed, deterministic=True)
|
|
em.add_totp("python-totp-imported", secret="JBSWY3DPEHPK3PXP", period=45, digits=8)
|
|
em.add_key_value("python-api", "token", "py-secret-value")
|
|
em.add_document("python-doc", "python document body", file_type="md")
|
|
em.add_seed("python-cold-seed", seed, words_num=24)
|
|
em.add_managed_account("python-managed", seed)
|
|
em.add_nostr_key("python-nostr", seed)
|
|
em.add_ssh_key("python-ssh", seed)
|
|
em.add_pgp_key("python-pgp", seed, user_id="[email protected]")
|
|
|
|
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":
|
|
# Mirror _generate_password_for_entry: merge the entry's policy
|
|
# overrides onto the base policy before deriving.
|
|
import dataclasses
|
|
|
|
policy = PasswordPolicy()
|
|
overrides = entry.get("policy", {})
|
|
if isinstance(overrides, dict) and overrides:
|
|
policy = dataclasses.replace(
|
|
policy,
|
|
**{k: v for k, v in overrides.items() if hasattr(policy, k)},
|
|
)
|
|
pg = PasswordGenerator(_Deriver(), seed, bip85, policy=policy)
|
|
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,
|
|
period=int(entry.get("period", 30)),
|
|
digits=int(entry.get("digits", 6)),
|
|
)
|
|
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 == "pgp":
|
|
from seedpass.core.password_generation import derive_pgp_key
|
|
|
|
priv, _pub, _fp = derive_pgp_key(
|
|
bip85,
|
|
int(entry.get("index", int(idx))),
|
|
entry.get("key_type", "ed25519"),
|
|
entry.get("user_id", ""),
|
|
)
|
|
out[label] = priv
|
|
elif kind == "ssh":
|
|
from seedpass.core.password_generation import derive_ssh_key_pair
|
|
|
|
priv, _pub = derive_ssh_key_pair(seed, int(entry.get("index", int(idx))))
|
|
out[label] = priv
|
|
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)
|
|
# PEM values carry a trailing newline that the CLI capture strips; compare
|
|
# on trailing whitespace-insensitive values.
|
|
mismatches = [
|
|
k
|
|
for k in py_secrets
|
|
if py_secrets[k].rstrip("\n") != (ts_secrets.get(k) or "").rstrip("\n")
|
|
]
|
|
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)
|
|
run_cli(app_dir, "entry", "add", "ssh", "ts-ssh", env_extra=env)
|
|
run_cli(app_dir, "entry", "add", "nostr", "ts-nostr", env_extra=env)
|
|
run_cli(
|
|
app_dir, "entry", "add", "pgp", "ts-pgp", "--user-id", "[email protected]",
|
|
env_extra=env,
|
|
)
|
|
run_cli(app_dir, "entry", "add", "document", "ts-doc", "ts document body", env_extra=env)
|
|
run_cli(app_dir, "entry", "add", "seed", "ts-seed", "--words", "24", env_extra=env)
|
|
run_cli(
|
|
app_dir, "entry", "add", "totp", "ts-totp-imported",
|
|
"--secret", "JBSWY3DPEHPK3PXP", "--period", "45", "--digits", "8",
|
|
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)
|
|
# PEM values carry a trailing newline that the CLI capture strips; compare
|
|
# on trailing whitespace-insensitive values.
|
|
mismatches = [
|
|
k
|
|
for k in py_secrets
|
|
if py_secrets[k].rstrip("\n") != (ts_secrets.get(k) or "").rstrip("\n")
|
|
]
|
|
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 phase_e(tmp: Path) -> None:
|
|
print("\nPhase E: a legacy-schema Python vault opens in TypeScript")
|
|
from seedpass.core.encryption import EncryptionManager
|
|
from seedpass.core.migrations import apply_migrations
|
|
from seedpass.core.vault import Vault
|
|
from utils.fingerprint import generate_fingerprint
|
|
from utils.key_derivation import derive_index_key
|
|
|
|
app_dir = tmp / "e"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
fp_dir = app_dir / fp
|
|
|
|
# Overwrite the index with a pre-v4 payload, as an older SeedPass wrote it
|
|
legacy = {
|
|
"schema_version": 2,
|
|
"entries": {
|
|
"0": {
|
|
"type": "password",
|
|
"label": "legacy-login.example",
|
|
"length": 16,
|
|
"notes": "written by an older SeedPass",
|
|
"username": "olduser",
|
|
},
|
|
"1": {
|
|
"type": "key_value",
|
|
"label": "legacy-kv",
|
|
"key": "token",
|
|
"value": "legacy-secret-value",
|
|
"notes": "",
|
|
},
|
|
},
|
|
}
|
|
enc_mgr = EncryptionManager(derive_index_key(SEED_A), fp_dir)
|
|
enc_mgr.save_json_data(legacy, Path("seedpass_entries_db.json.enc"))
|
|
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A}
|
|
try:
|
|
rows = json.loads(run_cli(app_dir, "entry", "list", env_extra=env))
|
|
labels = sorted(r["label"] for r in rows)
|
|
check(
|
|
"TS opens a v2 index and migrates it",
|
|
labels == ["legacy-kv", "legacy-login.example"],
|
|
str(labels),
|
|
)
|
|
revealed = run_cli(app_dir, "entry", "reveal", "legacy-kv", env_extra=env)
|
|
check("TS reveals a migrated legacy secret", revealed == "legacy-secret-value")
|
|
except Exception as exc:
|
|
check("TS opens a v2 index and migrates it", False, str(exc))
|
|
return
|
|
|
|
# Python's own migration of the same payload must agree with what TS did
|
|
py_migrated = apply_migrations(json.loads(json.dumps(legacy)))
|
|
py_labels = sorted(e["label"] for e in py_migrated["entries"].values())
|
|
check("Python and TS agree on the migrated entry set", py_labels == labels)
|
|
|
|
# And TS must not have written a downgraded index back to disk
|
|
reloaded = Vault(EncryptionManager(derive_index_key(SEED_A), fp_dir), fp_dir).load_index()
|
|
check(
|
|
"Python still reads the profile after TS touched it",
|
|
sorted(e["label"] for e in reloaded.get("entries", {}).values()) == labels,
|
|
)
|
|
|
|
|
|
class relay_running:
|
|
"""Run the in-process NIP-01 test relay and yield its ws:// URL."""
|
|
|
|
SCRIPT = """
|
|
import { register } from "tsx/esm/api";
|
|
register();
|
|
const { MockRelay } = await import("REPO/js/packages/core/test/mockRelay.ts");
|
|
const relay = new MockRelay();
|
|
console.log(await relay.start());
|
|
process.on("SIGTERM", () => relay.stop().then(() => process.exit(0)));
|
|
await new Promise(() => {});
|
|
"""
|
|
|
|
def __init__(self, tmp: Path) -> None:
|
|
self.tmp = tmp
|
|
self.proc: subprocess.Popen | None = None
|
|
self.url = ""
|
|
|
|
def __enter__(self) -> "relay_running":
|
|
# Must live inside the workspace so pnpm's node_modules resolve.
|
|
script = CLI_BIN.parent / "_cross_impl_relay.mjs"
|
|
script.write_text(self.SCRIPT.replace("REPO", str(REPO)))
|
|
self.script = script
|
|
self.proc = subprocess.Popen(
|
|
["node", str(script)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
cwd=str(CLI_BIN.parent),
|
|
)
|
|
assert self.proc.stdout is not None
|
|
self.url = self.proc.stdout.readline().strip()
|
|
if not self.url.startswith("ws://"):
|
|
raise RuntimeError(f"relay failed to start: {self.url!r}")
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> None:
|
|
if self.proc is not None:
|
|
self.proc.terminate()
|
|
self.proc.wait(timeout=10)
|
|
self.script.unlink(missing_ok=True)
|
|
|
|
|
|
def phase_f(tmp: Path) -> None:
|
|
print("\nPhase F: Nostr sync interop through a live relay")
|
|
import asyncio
|
|
|
|
from seedpass.core.encryption import EncryptionManager
|
|
from seedpass.core.vault import Vault
|
|
from utils.fingerprint import generate_fingerprint
|
|
from utils.key_derivation import derive_index_key
|
|
|
|
app_dir = tmp / "f"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
fp_dir = app_dir / fp
|
|
|
|
with relay_running(tmp) as relay:
|
|
# --- Python publishes -------------------------------------------------
|
|
try:
|
|
from nostr.client import NostrClient
|
|
from seedpass.core.config_manager import ConfigManager
|
|
from utils.key_hierarchy import kd
|
|
from bip_utils import Bip39SeedGenerator
|
|
|
|
enc_mgr = EncryptionManager(derive_index_key(SEED_A), fp_dir)
|
|
vault = Vault(enc_mgr, fp_dir)
|
|
cfg_mgr = ConfigManager(vault, fp_dir)
|
|
cfg = cfg_mgr.load_config(require_pin=False)
|
|
cfg["relays"] = [relay.url]
|
|
cfg_mgr.save_config(cfg)
|
|
|
|
seed_bytes = Bip39SeedGenerator(SEED_A).Generate()
|
|
key_index = kd(kd(seed_bytes, b"seedpass:v1:master"), b"seedpass:v1:index")
|
|
client = NostrClient(
|
|
encryption_manager=enc_mgr,
|
|
fingerprint=fp,
|
|
relays=[relay.url],
|
|
config_manager=cfg_mgr,
|
|
parent_seed=SEED_A,
|
|
key_index=key_index,
|
|
account_index=0,
|
|
)
|
|
encrypted = vault.get_encrypted_index()
|
|
manifest, manifest_id = asyncio.run(client.publish_snapshot(encrypted))
|
|
published = bool(manifest_id) and bool(manifest.chunks)
|
|
check("Python publishes a snapshot to the relay", published, str(manifest_id))
|
|
except Exception as exc:
|
|
check("Python publishes a snapshot to the relay", False, f"{type(exc).__name__}: {exc}")
|
|
return
|
|
|
|
# --- TypeScript restores from what Python published -------------------
|
|
ts_dir = tmp / "f-ts"
|
|
ts_dir.mkdir()
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A, "SEEDPASS_PASSWORD": PASSWORD}
|
|
run_cli(ts_dir, "fingerprint", "add", "--name", "ts-restore", env_extra=env)
|
|
run_cli(ts_dir, "nostr", "add-relay", relay.url, env_extra=env)
|
|
for _ in range(3):
|
|
try:
|
|
run_cli(ts_dir, "nostr", "remove-relay", "1", env_extra=env)
|
|
except Exception:
|
|
break
|
|
try:
|
|
restored = json.loads(run_cli(ts_dir, "nostr", "restore", env_extra=env))
|
|
py_index = vault.load_index()
|
|
py_labels = sorted(e["label"] for e in py_index.get("entries", {}).values())
|
|
rows = json.loads(run_cli(ts_dir, "entry", "list", env_extra=env))
|
|
ts_labels = sorted(r["label"] for r in rows)
|
|
check(
|
|
"TS restores the Python-published snapshot",
|
|
ts_labels == py_labels,
|
|
f"{ts_labels} != {py_labels}",
|
|
)
|
|
revealed = run_cli(ts_dir, "entry", "reveal", "python-api", env_extra=env)
|
|
check("restored secrets are intact", revealed == "py-secret-value", revealed)
|
|
except Exception as exc:
|
|
check("TS restores the Python-published snapshot", False, str(exc))
|
|
|
|
|
|
def _py_vault(app_dir: Path, fp: str, seed: str):
|
|
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.vault import Vault
|
|
from utils.key_derivation import derive_index_key
|
|
|
|
fp_dir = app_dir / fp
|
|
enc = EncryptionManager(derive_index_key(seed), fp_dir)
|
|
vault = Vault(enc, fp_dir)
|
|
cfg = ConfigManager(vault, fp_dir)
|
|
return vault, EntryManager(vault, BackupManager(fp_dir, cfg)), cfg
|
|
|
|
|
|
def phase_g(tmp: Path) -> None:
|
|
print("\nPhase G: edits made by one implementation are seen by the other")
|
|
from utils.fingerprint import generate_fingerprint
|
|
|
|
app_dir = tmp / "g"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A}
|
|
|
|
# Python edits -> TS sees
|
|
_vault, em, _cfg = _py_vault(app_dir, fp, SEED_A)
|
|
em.modify_entry(0, username="edited-by-python", notes="python note")
|
|
em.archive_entry(1)
|
|
em.add_link(0, 2, relation="related_to", note="py link")
|
|
row = json.loads(run_cli(app_dir, "entry", "get", "0", env_extra=env))
|
|
check(
|
|
"TS sees Python's edit",
|
|
row.get("username") == "edited-by-python" and row.get("notes") == "python note",
|
|
json.dumps({k: row.get(k) for k in ("username", "notes")}),
|
|
)
|
|
links = json.loads(run_cli(app_dir, "entry", "links", "0", env_extra=env))
|
|
check(
|
|
"TS sees Python's link with a resolved target",
|
|
len(links) == 1 and links[0]["relation"] == "related_to" and links[0]["target_id"] == 2,
|
|
json.dumps(links),
|
|
)
|
|
archived = json.loads(run_cli(app_dir, "entry", "get", "1", env_extra=env))
|
|
check("TS sees Python's archive flag", archived.get("archived") is True)
|
|
|
|
# TS edits -> Python sees
|
|
run_cli(app_dir, "entry", "modify", "0", "--notes", "edited-by-ts", env_extra=env)
|
|
run_cli(app_dir, "entry", "unarchive", "1", env_extra=env)
|
|
run_cli(app_dir, "entry", "link-remove", "0", "2", env_extra=env)
|
|
_vault2, em2, _cfg2 = _py_vault(app_dir, fp, SEED_A)
|
|
entry0 = em2.retrieve_entry(0)
|
|
entry1 = em2.retrieve_entry(1)
|
|
check("Python sees TS's edit", entry0.get("notes") == "edited-by-ts", str(entry0.get("notes")))
|
|
check("Python sees TS's unarchive", entry1.get("archived") is False)
|
|
check("Python sees TS's link removal", em2.get_links(0) == [])
|
|
|
|
|
|
def phase_h(tmp: Path) -> None:
|
|
print("\nPhase H: conflict merge agrees across implementations")
|
|
# Both sides merge the same divergent payloads; results must be identical,
|
|
# or two clients syncing the same vault would converge differently.
|
|
from seedpass.core.sync_conflict import merge_index_payloads
|
|
|
|
base_ts = 1800000000
|
|
current = {
|
|
"schema_version": 4,
|
|
"entries": {
|
|
"0": {
|
|
"type": "password", "kind": "password", "label": "shared",
|
|
"length": 16, "archived": False, "notes": "from-current",
|
|
"tags": ["a"], "modified_ts": base_ts,
|
|
},
|
|
"1": {
|
|
"type": "key_value", "kind": "key_value", "label": "only-current",
|
|
"key": "k", "value": "v", "archived": False, "notes": "",
|
|
"tags": [], "modified_ts": base_ts,
|
|
},
|
|
},
|
|
}
|
|
incoming = {
|
|
"schema_version": 4,
|
|
"entries": {
|
|
"0": {
|
|
"type": "password", "kind": "password", "label": "shared",
|
|
"length": 16, "archived": True, "notes": "",
|
|
"tags": ["b"], "modified_ts": base_ts, "username": "incoming-user",
|
|
},
|
|
"2": {
|
|
"type": "password", "kind": "password", "label": "only-incoming",
|
|
"length": 20, "archived": False, "notes": "", "tags": [],
|
|
"modified_ts": base_ts + 5,
|
|
},
|
|
},
|
|
}
|
|
|
|
py_merged = merge_index_payloads(
|
|
json.loads(json.dumps(current)), json.loads(json.dumps(incoming)), source_tag="xtest"
|
|
)
|
|
|
|
# Run the TS merge through a tiny node harness against the same inputs
|
|
script = CLI_BIN.parent / "_cross_impl_merge.mjs"
|
|
script.write_text(
|
|
'import { register } from "tsx/esm/api";\n'
|
|
"register();\n"
|
|
'const { mergeIndexPayloads } = await import("@seedpass/core");\n'
|
|
"const [current, incoming] = JSON.parse(process.argv[2]);\n"
|
|
'console.log(JSON.stringify(mergeIndexPayloads(current, incoming, "xtest")));\n'
|
|
)
|
|
try:
|
|
proc = subprocess.run(
|
|
["node", str(script), json.dumps([current, incoming])],
|
|
capture_output=True, text=True, cwd=str(CLI_BIN.parent),
|
|
)
|
|
if proc.returncode != 0:
|
|
check("TS and Python merge identically", False, proc.stderr.strip()[:200])
|
|
return
|
|
ts_merged = json.loads(proc.stdout)
|
|
finally:
|
|
script.unlink(missing_ok=True)
|
|
|
|
def comparable(payload: dict) -> str:
|
|
# _system.index0 is Python-derived state TS does not emit.
|
|
return json.dumps(
|
|
{k: v for k, v in payload.items() if k != "_system"}, sort_keys=True
|
|
)
|
|
|
|
check(
|
|
"TS and Python merge identically",
|
|
comparable(py_merged) == comparable(ts_merged),
|
|
"merged payloads differ",
|
|
)
|
|
check(
|
|
"merge keeps entries from both sides",
|
|
sorted(ts_merged["entries"]) == ["0", "1", "2"],
|
|
str(sorted(ts_merged.get("entries", {}))),
|
|
)
|
|
|
|
|
|
def phase_i(tmp: Path) -> None:
|
|
print("\nPhase I: an Argon2id-mode Python profile opens in TypeScript")
|
|
from seedpass.core.encryption import EncryptionManager
|
|
from seedpass.core.manager import PasswordManager
|
|
from utils.fingerprint import generate_fingerprint
|
|
|
|
app_dir = tmp / "i"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
fp_dir = app_dir / fp
|
|
|
|
# Re-encrypt the parent seed under argon2id, as a profile configured for
|
|
# argon2 would have it on disk.
|
|
dummy = PasswordManager.__new__(PasswordManager)
|
|
dummy.config_manager = None
|
|
kdf_cfg = PasswordManager._build_seed_kdf_config(dummy, fp, mode="argon2")
|
|
seed_key = PasswordManager._derive_seed_key(
|
|
dummy, PASSWORD, fp, mode="argon2", kdf_config=kdf_cfg
|
|
)
|
|
EncryptionManager(seed_key, fp_dir).encrypt_parent_seed(SEED_A, kdf=kdf_cfg)
|
|
|
|
with agent_running(app_dir):
|
|
try:
|
|
run_cli(
|
|
app_dir, "vault", "unlock", "--ttl", "10",
|
|
env_extra={"SEEDPASS_PASSWORD": PASSWORD},
|
|
)
|
|
revealed = run_cli(app_dir, "entry", "reveal", "python-api")
|
|
check(
|
|
"TS unlocks an argon2id-protected Python profile",
|
|
revealed == "py-secret-value",
|
|
revealed,
|
|
)
|
|
except Exception as exc:
|
|
check("TS unlocks an argon2id-protected Python profile", False, str(exc))
|
|
|
|
|
|
def phase_j(tmp: Path) -> None:
|
|
print("\nPhase J: config file interoperates")
|
|
from utils.fingerprint import generate_fingerprint
|
|
|
|
app_dir = tmp / "g" # reuse the profile from phase G
|
|
fp = generate_fingerprint(SEED_A)
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A}
|
|
|
|
# Python writes config -> TS reads it
|
|
_vault, _em, cfg_mgr = _py_vault(app_dir, fp, SEED_A)
|
|
cfg = cfg_mgr.load_config(require_pin=False)
|
|
cfg["clipboard_clear_delay"] = 77
|
|
cfg["relays"] = ["wss://relay.example.test"]
|
|
cfg_mgr.save_config(cfg)
|
|
ts_cfg = json.loads(run_cli(app_dir, "config", "get", env_extra=env))
|
|
check(
|
|
"TS reads Python's config",
|
|
ts_cfg.get("clipboard_clear_delay") == 77
|
|
and ts_cfg.get("relays") == ["wss://relay.example.test"],
|
|
json.dumps({k: ts_cfg.get(k) for k in ("clipboard_clear_delay", "relays")}),
|
|
)
|
|
|
|
# TS writes config -> Python reads it
|
|
run_cli(app_dir, "config", "set", "inactivity_timeout", "123", env_extra=env)
|
|
_v2, _e2, cfg_mgr2 = _py_vault(app_dir, fp, SEED_A)
|
|
py_cfg = cfg_mgr2.load_config(require_pin=False)
|
|
check(
|
|
"Python reads TS's config",
|
|
int(py_cfg.get("inactivity_timeout", 0)) == 123,
|
|
str(py_cfg.get("inactivity_timeout")),
|
|
)
|
|
|
|
|
|
def phase_k(tmp: Path) -> None:
|
|
print("\nPhase K: rollback — a profile driven by TS still works in Python")
|
|
from utils.fingerprint import generate_fingerprint
|
|
|
|
app_dir = tmp / "k"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A}
|
|
|
|
# Simulate a migration window: the user works exclusively in TS for a
|
|
# while, exercising creation, modification and archival.
|
|
run_cli(app_dir, "entry", "add", "password", "post-migration-site", "--length", "22", env_extra=env)
|
|
run_cli(app_dir, "entry", "add", "key-value", "post-migration-kv", "k", "rollback-value", env_extra=env)
|
|
run_cli(app_dir, "entry", "add", "totp", "post-migration-totp", env_extra=env)
|
|
run_cli(app_dir, "entry", "modify", "0", "--notes", "touched by ts", env_extra=env)
|
|
run_cli(app_dir, "entry", "archive", "1", env_extra=env)
|
|
|
|
# Then they roll back to Python and must lose nothing.
|
|
try:
|
|
py_seed, py_index = py_open_profile(app_dir, fp, PASSWORD)
|
|
except Exception as exc:
|
|
check("Python reopens a TS-driven profile", False, str(exc))
|
|
return
|
|
check("Python reopens a TS-driven profile", py_seed == SEED_A)
|
|
|
|
labels = {e["label"] for e in py_index.get("entries", {}).values()}
|
|
check(
|
|
"TS-created entries survive the rollback",
|
|
{"post-migration-site", "post-migration-kv", "post-migration-totp"} <= labels,
|
|
str(sorted(labels)),
|
|
)
|
|
entry0 = py_index["entries"]["0"]
|
|
check("TS edits survive the rollback", entry0.get("notes") == "touched by ts")
|
|
check(
|
|
"TS archive state survives the rollback",
|
|
bool(py_index["entries"]["1"].get("archived")),
|
|
)
|
|
|
|
# And Python can still derive the secrets for what TS created.
|
|
py_secrets = py_secrets_for(py_index, SEED_A)
|
|
ts_secrets = ts_secrets_for(app_dir, ["post-migration-site", "post-migration-kv"], SEED_A)
|
|
check(
|
|
"secrets for TS-created entries match in Python",
|
|
all(
|
|
py_secrets[k].rstrip("\n") == ts_secrets[k].rstrip("\n")
|
|
for k in ts_secrets
|
|
),
|
|
)
|
|
|
|
# Finally, Python must still be able to write to the profile.
|
|
_vault, em, _cfg = _py_vault(app_dir, fp, SEED_A)
|
|
em.add_entry("written-after-rollback", 16)
|
|
rows = json.loads(run_cli(app_dir, "entry", "list", env_extra=env))
|
|
check(
|
|
"Python can still write, and TS sees it",
|
|
any(r["label"] == "written-after-rollback" for r in rows),
|
|
)
|
|
|
|
|
|
def phase_l(tmp: Path) -> None:
|
|
print("\nPhase L: foreign data round-trip — unknown kinds/fields survive A->B->A")
|
|
# Spec section 8.6: for a vault holding records neither side fully
|
|
# understands (a future BitLogin kind, unknown fields, an unknown
|
|
# top-level index key), an interleaved Python -> TS -> Python
|
|
# read-modify-write cycle must preserve every foreign byte. The trap
|
|
# fields (blacklisted/website/words) are names Python's legacy
|
|
# migrations act on for ITS shapes; touching them on a foreign record
|
|
# would be reinterpretation, which the spec forbids.
|
|
app_dir = tmp / "l"
|
|
app_dir.mkdir()
|
|
fp = py_create_profile(app_dir, SEED_A, PASSWORD)
|
|
env = {"SEEDPASS_MNEMONIC": SEED_A}
|
|
|
|
foreign_entry = {
|
|
"kind": "bitlogin_org",
|
|
"type": "bitlogin_org",
|
|
"label": "Acme Corporation",
|
|
"modified_ts": 1700000123,
|
|
"blacklisted": "foreign meaning, not our archive flag",
|
|
"website": "foreign meaning, not our label alias",
|
|
"words": ["foreign", "list"],
|
|
"bitlogin": {"admins": ["npub1aaaa"], "roles": {"sales": ["npub1bbbb"]}, "policy_rev": 7},
|
|
}
|
|
foreign_top_level = {"spec": "bitlogin-v1", "org_count": 1}
|
|
|
|
# Python plants the foreign data at a fresh id beside the native entries.
|
|
vault, em, _cfg = _py_vault(app_dir, fp, SEED_A)
|
|
data = vault.load_index()
|
|
pre_count = len(data["entries"])
|
|
foreign_id = str(max((int(k) for k in data["entries"]), default=-1) + 1)
|
|
data["entries"][foreign_id] = dict(foreign_entry)
|
|
data["bitlogin_meta"] = dict(foreign_top_level)
|
|
vault.save_index(data)
|
|
|
|
# Python read-modify-write on a neighbour.
|
|
em2 = _py_vault(app_dir, fp, SEED_A)[1]
|
|
em2.add_entry("native-python", 16)
|
|
|
|
# TS read-modify-write: create and edit, both of which re-validate and
|
|
# re-encrypt the whole index.
|
|
run_cli(app_dir, "entry", "add", "key-value", "native-ts", "k", "v", env_extra=env)
|
|
run_cli(app_dir, "entry", "modify", "0", "--notes", "touched", env_extra=env)
|
|
|
|
# Python reopens: every foreign byte must be exactly as planted.
|
|
_seed, final = py_open_profile(app_dir, fp, PASSWORD)
|
|
check(
|
|
"foreign entry survives Python->TS->Python byte-for-byte",
|
|
final["entries"].get(foreign_id) == foreign_entry,
|
|
json.dumps(final["entries"].get(foreign_id), sort_keys=True)[:200],
|
|
)
|
|
check(
|
|
"foreign top-level index key survives",
|
|
final.get("bitlogin_meta") == foreign_top_level,
|
|
json.dumps(final.get("bitlogin_meta"), sort_keys=True),
|
|
)
|
|
labels = {e.get("label") for e in final["entries"].values()}
|
|
check(
|
|
"native entries from both sides coexist with the foreign record",
|
|
{"native-python", "native-ts"} <= labels,
|
|
str(sorted(str(l) for l in labels)),
|
|
)
|
|
check(
|
|
"allocation skipped past the foreign id (no collisions, no reuse)",
|
|
len(final["entries"]) == pre_count + 3
|
|
and max(int(k) for k in final["entries"]) == int(foreign_id) + 2,
|
|
str(sorted(final["entries"].keys(), key=int)),
|
|
)
|
|
# And the foreign record is invisible to secret materialization rather
|
|
# than guessed at: TS reveal must refuse it.
|
|
try:
|
|
run_cli(app_dir, "entry", "reveal", f"sp://entry/{foreign_id}", env_extra=env)
|
|
refused = False
|
|
except Exception:
|
|
refused = True
|
|
check("TS refuses to reveal the foreign record", refused)
|
|
|
|
|
|
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)
|
|
phase_e(tmp)
|
|
phase_g(tmp)
|
|
phase_h(tmp)
|
|
phase_i(tmp)
|
|
phase_j(tmp)
|
|
phase_k(tmp)
|
|
phase_l(tmp)
|
|
if not args.skip_relay:
|
|
phase_f(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 divergences:
|
|
print(f"\n{len(divergences)} known divergence(s):")
|
|
for name, detail in divergences:
|
|
print(f" - {name}: {detail}")
|
|
if failed:
|
|
print("\nFailures:")
|
|
for name, _ok, detail in failed:
|
|
print(f" - {name}: {detail}")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|