feat(identity): seed-derivation verifier + KAT; rename "Your DID"→"Node DID"
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
67609eea91
commit
3a9d1db763
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,7 +179,7 @@
|
|||||||
"aiDataAccess": "AI Data Access",
|
"aiDataAccess": "AI Data Access",
|
||||||
"serverName": "Hostname",
|
"serverName": "Hostname",
|
||||||
"sessionStatus": "Session Status",
|
"sessionStatus": "Session Status",
|
||||||
"yourDid": "Your DID",
|
"yourDid": "Node DID",
|
||||||
"onionAddress": "Node .onion Address",
|
"onionAddress": "Node .onion Address",
|
||||||
"loggedIn": "Currently logged in",
|
"loggedIn": "Currently logged in",
|
||||||
"didHelper": "Decentralized identifier for passwordless auth",
|
"didHelper": "Decentralized identifier for passwordless auth",
|
||||||
|
|||||||
129
scripts/verify-seed-derivation.py
Normal file
129
scripts/verify-seed-derivation.py
Normal file
@ -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=<domain>) = 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user