mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
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:
1 parent
1eee053ff0
commit
3ebaced2e3
5 files changed
+173
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user