Post-quantum cryptography has crossed the threshold from research to deployment. In August 2024, NIST finalized two post-quantum signature schemes–ML-DSA and SLH-DSA–as the first production-grade replacements for RSA and ECDSA. That milestone starts a ten-year countdown: by 2035 every critical system in the U.S. is expected to retire classical public-key cryptography.

Why the rush? A large, fault-tolerant quantum computer running Shor’s algorithm would break the security of any scheme that hides secrets in factorisation or discrete-log problems. Signatures, key exchanges, and blockchain proofs of ownership would all be up for grabs. By contrast, Grover’s algorithm–the quantum attack on symmetric ciphers and hashes–only halves their effective key length. Doubling a symmetric key restores the lost margin, so AES-256 and SHA-256 already have a comfortable 128-bit quantum resistance. The urgent job is therefore to move signatures and key exchanges to post-quantum cryptography while leaving the hash and cipher layer intact.

This was largely the design brief and motivation for yellowpages; the first public, anonymous, post-quantum proof of Bitcoin ownership. We needed to put fresh post-quantum cryptography signatures in front of ordinary users without asking them to learn a new mental model for handling keys. The obvious candidate was the one crypto holders already trust: a BIP-39 seed phrase. Twelve or twenty-four everyday words expand, via standardised steps, into whatever private key the wallet needs. Copy the words once, keep them safe, and you can always restore your keys.

Seed phrases have drawbacks–lose the words and you lose your assets–but they remain far easier to secure than raw key material. Nobody remembers 256 random bits; everybody can write twenty-four short words onto paper (or ideally steel). So we decided to keep the seed phrase in our design.

The next question was algorithm choice. ML-DSA-44 offers fast verification and compact signatures; SLH-DSA-SHA2-S-128 trades more bandwidth for more battle tested cryptography. Which to pick? After a debate it was easy, we wanted both. If one scheme is ever weakened, users should have a simple way to instantly pivot to the other without requiring a new seed phrase or touching their backups. That requirement led us to the core idea of this post:

How can a user have one 24-word phrase that must deterministically generate every future post-quantum key they will ever need?

Achieving this goal requires three moving parts–BIP‑39 for the human‑friendly 24 word back-up, BIP‑32 to turn that seed into a hierarchical key tree, and BIP‑85 which extracts fresh, deterministic entropy from any branch of that tree.

⚠️ Warning: Rolling your own crypto: This is a custom cryptography implementation that we needed to do for yellowpages. If you don’t need to roll your own crypto, don’t. But if you want to take a custom approach like us, you should also get it audited to avoid implementation errors. We had ours audited by cure53.

Let’s take a look at how it works

First we generate a normal 24‑word BIP‑39 phrase. Internally that phrase expands into a 64‑byte seed. Feeding that seed into BIP‑32 produces a 32 byte master extended private key, a 32 byte chain-code, which is just an extra 32 bytes of secret data, and a hierarchical key tree. Up to this point nothing is novel; it is the same path most crypto wallets follow.

BIP 32 (Hierarchical Deterministic Wallets)

BIP 32 (Hierarchical Deterministic Wallets) - https://river.com/learn/terms/b/bip-32/

A hierarchical key tree sounds complex but in essence it is just a tree of nodes where the root of that tree is a special 256-bit random number we call the “master extended private key” and each leaf in the tree is just another 256-bit random number. We define each leaf by its path from the root, something that looks like this:

m / 44' / 0' / 0'

Thus, every branch in a BIP-32 tree acts like a deterministic random-number generator: each child leaf is a fresh, pseudorandom 256-bit value that you can always regenerate by visiting the same path in the tree. Typically these leaves are treated as private keys and used to create crypto wallets, but they can equally be used as entropy for post-quantum key generation.

BIP-85

Now all we have to define is a unique, three-part path for every post-quantum algorithm we support. The specific path we use for yellowpages is:

m / 83696968' / 503131' / <algorithm-id>'

The first part of the path is the purpose: m/83696968', which spells “BIPS” on a phone keypad and is defined in the BIP-85 spec. The second part is the application number: 503131', which spells “P11” and marks this subtree as belonging to yellowpages. The final part is the algorithm identifier. Right now ML-DSA-44 lives at …/0' and SLH-DSA-SHA2-S-128 lives at …/2', and we can allocate new IDs as new schemes are standardised (note .../1’ is reserved for test vectors but not something we need to worry about here).

Now that we have our path, we can feed that into our key tree and arrive at our post-quantum algorithm specific leaf, a cryptographically secure pseudorandom 256-bit number. We take that value and pass it straight into:

HMAC-SHA-512(key = "bip-entropy-from-k", msg = childPrivateKey)

HMAC-SHA-512 gives us a 512-bit digest (64 bytes). From that digest we simply slice off the exact number of bytes the target algorithm expects–32 bytes for ML-DSA-44, 48 bytes for SLH-DSA-SHA2-S-128, and so on–and pass that chunk straight into the key-generation routine for the post-quantum algorithm.

Everything is deterministic: the same twenty-four words combined with the same algorithm ID will always yield the same key-pair. At the same time, keys for different algorithms–or even different indices under one algorithm–are unlinkable because each branch is separated by its own HMAC barrier. Even if an attacker captures the digest or the derived post-quantum private key they cannot work backwards to the child branch, the 24-word root, or any sibling keys.

For the user the workflow is simple. Write down the twenty-four words once. Need to sign with ML-DSA-44? Derive m/83696968'/503131'/0', pass that value through the HMAC, pull 32 bytes off the digest, and generate the key-pair. Need SLH-DSA-SHA2-S-128? Derive m/83696968'/503131'/2', pass that value through the HMAC, pull 48 bytes off the digest, and generate the key-pair. If we introduce a brand-new algorithm next year, we give it the next free ID and ship an update; the same seed phrase already contains enough randomness.

Security remains layered. Losing one post-quantum private key does not endanger the root phrase or any sibling keys. Losing the phrase is still catastrophic, so we treat it like any other cold-storage secret–engrave it on steel, split it with Shamir, bury it in geographically separate vaults–but there is exactly one artefact to protect, not dozens.

In short, BIP-32 supplies deterministic “random” numbers, BIP-85 refines them into algorithm-specific entropy, and pq-crypto turns that entropy into quantum-safe keys–all from a single, human-memorable phrase. Users keep their backup burden unchanged while we quietly swap the cryptography under the hood–a win-win for usability and security.

A full flow diagram is included at the end of this post.

Code breakdown – four tiny steps from words to post-quantum keys

Below is the exact JavaScript/TypeScript we run in production, trimmed to the essentials and annotated for clarity. Each section maps to one of the four layers we introduced earlier.

1 . Generate (or validate) a 24-word BIP-39 phrase

// ----- Layer 1: BIP-39 human-readable backup -----
// Create a brand-new phrase
const generateSeedPhrase = (): Mnemonic24 => {
const mnemonic = generateMnemonic(wordlist, 256); // 256-bit entropy → 24 words
return ensure24WordMnemonic(mnemonic as Mnemonic24);
};
// Re-use an existing phrase (throws if it is not exactly 24 words)
function ensure24WordMnemonic(mnemonic: Mnemonic24): Mnemonic24 {
const words = mnemonic.trim().split(/\s+/);
if (words.length !== 24) throw new Error(`Expected 24 words, got ${words.length}`);
return mnemonic;
}

2 . Turn the mnemonic into a BIP-32 master key

// ----- Layer 2: BIP-32 hierarchical key tree -----
const seed = mnemonicToSeedSync(mnemonic24); // 64-byte BIP-39 seed
const masterNode = HDKey.fromMasterSeed(seed); // xprv root of the tree

(Buffers that hold seed are zeroed later; not shown here for readability.)

3 . Extract deterministic entropy with BIP-85

// ----- Layer 3: BIP-85 -----
const BIP85_PURPOSE = 83696968; // "BIPS"
const DEFAULT_APP_NO = 503131; // "P11"
const BIP85_HMAC_KEY = 'bip-entropy-from-k';
function deriveBip85Entropy(
root: HDKey,
algorithmId: number, // 0 = ML-DSA-44, 2 = SLH-DSA-SHA2-S-128, …
bytesNeeded: number // 32 or 48
): Uint8Array {
const path = `m/${BIP85_PURPOSE}'/${DEFAULT_APP_NO}'/${algorithmId}'`;
const node = root.derive(path); // hardened step
if (!node.privateKey) throw new Error('No key at path');
const digest = hmac(sha512, BIP85_HMAC_KEY, node.privateKey); // 512-bit output
node.wipePrivateData(); // scrub memory
return digest.slice(0, bytesNeeded); // trim to size
}

Example – entropy for ML-DSA-44 (needs 32 bytes):

const mlDsaEntropy = deriveBip85Entropy(masterNode, 0, 32);

4 . Feed that entropy into the post-quantum key generator

// ----- Layer 4: pass to PQ keygen routine -----
// ML-DSA-44
const mlDsaKeypair = ml_dsa44.keygen(mlDsaEntropy);
// SLH-DSA-SHA2-S-128 (needs 48 bytes instead of 32)
const slhDsaEntropy = deriveBip85Entropy(masterNode, 2, 48);
const slhDsaKeypair = slh_dsa_sha2_128s.keygen(slhDsaEntropy);

(Buffers holding entropy, seed, and any private keys are overwritten with zeros as soon as they are no longer needed as a best-effort in JavaScript).

Putting it together in one helper

function generatePQKeypair(mnemonic24: Mnemonic24,
alg: PQ_SIGNATURE_ALGORITHM) {
const seed = mnemonicToSeedSync(mnemonic24);
const master = HDKey.fromMasterSeed(seed);
const needed = PQ_ALGO_CONFIG[alg].entropyLength; // 32 or 48
const bytes = deriveBip85Entropy(master, alg, needed);
switch (alg) {
case PQ_SIGNATURE_ALGORITHM.ML_DSA_44:
return ml_dsa44.keygen(bytes);
case PQ_SIGNATURE_ALGORITHM.SLH_DSA_SHA2_S_128:
return slh_dsa_sha2_128s.keygen(bytes);
default:
throw new Error('Unsupported algorithm');
}
}

Call generatePQKeypair(mnemonic, ML_DSA_44) to get an ML-DSA key-pair, or switch the enum to SLH_DSA_SHA2_S_128 for the alternative scheme. Every other post-quantum algorithm we add in the future will slide into the same switch statement with its own byte requirement and numeric ID–no new backup words required.

One seed phrase → N PQ key pairs

  • Start with a 24-word BIP-39 phrase.
  • Feed it into BIP-32 to get a master extended private key and chain code.
  • Use BIP-85 with a unique path per algorithm (m/83696968’/503131’/’) to derive a 256-bit branch key.
  • Run HMAC-SHA-512 on that branch key, then take the first N bytes (32 for ML-DSA-44, 48 for SLH-DSA-SHA2-S-128).
  • Pass those bytes into the post-quantum key-generation routine.

Everything is deterministic and unlinkable. One backup phrase covers every current and future scheme. Future algorithms slot in by picking the next ID–no new words needed.

The full flow

PQ Key Generation Flow Diagram

PQ Key Generation Flow Diagram