diff --git a/docs/typescript_cutover_plan.md b/docs/typescript_cutover_plan.md index abf40ddf..0cefa03a 100644 --- a/docs/typescript_cutover_plan.md +++ b/docs/typescript_cutover_plan.md @@ -42,19 +42,20 @@ P0 gate is green, Python remains the normative reference 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). + `js/packages/core/test/relay.test.ts`, plus a live end-to-end round trip + in cross-impl phase F: Python publishes a snapshot through a real relay + and the TS CLI restores it with secrets intact. **Green.** -5. **Feature parity for real daily use.** **Partial.** Missing: - - SSH and PGP key material (entries round-trip; derivation not ported) +5. **Feature parity for real daily use.** **Partial.** Remaining: + - PGP key material (entries round-trip; derivation not ported — highest + parity risk, PGPy serialization) - 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) + Done since this list was written: SSH key derivation (PEM byte-for-byte) + and schema migrations 0→4, so legacy Python profiles now open in TS. + 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. diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 2e3a8682..575b4ccc 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -51,7 +51,7 @@ until a later milestone. | Environment | Status | |---|---| -| Node 22 (vitest) | green — 170/170 core + 44 CLI | +| Node 22 (vitest) | green — 171/171 core + 44 CLI | | jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 7c74109f..ab1ebf18 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -37,6 +37,7 @@ import { fetchLatestSnapshot, fetchDeltasSince, decryptPayload, + parseEncryptedFile, mergeIndexPayloads, parseVaultIndex, sha256Hex, @@ -813,8 +814,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { const fetched = await fetchLatestSnapshot(pool, keys.privateKeyHex); if (!fetched) throw new Error("no snapshot found on the configured relays"); const indexKey = deriveIndexKeyBytes(mnemonic); + // A published snapshot carries whatever the local index file held. + // Python's index files wrap the ciphertext in a kdf/ct JSON envelope + // (TS writes bare ciphertext), so parse the wrapper first — exactly + // as Python's decrypt_and_save_index_from_nostr does. + const snapshotPayload = parseEncryptedFile(fetched.encrypted).ciphertext; let state = JSON.parse( - new TextDecoder().decode(await decryptPayload(indexKey, fetched.encrypted)), + new TextDecoder().decode(await decryptPayload(indexKey, snapshotPayload)), ) as Record; let deltaCount = 0; @@ -826,7 +832,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ); for (const delta of deltas) { const incoming = JSON.parse( - new TextDecoder().decode(await decryptPayload(indexKey, delta)), + new TextDecoder().decode( + await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext), + ), ); state = mergeIndexPayloads(state, incoming, sha256Hex(delta).slice(0, 16)); deltaCount++; diff --git a/js/packages/core/test/relay.test.ts b/js/packages/core/test/relay.test.ts index e023da07..b2903a07 100644 --- a/js/packages/core/test/relay.test.ts +++ b/js/packages/core/test/relay.test.ts @@ -18,6 +18,7 @@ import { decryptV3, encryptV3, mergeIndexPayloads, + parseEncryptedFile, utf8, bytesToHex, } from "@seedpass/core"; @@ -106,6 +107,37 @@ describe.skipIf(IS_JSDOM)("relay snapshot round trip", () => { expect(entries["99"]!.label).toBe("from-delta"); }); + it("restores a snapshot whose payload is a kdf/ct wrapper (Python's format)", async () => { + // Python index files wrap the ciphertext in a JSON envelope, and a + // published snapshot carries whatever the local file held. Restoring + // must parse that wrapper rather than decrypting the envelope bytes. + const key = deriveIndexKeyBytes(MNEMONIC); + const inner = base64.decode(vaultV3Payload.payload_b64); + const wrapped = utf8( + JSON.stringify({ + kdf: { name: "pbkdf2", version: 1, params: { iterations: 200000 }, salt_b64: "" }, + ct: Buffer.from(inner).toString("base64"), + }), + ); + + const wrapRelay = new MockRelay(); + await wrapRelay.start(); + const wrapPool = new RelayPool([wrapRelay.url], { timeoutMs: 3000 }); + try { + await publishSnapshot(wrapPool, PRIVKEY, deriveKeyIndex(MNEMONIC), wrapped, { + limit: 500, + }); + const fetched = await fetchLatestSnapshot(wrapPool, PRIVKEY); + expect(fetched).not.toBeNull(); + const { ciphertext } = parseEncryptedFile(fetched!.encrypted); + const plain = await decryptV3(key, ciphertext); + expect(bytesToHex(sha256(plain))).toBe(vaultV3Payload.plaintext_sha256); + } finally { + await wrapPool.close(); + await wrapRelay.stop(); + } + }); + it("rejects snapshots whose chunks fail hash verification", async () => { // Corrupt every stored chunk's content in the relay for (const ev of relay.events) { diff --git a/scripts/cross_impl_check.py b/scripts/cross_impl_check.py index e5e27ae4..2105bee1 100644 --- a/scripts/cross_impl_check.py +++ b/scripts/cross_impl_check.py @@ -535,6 +535,125 @@ def phase_e(tmp: Path) -> None: ) +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 main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--skip-relay", action="store_true") @@ -553,6 +672,8 @@ def main() -> int: phase_c(tmp) phase_d(tmp) phase_e(tmp) + if not args.skip_relay: + phase_f(tmp) finally: if not args.keep: shutil.rmtree(tmp, ignore_errors=True)