BIP-39 mnemonic → identity, agent secret, chain key · seedkit/README.md
seedkit — the seed-phrase recovery layer ("lose the device, keep the identity")
Created 2026-09-09 by qalcode (project: crypto_currency)
Before seedkit, a qalchat identity (X25519 + ed25519) and its loopd agent secret were random keys born inside single JSON files. Lose the file — a dead laptop, a wiped phone — and you lost the identity, every conversation key derived from it, and the binding to your money rail. seedkit replaces key origination with a BIP-39 mnemonic: 12 or 24 words mint everything, and the same words re-mint the same everything, forever.
"armadillo quilt … vinyl" (12 or 24 BIP-39 words — the ONLY secret)
│ PBKDF2-HMAC-SHA512(mnemonic, "mnemonic"+passphrase, 2048, 64B)
▼
seed (64 bytes) ── fingerprint = sha256(seed)[:16] (NON-secret marker
│ stored in identity)
│ M = HMAC-SHA512(b"qal-derivation-v1", seed)
│ leaf(p) = HMAC-SHA512(M[:32], b"qal/leaf/v1|"+p+b"|"+idx)[:32]
├────────────┬──────────────────────┬───────────────────────────
▼ ▼ ▼ ▼
leaf("identity/ leaf("identity/ leaf("loopd/ leaf("chain/slot0")
x25519") ed25519") agent-secret") RESERVED —
→ qalchat → qalchat → 64-hex api_secret, future Qalnet
ECDH key signing key pushed to loopd via chain key slot
(b64 raw, (b64 raw, /agents/bind; HMAC-
identical to identical to signs every /transfer
new_keypairs()) new_keypairs())
That is the whole scheme (qal-derivation v1 — frozen label; a v2 must use a
different master label). One HMAC level, hard-coded purpose labels, index
reserved. Adding new purposes later never perturbs existing keys. Keypair
construction matches qalchat.new_keypairs() byte-for-byte (raw 32-byte
scalars/seeds via the cryptography library), so the wire format is
untouched — envelopes are sealed exactly as before; only where the keys come
from changed.
What ships here
| File | What |
|---|---|
bip39.py |
generate / validate / mnemonic→seed. Stdlib only. Validated against all 24 official BIP-39 English test vectors (trezor/python-mnemonic). |
derive.py |
qal-derivation v1 (+ CLI: mnemonic, validate, seed, keys). |
wordlist.txt |
VENDORED — preserve. Official BIP-39 English list, 2048 words, sha256 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda. Fetched once: curl https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt. |
test_seed.sh |
the money test (39 assertions): seed → use → destroy device → recover → use again, plus wrong-mnemonic hijack proof. |
qalchat integration (wire format unchanged)
qalchat.py init alice --from-seed # new phrase (12 words, shown ONCE)
qalchat.py init alice --from-seed --bits 256 # 24 words
qalchat.py init alice --from-seed --mnemonic "…" # restore at init time
qalchat.py init alice --from-seed --passphrase "…" # the optional 25th word
qalchat.py backup alice # re-show the phrase (Phase-0 stub)
qalchat.py recover alice "armadillo quilt …" # device-loss recovery
- The identity file gains one non-secret marker:
"seed": {scheme, fingerprint, words, passphrase: bool}. The mnemonic itself is NEVER written into the identity. The fingerprint lets a device prove "this identity is seed-derived v1" and match a phrase to it without storing the phrase. - loopd agent secret is a leaf of the same seed.
init --from-seedpushes it to loopd viaPOST /agents/bind(loopd marks the agent claimed without ever having held an unclaimed secret).recoverre-sends the same derived secret: loopd matches it → same agent_id, same balance, same cap — the money rail survives the device. A different secret for a claimed handle → 409, refused. recoverre-syncs the ratchet from the relay's append-only audit dump (envelopes.jsonl; override with--relay-dump, disable with--no-replay). Every replayed envelope is verified — msg_hash, signature (ours with the restored key), full AES-GCM decryption, and sender-side chain linkage. Our own envelopes that fail the signature (impostor artifacts) are skipped; ANY other doubt aborts that conversation's replay (fail-closed). Without a replay source the conversation starts cold and the peer sees one chain-gap rejection — fail-closed, never fail-open.recover --forceover an existing identity archives first (data/archive/recover_<ts>/) — identity, history, pins, stub. Nothing is ever deleted.
Passphrase guidance
The passphrase ("25th word") is mixed into the PBKDF2 salt: different passphrase ⇒ completely different seed ⇒ different every key. Use it when the words alone are too weak a boundary (shared accommodation, cloud-exposed notes, border crossings). Rules: never store it with the words; a phrase without its passphrase cannot be brute-forced (2048-round PBKDF2 per guess); if you use one, your paper backup must include a hint, not the passphrase.
Security notes — read before shipping the wallet
- The mnemonic IS the root key. Anyone holding the words (and passphrase, if set) IS the identity: reads future messages, spends from the agent. There is no reset, no support desk.
- Show once, store offline.
init --from-seedprints the phrase once. The wallet app must render it for capture (paper/steel) and persist NOTHING. Phase-0 CLI keeps a 0600 stub underdata/seed_backups/sobackupcan re-show it — that stub is a convenience with a cost: it is as sensitive as the identity file itself. Move offline, then delete. - The seed does NOT restore conversation history — by design. History and ratchet state are device-local; recovery restores keys, and the audit trail re-syncs counters/chains. Past ciphertext an attacker captured stays unreadable to them exactly as before (no forward-secret regression: the seed protects nothing retroactively because it never protected past traffic — the same was true of the random key it replaces).
- Loss of the phrase = permanent loss. Exactly like crypto wallets. The fingerprint marker in identity files is safe to share (64 bits, non-secret) and lets you verify a phrase matches an identity before trusting it.
- Wrong-phrase hijacks fail loudly, proven in the money test: loopd refuses the bind (409), the relay refuses the key takeover (first-write-wins), and the peer's TOFU pin rejects the impostor's envelopes (exit 3, "signature invalid").
- Phase-0 caveats, honestly:
/agents/bindcarries the derived secret over plain HTTP on localhost (fine for the single-host mesh; wrap in TLS + auth before multi-host); the relay audit dump is the recovery source, so a malicious relay could withold it (recovery then degrades to fail-closed, not to key compromise);check_authin loopd still compares HMACs in non-constant time for the lookup (compare_digest is used — fine).
Prove it
bash seedkit/test_seed.sh # 39 assertions, live loopd :8823 + relay :8830
bash qalchat/test_chat.sh # regression: 38 assertions stay GREEN