Report suspected vulnerabilities privately via GitHub Security Advisories. Please do not open a public issue for an unpatched vulnerability.
A past point-in-time review is kept in SECURITY_AUDIT.md.
This section documents where Archon's private keys come from and how much entropy they carry. It exists because "the private key is 256 bits" is true of the value and misleading about the entropy — the two differ, and the difference is worth stating plainly.
Every claim below was verified against the code and by executing the relevant paths, not read off documentation.
bip39.generateMnemonic() 128 bits of entropy (12 words)
↓ mnemonicToSeedSync — PBKDF2-HMAC-SHA512, 2048 iterations
512-bit seed still 128 bits of entropy
↓ HDKey.fromMasterSeed → derive("m/44'/0'/{account}'/0/{index}")
32-byte secp256k1 private key 256-bit value, 128 bits of entropy
packages/cipher/src/cipher-base.ts calls bip39.generateMnemonic() with no strength argument; bip39 v3.1.0 defaults to strength = 128. The Python port states it explicitly — python/keymaster/src/keymaster/crypto.py uses Mnemonic("english").generate(strength=128) — so both implementations agree.
The PBKDF2 stretch to a 512-bit seed adds no entropy. Stretching increases the cost of a brute-force attempt per guess; it does not increase the number of guesses required.
secp256k1 provides roughly 128-bit security — Pollard's rho solves the discrete log on a 256-bit curve in about 2¹²⁸ operations. Seed entropy of 128 bits is therefore matched to the curve, not the limiting factor. Raising the mnemonic to 256 bits (24 words) would not make the resulting keys harder to attack; the curve still caps the attacker's work at ~2¹²⁸.
12 words is also the dominant default across BIP-39 wallets.
Every identity in a wallet derives from one master seed, distinguished only by the account index in the derivation path. Compromising the mnemonic compromises every DID derived from it, across all accounts, retroactively and permanently.
This is inherent to hierarchical-deterministic wallets and is intended — it is what makes a single mnemonic a complete backup. It does mean the mnemonic is the entire security boundary for wallet-derived keys, and that separate mnemonics, not separate accounts, are what actually isolate identities.
This is by design: a node has one mnemonic, and everything it controls derives from it. The wallet mediators hold no seed of their own — each fetches the Keymaster's over HTTP at startup and derives its chain keys from it:
// services/mediators/*/src/wallet-api.ts
const response = await axios.get(`${config.keymasterURL}/api/v1/wallet/mnemonic`, { headers });So the same 128-bit seed that produces your DIDs also produces the on-chain spending keys:
| service | derivation path | standard |
|---|---|---|
| Identity / DID keys | m/44'/0'/{account}'/0/{index} |
BIP-44 |
| DIDComm X25519 | m/44'/0'/{account}'/1/0 |
dedicated branch |
Bitcoin (satoshi-wallet) |
m/84'/{0|1}'/0' |
BIP-84 native segwit |
Ethereum (ethereum-wallet) |
m/44'/60'/0'/0/0 |
BIP-44 |
Solana (solana-wallet) |
m/44'/501'/0'/0' |
ed25519-hd-key |
Zcash (zcash-wallet) |
m/44'/{133|1}'/0'/0/{i} |
BIP-44 transparent |
Filecoin (filecoin-wallet) |
m/44'/461'/0'/0/0 |
BIP-44 |
Chain paths are overridable per service (ARCHON_WALLET_ETH_DERIVATION_PATH and equivalents); the identity paths are not.
The benefit is the usual HD-wallet one, applied node-wide: twelve words back up the entire node — every DID, on every account, plus every chain wallet — and restoring them reconstructs all of it. There is no per-service key material to enumerate, escrow, or lose.
The cost is that the mnemonic is a funds-bearing secret, not only an identity secret. Compromising it does not merely expose every DID; it yields spending authority over every chain wallet the node operates. Treat it accordingly, and note that isolating funds from identities — or one identity from another — requires a separate node with its own mnemonic, not a separate account index.
Two operational consequences follow from the mediators fetching rather than holding the seed:
GET /api/v1/wallet/mnemonicreturns the decrypted mnemonic. It sits behind the admin-key middleware, but that middleware passes the request through whenARCHON_ADMIN_API_KEYis unset — a deliberate development-mode behaviour that the Keymaster warns about at startup (Warning: ARCHON_ADMIN_API_KEY is not set — admin routes are unprotected). On a node reachable beyond localhost without that variable set, the mnemonic is readable by anyone who can reach the port. Set it.- The plaintext mnemonic crosses the service boundary on each fetch — at startup, and again on the metrics interval for backends that need it. Within a Docker network that is contained, but it means Keymaster's port is as sensitive as the wallet file itself, and exposing it beyond the compose network changes the threat model entirely.
getNostrKeys returns the imported nsec if one is present; otherwise it converts the identity's own secp256k1 keypair (fetchKeyPair) into nostr format. Absent an explicit import, the nostr identity and the DID share one private key — a compromise of either is a compromise of both, and rotating one rotates the other.
Lightning is different: the LNbits invoiceKey and adminKey are issued by that external service and stored in the wallet. They are not seed-derived and not recoverable from the mnemonic.
Two paths generate independent key material at full width:
| path | source | entropy |
|---|---|---|
Vault keypair (generateRandomJwk) |
secp.utils.randomPrivateKey() → randomBytes(48) reduced per FIPS 186 B.4.1 |
~256 bits |
Salts (generateRandomSalt) |
randomBytes(32) / getRandomValues(32) |
256 bits |
| Passphrase salt / IV | @noble/hashes randomBytes |
128 / 96 bits |
A vault keypair is not recoverable from the mnemonic. That is a deliberate trade-off in the opposite direction from the wallet keys, and worth knowing before assuming a seed backup covers everything.
DIDComm X25519 keys are seed-derived: generateX25519Jwk takes 32 bytes of HD-derived material and uses it directly as the X25519 private scalar, so the same seed always yields the same key-agreement keypair.
Every path reaches crypto.getRandomValues, but through two independent implementations, not one:
| generator | RNG | reached via |
|---|---|---|
| Mnemonic, salts, passphrase IV | @noble/hashes randomBytes |
bip39.generateMnemonic(), generateRandomSalt() |
| Vault keypair | @noble/secp256k1's own randomBytes |
secp.utils.randomPrivateKey() |
@noble/secp256k1 does not use @noble/hashes for this — it ships its own. The two are separate packages on separate version lines, so a dependency bump can change one and leave the other untouched. (A single install can also contain more than one resolved copy of @noble/hashes; @noble/curves nests its own.) Anything asserting a property of "the RNG" has to assert it of both.
Both refuse to degrade, by different mechanisms:
// @noble/hashes — captures globalThis.crypto at module load
throw new Error('crypto.getRandomValues must be defined');
// @noble/secp256k1 — reads globalThis.crypto at call time, then dereferences it
const cr = () => globalThis?.crypto;There is no silent degradation to Math.random() on any path, in either the Node or browser build. This is the property most worth preserving — a weak-RNG regression is invisible in tests and catastrophic in production, so tests/cipher/rng.test.ts asserts it of both implementations, in both directions: that each draws from crypto.getRandomValues, and that each throws rather than returning anything when no CSPRNG exists.
Note what those tests deliberately do not do. A degraded RNG still produces output that looks random, so neither statistical testing nor any assertion about the shape of a generated mnemonic would catch a substitution. Only provenance and the absence of a fallback are worth asserting.
crypto.getRandomValues is where Archon's responsibility ends, but it is not where the randomness originates. Below it, both implementations above converge on one chain — on a Linux node:
bip39.generateMnemonic() · randomPrivateKey() · generateRandomSalt()
↓ @noble/hashes randomBytes
crypto.getRandomValues Web Crypto
↓ Node routes this to OpenSSL RAND_bytes
OpenSSL 3.x CTR-DRBG (AES-256-CTR) periodically reseeded
↓ seeded via the OS entropy syscall
getrandom(2) BCryptGenRandom / getentropy elsewhere
↓
Linux kernel CSPRNG (ChaCha20)
↑ seeded and continuously reseeded from
interrupt timing jitter · RDSEED/RDRAND · bootloader/EFI seed · seed file across reboots
↑ which in turn originate in
thermal noise in silicon · timing variance between physically independent clocks
Note which branch actually runs: @noble/hashes tries crypto.getRandomValues first and only falls back to Node's crypto.randomBytes if it is absent. Since Node 19 exposes globalThis.crypto, the first branch is the one taken on every supported Node version and in browsers; the fallback is legacy compatibility that current deployments never reach.
Everything above is transport. A DRBG expands entropy, a seed file carries it across a reboot, getrandom(2) hands it over — none of them create any. Follow the inputs down and they terminate in two physical processes:
Thermal noise in silicon. RDSEED is backed by an on-die entropy source: a circuit deliberately driven into a metastable state, whose settling direction is decided by Johnson–Nyquist noise — the random thermal motion of charge carriers in the transistors. That raw output is biased, so the hardware conditions it (Intel's design runs it through AES-CBC-MAC) before RDSEED returns it. The randomness is manufactured by heat, not by an algorithm.
Timing variance between independent physical systems. The kernel timestamps hardware interrupts with the CPU cycle counter and mixes in the low bits. Those bits are unpredictable because the events and the clock measuring them are physically decoupled: a network packet's arrival is determined by events on another machine, a disk completion by mechanical and thermal conditions in the drive, and the sampling clock itself drifts against them through oscillator phase noise — which is, again, thermal. Related jitter-entropy designs measure variance in the CPU's own execution timing, arising from cache state, frequency scaling, and the same underlying noise.
So the honest answer to "where does the entropy ultimately come from" is: thermal noise in physical hardware, sampled either directly by a dedicated circuit or indirectly through timing. It bottoms out in statistical mechanics — and at the smallest scale, quantum effects — not in any part of the software stack.
Two caveats worth stating:
- This host is a WSL2 VM with no
hw_randomdevice (rng_availableisnone), so the kernel's hardware-RNG framework contributes nothing here. On such systems the arch-random path (RDSEEDon this i7-14700F) and interrupt timing carry the load. A cloud VM typically also getsvirtio-rng, which is the host's entropy passed through — it does not generate any, which is why a cloned guest is dangerous. - "Random" here means physically unpredictable, not provably indeterminate. The security claim is that no attacker can model thermal fluctuations in your specific silicon well enough to predict the output — not a metaphysical claim about determinism.
Three consequences worth understanding:
The CPU's hardware RNG is an input, not the source. Linux mixes RDSEED/RDRAND into the pool rather than emitting them directly, so a backdoored CPU RNG cannot by itself determine what Archon gets. (With random.trust_cpu, which many distributions enable, that hardware output is additionally credited toward initial seeding — it still passes through the ChaCha20 construction.)
Containers do not have their own entropy. A dockerised node shares the host kernel's CSPRNG rather than having one of its own. The quality of every key Archon generates is therefore a property of the host, so that is where this is hardened, not in the image.
The real risk is a cloned or freshly-booted image. This is the one failure mode that could actually make Archon keys predictable, and it happens before any Archon code runs:
- A VM cloned from a snapshot taken after the CRNG was seeded starts life with the same CRNG state as every other clone. Two nodes from one golden image can generate the same mnemonic.
- A golden image that ships a
/var/lib/systemd/random-seed(or equivalent) hands every instance the same seed file. - An embedded or minimal system booting with no seed file and little interrupt activity can sit with an uninitialised CRNG.
getrandom(2) blocking until initialisation is the protection against the third case, and it is why nothing here silently falls back to weak randomness. It is not protection against the first two — a cloned seeded state looks perfectly initialised. If you build node images, generate identities after first boot, not in the image, and strip any seed file from the template.
The mnemonic is stored encrypted, never in plaintext: PBKDF2-HMAC-SHA512 at 100,000 iterations (PBKDF2_ITERATIONS overrides it) over a 16-byte random salt, then AES-GCM with a 12-byte random IV.
Mnemonic strength cannot be raised through any public interface — generateMnemonic() takes no parameter in Cipher, CipherBase, or the Python port. A user can import a 24-word mnemonic via newWallet(mnemonic), and a 256-bit mnemonic derives correctly, but Archon will never generate one.
Given the curve-matching argument above, this is defensible. It would only be worth changing as a hedge against future concerns about entropy quality rather than for present cryptographic strength.
| property | value |
|---|---|
| Wallet seed entropy | 128 bits (12-word BIP-39) |
| Derived key value | 256-bit secp256k1 scalar |
| Effective security | ~128 bits, matched to secp256k1 |
| Derivation | m/44'/0'/{account}'/0/{index}, deterministic |
| Chain wallets | same mnemonic, BIP-44/84 coin paths, fetched from Keymaster |
| Vault keys | ~256 bits, independent, not seed-recoverable |
| RNG | two implementations (@noble/hashes, @noble/secp256k1); both throw rather than degrading |
| Entropy origin | thermal noise (RDSEED, interrupt timing) → kernel CSPRNG → OpenSSL DRBG |
| Main RNG risk | cloned VM images / shipped seed files |
| Mnemonic at rest | PBKDF2-SHA512 100k iters + AES-GCM |
| TS / Python parity | both fixed at 128 bits |