cross-impl: live Python-publishes -> TS-restores relay round trip

Phase F starts the in-process NIP-01 relay, has the real Python NostrClient
publish a chunked snapshot to it, then restores that snapshot with the TS
CLI on a clean profile and checks the secrets survive.

It immediately caught a genuine restore bug: Python's index files wrap the
ciphertext in a kdf/ct JSON envelope, and a published snapshot carries
whatever the local file held. TS decrypted the fetched bytes directly, so
it failed on anything Python published -- invisible to the TS-only tests
because TS writes bare ciphertext. Restore (and delta replay) now parse the
wrapper first, exactly as decrypt_and_save_index_from_nostr does.

Regression test in relay.test.ts publishes a wrapper-format payload through
a live relay and restores it.

Cutover plan updated: gate 4 (sync protocol) now cites this end-to-end
evidence, and gate 5's remaining list drops SSH and migrations.

171 core + 46 CLI tests green; 19/19 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:24:56 -04:00
1 parent 1eee053ff0
commit 3ebaced2e3
5 files changed
+173 -11

No files matched your search

+9 -8
View File
@@ -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.
+1 -1
View File
@@ -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 |
+10 -2
View File
@@ -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<string, unknown>;
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++;
+32
View File
@@ -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) {
+121
View File
@@ -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)