NUT-13: Deterministic Secrets
optional
depends on: NUT-09
In this document, we describe the process that allows wallets to recover their ecash balance with the help of the mint using a familiar 12 word seed phrase (mnemonic). This allows us to restore the wallet’s previous state in case of a device loss or other loss of access to the wallet. The basic idea is that wallets that generate the ecash deterministically can regenerate the same tokens during a recovery process. For this, they ask the mint to reissue previously generated signatures using NUT-09.
Deterministic secret derivation
An ecash token, or a Proof, consists of a
secret generated by the wallet, and a signature
C generated by the wallet and the mint in
collaboration. Here, we describe how wallets can deterministically
generate the secrets and blinding factors
r necessary to generate the signatures
C.
The wallet generates a seed derived from a 12-word
BIP39
mnemonic seed phrase that the user stores in a secure
place. The wallet uses the seed to derive
deterministic values for the secret and the blinding
factors r for every new ecash token that it
generates.
In order to do this, the wallet keeps track of a
counter_k for each keyset_k it uses. The
index k indicates that the wallet
MUST keep track of a separate counter for each
keyset k it uses. The wallet MUST
keep track of multiple keysets for every mint it interacts
with.
Versioned Secret Derivation
The secret derivation method depends on the keyset ID version the wallet derives secrets and blinding factors for.
- Keyset V2 (keyset IDs starting with
01): Use HMAC-SHA256 Derivation - Keyset V1 (deprecated) (keyset IDs starting
with
00): Use BIP32 Legacy Derivation
HMAC-SHA256 Derivation
For keysets with version byte 01, the wallet uses
counter_k, seed and
keyset_id as inputs to a Key Derivation Function
(KDF) whose output is used to derive secret and
r.
The HMAC-SHA256 KDF is built as the following:
message = b"Cashu_KDF_HMAC_SHA256" || keyset_id_bytes || counter_k_bytes || derivation_type_byte, where:"Cashu_KDF_HMAC_SHA256"is the domain separation or purpose string, encoded to bytes as UTF-8.keyset_id_bytesare the raw bytes ofkeyset_id(hex decoded).counter_k_bytesis the counter encoded as an unsigned 64-bit integer in big-endian format.derivation_type_byteis exactly 1 byte specifying the type of derivation required:0x00for secrets0x01for blinded messages
hmac_digest = HMAC_SHA256(seed, message), whereHMAC_SHA256is the hash-based message authentication code using SHA-256 as the hashing algorithm.secret = hmac_digestandblinding_factor = hmac_digest % N.
P2PK Derivation
Wallets are able to generate private keys in a deterministic way to have proofs locked to them.
The following BIP32 derivation path for derivation of the key:
m/129373'/10'/0'/0'/{counter}:
- 129373’: Purpose picked for P2PK derivation.
- 10’: Account for generating private keys for usage in P2PK.
{counter}is an incrementing, non-hardened BIP32 child index.
This will allow wallets to swap proofs that are still locked to a public key during a restore process.
Code Examples
Versioned Secret Derivation
Below are code examples with keyset version-dependent derivation.
Python:
import hmac
import hashlib
def derive_secret_and_r(seed: bytes, keyset_id: str, counter_k: int):
"""
Derive secret and blinding factor using appropriate method based on keyset version
"""
# Determine keyset version from first two characters
keyset_version = keyset_id[:2]
if keyset_version == "00":
# Use legacy BIP32 derivation for version 00
return derive_secret_and_r_bip32(seed, keyset_id, counter_k)
elif keyset_version == "01":
# Use HMAC-SHA256 derivation for version 01
return derive_secret_and_r_hmac(seed, keyset_id, counter_k)
else:
raise ValueError(f"Unsupported keyset version: {keyset_version}")
def derive_secret_and_r_hmac(seed: bytes, keyset_id: str, counter_k: int):
"""
HMAC-SHA256 derivation for keyset version 01
Semantics:
- secret = HMAC-SHA256(seed, msg || 0x00)
- r = OS2IP(HMAC-SHA256(seed, msg || 0x01)) mod N
- reject r == 0 (astronomically unlikely)
"""
# secp256k1 scalar field (group) order
SECP256K1_N = int(
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16
)
# Step 1: Create the message
message = b"Cashu_KDF_HMAC_SHA256" + bytes.fromhex(keyset_id) + counter_k.to_bytes(8, 'big')
# Step 2: Compute HMAC-SHA256
secret = hmac.new(seed, message + b"\x00", hashlib.sha256).digest()
blinding_factor_digest = hmac.new(seed, message + b"\x01", hashlib.sha256).digest()
# Step 3: Interpret digest as integer and reduce mod N
x = int.from_bytes(blinding_factor_digest, "big", signed=False)
r = x % SECP256K1_N
if r == 0:
raise RuntimeError("Derived invalid blinding scalar r == 0")
return secret, r
def derive_secret_and_r_bip32(seed: bytes, keyset_id: str, counter_k: int):
"""
Legacy BIP32 derivation for keyset version 00
"""
# Convert seed to mnemonic and derive master key
bip32 = BIP32.from_seed(seed)
# Calculate keyset_id_int for BIP32 derivation path
keyset_id_int = int.from_bytes(bytes.fromhex(keyset_id), "big") % (2**31 - 1)
# Derive secret and r using BIP32 paths
secret_path = f"m/129372'/0'/{keyset_id_int}'/{counter_k}'/0"
r_path = f"m/129372'/0'/{keyset_id_int}'/{counter_k}'/1"
secret = bip32.get_privkey_from_path(secret_path)
r = bip32.get_privkey_from_path(r_path)
return secret, r
TypeScript:
import * as crypto from "crypto";
// secp256k1 scalar field (group) order
const SECP256K1_N = BigInt(
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
);
function deriveSecretAndR(seed: Buffer, keysetId: string, counterK: number) {
// Determine keyset version from first two characters
const keysetVersion = keysetId.substring(0, 2);
if (keysetVersion === "00") {
// Use legacy BIP32 derivation for version 00
return deriveSecretAndRBip32(seed, keysetId, counterK);
} else if (keysetVersion === "01") {
// Use HMAC-SHA256 derivation for version 01
return deriveSecretAndRHmac(seed, keysetId, counterK);
} else {
throw new Error(`Unsupported keyset version: ${keysetVersion}`);
}
}
function deriveSecretAndRHmac(
seed: Buffer,
keysetId: string,
counterK: number,
) {
// Step 1: Create message
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counterK));
const message = Buffer.concat([
Buffer.from("Cashu_KDF_HMAC_SHA256"),
Buffer.from(keysetId, "hex"),
counterBuffer,
]);
const secretDerivation = Buffer.from([0]);
const blindingFactorDerivation = Buffer.from([1]);
// Step 2: Compute HMAC-SHA256
const secret = crypto
.createHmac("sha256", seed)
.update(Buffer.concat([message, secretDerivation]))
.digest();
const blindingFactorDigest = crypto
.createHmac("sha256", seed)
.update(Buffer.concat([message, blindingFactorDerivation]))
.digest();
// Step 3: OS2IP + modulo reduction
const x = BigInt("0x" + blindingFactorDigest.toString("hex"));
const r = x % SECP256K1_N;
if (r === 0n) {
throw new Error("Derived invalid blinding scalar r == 0");
}
return { secret, r };
}
function deriveSecretAndRBip32(
seed: Buffer,
keysetId: string,
counterK: number,
) {
// Legacy BIP32 derivation for version 00
// Implementation would use BIP32 library (e.g., bip32, bitcoinjs-lib)
// Calculate keyset_id_int for BIP32 derivation path
const keysetIdInt = BigInt(`0x${keysetId}`) % BigInt(2 ** 31 - 1);
// This is pseudocode - actual implementation depends on BIP32 library
const secretPath = `m/129372'/0'/${keysetIdInt}'/${counterK}'/0`;
const rPath = `m/129372'/0'/${keysetIdInt}'/${counterK}'/1`;
// const secret = bip32.derivePath(secretPath).privateKey;
// const r = bip32.derivePath(rPath).privateKey;
// Return placeholder for demonstration
throw new Error(
"BIP32 derivation requires additional library implementation",
);
}
Note: See the test vectors.
Legacy
Derivation (For Keyset Version 00)
[!NOTE] This derivation method is used for keysets with version
00(legacy keysets). Wallets MUST use this method when working with keysets that have IDs starting with00.
BIP32
derivation paths are used. The derivation path depends on the keyset ID of keyset_k, and the
counter_k of that keyset.
- Purpose’ =
129372'(UTF-8 for 🥜) - Coin type’ = Always
0' - Keyset id’ = Keyset ID represented as an integer
(
keyset_k_int) - Coin counter’ =
counter'(this value is incremented) secretorr=0or1
m / 129372' / 0' / keyset_k_int' / counter' / secret||r
This results in the following derivation paths:
secret_derivation_path = `m/129372'/0'/{keyset_k_int}'/{counter_k}'/0`
r_derivation_path = `m/129372'/0'/{keyset_k_int}'/{counter_k}'/1`
Here, {keyset_k_int} and {counter_k}
are the only variables that can change. keyset_k_int
is an integer representation (see below) of the keyset ID the
token is generated with. This means that the derivation path is
unique for each keyset. Note that the coin type is always
0', independent of the unit of the ecash.
[!NOTE] For examples, see the test vectors.
Counter
The wallet starts with counter_k := 0 upon
encountering a new keyset and increments it by 1
every time it has successfully minted new ecash with this keyset.
The wallet stores the latest counter_k in its
database for all keysets it uses. Note that we have a
counter (and therefore a derivation path) for each
keyset k. We omit the keyset index k in
the following of this document.
When encountering keysets with different versions, wallets
MUST use the appropriate derivation method based
on the keyset ID version and retain the existing
counter_k value for each keyset to ensure consistent
restore support across wallet implementations.
Keyset ID to Integer Mapping (deprecated)
[!CAUTION] This mapping is deprecated and unsafe to use due to its small keyspace.
The integer representation keyset_id_int of a
keyset is calculated from its hexadecimal
ID which has a length of 8 bytes or 16 hex characters. First,
we convert the hex string to a big-endian sequence of bytes. This
value is then modulo reduced by 2^31 - 1 to arrive at
an integer that is a unique identifier keyset_id_int.
Keyset IDs with version prefix 01
MUST be shortened to the first 8 bytes before
conversion.
Example in Python:
keyset_id_int = int.from_bytes(bytes.fromhex(keyset_id_hex), "big") % (2**31 - 1)
Example in JavaScript:
keysetIdInt = BigInt(`0x${keysetIdHex}`) % BigInt(2 ** 31 - 1);
Restore from seed phrase
Using deterministic secret derivation, a user’s wallet can
regenerate the same BlindedMessages in case of loss
of a previous wallet state. To also restore the corresponding
BlindSignatures to fully recover the ecash, the
wallet can either request the mint to re-issue past
BlindSignatures on the regenerated
BlindedMessages (see NUT-09) or download the entire database of
the mint (TBD).
The wallet takes the following steps during recovery:
- Determine the keyset version from the keyset ID
- Generate
secretandrfromcounterandkeysetusing the appropriate derivation method:- For keyset version
00: Use legacy BIP32 derivation - For keyset version
01: Use HMAC-SHA256 derivation
- For keyset version
- Generate
BlindedMessagefromsecret - Obtain
BlindSignatureforsecretfrom the mint - Unblind
BlindSignaturetoCusingr - Restore
Proof = (secret, C) - Check if
Proofis already spent
Generate
BlindedMessages
To generate the BlindedMessages, the wallet starts
with a counter := 0 and, for each increment of the
counter, generates a secret and
r using the appropriate derivation method based on
the keyset version.
For keyset version 00
(legacy):
secret = bip32.get_privkey_from_path(secret_derivation_path).hex()
r = self.bip32.get_privkey_from_path(r_derivation_path)
For keyset version 01
(HMAC-SHA256):
secret, r = derive_secret_and_r_hmac(seed, keyset_id, counter)
[!NOTE] For examples, see the test vectors.
Using the secret string and the private key
r, the wallet generates a
BlindedMessage. The wallet then increases the
counter by 1 and repeats the same
process for a given batch size. It is recommended to use a batch
size of 100.
The user’s wallet can now request the corresponding
BlindSignatures for these
BlindedMessages from the mint using the NUT-09 restore endpoint or by downloading
the entire mint’s database.
Generate
Proofs
Using the restored BlindSignatures and the
r generated in the previous step, the wallet can unblind the signature to C.
The triple (secret, C, amount) is a restored
Proof.
Check
Proofs states
If the wallet used the restore endpoint NUT-09 for regenerating the
Proofs, it additionally needs to check for the
Proofs spent state using NUT-07. The wallet deletes all
Proofs which are already spent and keeps the unspent
ones in its database.
Restoring batches
Usually, the user won’t remember the last state of
counter when starting the recovery process.
Therefore, wallets need to know how far they need to increment the
counter during the restore process to be confident to
have reached the most recent state.
The following approach is recommended:
- Set
counter = 0 - Select key derivation function: legacy for
00and HMAC-SHA256 for01keysets - Restore
Proofsin batches of 100, and incrementcounter - Repeat restore until three consecutive batches are returned empty
- Reset
counterto the value at the last successful restore + 1
Wallets restore Proofs in batches of 100. The
wallet starts with a counter=0 and increments it for
every Proof it generated during one batch. When the
wallet begins restoring the first Proofs, it is
likely that the first few batches will only contain spent
Proofs. Eventually, the wallet will reach a
counter that will result in unspent
Proofs which it stores in its database. The wallet
then continues to restore until three successive batches are
returned empty by the mint. This is to be confident that the
restore process did not miss any Proofs that might
have been generated with larger gaps in the counter
by the previous wallet that we are restoring.