Reference
TCC-0001 Wallet derivation
The TENRAI-HD-v1 rule that turns a 24-word recovery phrase and an optional passphrase into ML-DSA-65 keys. Constants, root, paths and the fingerprint.
On this page
| Number | TCC-0001 |
|---|---|
| Status | Final |
| Category | Wallet |
| Identifier | TENRAI-HD-v1, version byte 0x01 |
This convention specifies how a Tenrai seed wallet turns a recovery phrase and an optional recovery passphrase into ML-DSA-65 signing keys. The identifier is TENRAI-HD-v1 and the version byte is 0x01.
It is neither BIP-32 nor the BIP-39 seed derivation. BIP-39 serves only as an encoding of 256 bits of entropy. A wallet that implements BIP-39's PBKDF2-HMAC-SHA512 passphrase step will not reproduce these keys.
Recovery phrase
The phrase is a 24-word English BIP-39 mnemonic. No other word count and no other wordlist is accepted, on any interface.
entropy = BIP39_ENTROPY(mnemonic) exactly 32 bytes24 words carry 256 bits. 12 words carry 128 and are rejected, because the ML-DSA-65 key-generation seed is itself 32 bytes: a 128-bit phrase would produce a seed that looks like 256 bits of key material while holding half that.
Recovery passphrase
The passphrase is optional. Absent, empty, and a file holding only a trailing newline all mean the same thing.
passphrase_bytes = UTF8(NFKD(passphrase_text))Normalisation is NFKD and happens inside the derivation core, not in callers. Without it the same text could yield different keys depending on composition: U+00EF and U+0069 U+0308 both display as ï. After normalisation the encoded form must be at most 256 bytes; longer input is rejected, not truncated.
An empty passphrase is not a special case: the derivation below runs unchanged with a zero-length password.
Constants
VERSION = 0x01
MASTER_LABEL = "TENRAI-HD-v1/MLDSA65/SEED" ASCII, no terminator
PASSPHRASE_DOMAIN = "TENRAI-HD-v1/MLDSA65/PASSPHRASE" ASCII, no terminator
FINGERPRINT_DOMAIN = "TENRAI-HD-v1/WALLET-FINGERPRINT" ASCII, no terminator
ARGON2_TYPE = Argon2id
ARGON2_VERSION = 0x13 (19)
ARGON2_MEMORY = 65536 KiB (64 MiB)
ARGON2_ITERATIONS = 3
ARGON2_PARALLELISM = 1
ARGON2_OUTPUT = 32 bytes
ARGON2_SALT = 16 bytes
PURPOSE = 44
COIN_TYPE = 0x5452 (21586)All integers are big-endian.
Root derivation
salt = SHA-512( PASSPHRASE_DOMAIN || VERSION || entropy )[0..16]
stretched = Argon2id( password = passphrase_bytes,
salt = salt,
m = 65536,
t = 3,
p = 1,
version = 0x13,
outlen = 32 )
master = HMAC-SHA512( key = MASTER_LABEL,
msg = VERSION || entropy || stretched )
secret = master[0..32]
chain_code = master[32..64]The entropy enters twice, as the Argon2 salt and directly in the HMAC message. The second use guarantees the root never carries less entropy than the phrase alone, whatever happens in the passphrase step.
(secret, chain_code) is the wallet root. Argon2id runs once per unlock, not once per address; every path below starts from the cached root.
Path derivation
Every component is hardened. There is no public parent-to-child derivation.
components = [ PURPOSE, COIN_TYPE, account, branch, index ]
for component in components:
hardened = component + 2^31
data = 0x00 || secret || BE32(hardened) (37 bytes)
material = HMAC-SHA512( key = chain_code, msg = data )
secret = material[0..32]
chain_code = material[32..64]
keygen_seed = secretaccount and index are below 2^31. branch is 0 for receive and 1 for change. keygen_seed is the 32-byte seed consumed by ML-DSA.KeyGen_internal in FIPS 204.
Wallet fingerprint
A wrong passphrase is not detectable: it yields a different, valid wallet with a zero balance and no error. The fingerprint lets a person catch that before relying on a restore.
pk = ML-DSA-65 public key at account 0, branch 0 (receive), index 0
digest = SHA-384( FINGERPRINT_DOMAIN || pk )
fingerprint = "TRAI-" || HEX4(digest[0..2]) || "-" || HEX4(digest[2..4])HEX4 is four upper-case hexadecimal digits of a big-endian 16-bit value, for example TRAI-9C41-0B7E. Interfaces display it when a wallet is created and when one is restored, and warn that a different passphrase opens a different wallet.
What the passphrase is for
It protects a compromised recovery phrase, such as a photographed backup or a copied file: the words alone no longer restore the wallet.
It does not raise the security level. The 24 words already supply 256 bits; Argon2id only makes a weak passphrase expensive to search for an attacker who already holds the phrase.
It is never stored. Interfaces hold it for the session only, so the vault password and the passphrase stay independent secrets.
Test vectors
crates/wallet/tests/vectors/wallet-derivation-v1.json in the node repository pins this construction. Each entry carries the mnemonic, the passphrase, the derivation path, the resulting keygen seed, the ML-DSA-65 public key hash, the address and the fingerprint.
