From 3a9d1db763b3618fba766e1212e46479790166b2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 10:17:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(identity):=20seed-derivation=20verifier=20?= =?UTF-8?q?+=20KAT;=20rename=20"Your=20DID"=E2=86=92"Node=20DID"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/verify-seed-derivation.py: stdlib-only tool to cryptographically prove a node's on-disk keys (node_key→DID, nostr_secret→npub, fips_key) are derived from its onboarding seed exactly as seed.rs documents (BIP-39 → PBKDF2-HMAC- SHA512 → HKDF-SHA256 with per-key domain separation). - seed.rs: known-answer regression test cross-checking Rust node_key + nostr bytes against the Python verifier (locks the derivation). - en.json: "Your DID" → "Node DID". Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/seed.rs | 18 +++++ neode-ui/src/locales/en.json | 2 +- scripts/verify-seed-derivation.py | 129 ++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-seed-derivation.py diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index a0430dd3..ffe9532a 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -543,4 +543,22 @@ mod tests { } } } + + #[test] + fn test_node_key_known_answer_vs_python_verifier() { + // Cross-checks scripts/verify-seed-derivation.py: same mnemonic must + // produce the same node_key bytes in Rust and in the Python verifier. + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let key = derive_node_ed25519(&seed).unwrap(); + assert_eq!( + hex::encode(key.to_bytes()), + "3b4f4a1450450260ae360adb9c33ea5eb86356fa14454ca0067dd4b51ea8be87" + ); + let nostr = derive_node_nostr_key(&seed).unwrap(); + assert_eq!( + hex::encode(nostr.secret_key().to_secret_bytes()), + "3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831" + ); + } + } diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index a08b535a..39f9477e 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -179,7 +179,7 @@ "aiDataAccess": "AI Data Access", "serverName": "Hostname", "sessionStatus": "Session Status", - "yourDid": "Your DID", + "yourDid": "Node DID", "onionAddress": "Node .onion Address", "loggedIn": "Currently logged in", "didHelper": "Decentralized identifier for passwordless auth", diff --git a/scripts/verify-seed-derivation.py b/scripts/verify-seed-derivation.py new file mode 100644 index 00000000..f68b91ba --- /dev/null +++ b/scripts/verify-seed-derivation.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Cryptographically verify that a node's on-disk keys are deterministically +derived from its onboarding seed, exactly as documented in core/archipelago/ +src/seed.rs: + + BIP-39 mnemonic (24 words) + -> PBKDF2-HMAC-SHA512(2048, salt="mnemonic") = 64-byte seed + -> HKDF-SHA256(salt=None, IKM=seed, info=) = each 32-byte key + "archipelago/node/ed25519/v1" -> node_key (=> Node DID) + "archipelago/nostr-node/secp256k1/v1" -> nostr_secret (=> npub) + "archipelago/fips/secp256k1/v1" -> fips_key (FIPS transport) + +It compares each freshly-derived key against the bytes actually on disk under +/var/lib/archipelago/identity/. A MATCH proves the on-disk key was derived from +the seed (and nothing else). Also prints the resulting did:key for cross-check +against Settings -> Node DID. + +Usage (run on the node): + sudo python3 verify-seed-derivation.py + # paste the 24-word mnemonic when prompted (input is hidden, never logged) + +Pure standard library — no third-party crypto packages required. +""" +import sys, os, hmac, hashlib, getpass, unicodedata + +IDENT = "/var/lib/archipelago/identity" + +DOMAINS = { + "node_key (=> Node DID)": (b"archipelago/node/ed25519/v1", f"{IDENT}/node_key", "raw"), + "nostr_secret (=> node npub)": (b"archipelago/nostr-node/secp256k1/v1", f"{IDENT}/nostr_secret", "nsec"), + "fips_key (FIPS transport)": (b"archipelago/fips/secp256k1/v1", f"{IDENT}/fips_key", "nsec"), +} + + +def hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes: + """RFC 5869 HKDF-SHA256 with salt=None (== HashLen zero bytes).""" + salt = b"\x00" * hashlib.sha256().digest_size + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + okm, t, i = b"", b"", 1 + while len(okm) < length: + t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest() + okm += t + i += 1 + return okm[:length] + + +# --- minimal bech32 decode (BIP-173) to recover the 32-byte secret from nsec --- +_B32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bech32_decode_data(s: str) -> bytes: + s = s.strip().lower() + pos = s.rfind("1") + data = [_B32.index(c) for c in s[pos + 1:]] + data = data[:-6] # drop 6-char checksum + # convert 5-bit groups -> 8-bit bytes + acc = bits = 0 + out = bytearray() + for v in data: + acc = (acc << 5) | v + bits += 5 + if bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + return bytes(out) + + +# --- minimal base58btc + multicodec to render did:key from the ed25519 pubkey --- +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def _b58(b: bytes) -> str: + n = int.from_bytes(b, "big") + s = "" + while n: + n, r = divmod(n, 58) + s = _B58[r] + s + return "1" * (len(b) - len(b.lstrip(b"\x00"))) + s + + +def did_key_from_ed25519_pub(pub: bytes) -> str: + return "did:key:z" + _b58(b"\xed\x01" + pub) # 0xed01 = ed25519-pub multicodec + + +def main() -> int: + if not os.path.isdir(IDENT): + print(f"!! {IDENT} not found — run this on a node.") + return 2 + mnemonic = getpass.getpass("Paste the node's 24-word mnemonic (hidden): ").strip() + words = mnemonic.split() + if len(words) != 24: + print(f"!! expected 24 words, got {len(words)}") + return 2 + # BIP-39: seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic"+passphrase, 2048, 64) + norm = unicodedata.normalize("NFKD", " ".join(words)).encode("utf-8") + seed = hashlib.pbkdf2_hmac("sha512", norm, b"mnemonic", 2048, 64) + + all_ok = True + for name, (info, path, fmt) in DOMAINS.items(): + derived = hkdf_sha256(seed, info, 32) + try: + raw = open(path, "rb").read() + disk = raw if fmt == "raw" else _bech32_decode_data(raw.decode().strip()) + disk = disk[:32] + except Exception as e: + print(f"[{name}] could not read {path}: {e}") + all_ok = False + continue + ok = disk == derived + all_ok &= ok + print(f"[{'MATCH ✅' if ok else 'MISMATCH ❌'}] {name}") + print(f" derived(seed): {derived.hex()}") + print(f" on-disk : {disk.hex()}") + + # Render the Node DID from node_key.pub for a visual cross-check vs the UI. + try: + pub = open(f"{IDENT}/node_key.pub", "rb").read()[:32] + print(f"\nNode DID (from node_key.pub): {did_key_from_ed25519_pub(pub)}") + print(" ^ should equal Settings -> Node DID") + except Exception: + pass + + print("\n==> ALL KEYS SEED-DERIVED ✅" if all_ok else "\n==> SOME KEYS DID NOT MATCH ❌") + return 0 if all_ok else 1 + + +if __name__ == "__main__": + sys.exit(main())