Docs
v1

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
NumberTCC-0001
StatusFinal
CategoryWallet
IdentifierTENRAI-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.

Text
entropy = BIP39_ENTROPY(mnemonic)          exactly 32 bytes

24 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.

Text
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

Text
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

Text
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.

Text
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 = secret

account 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.

Text
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.

Type to search guides, concepts and reference pages.