{"id":"fb9cc60c598065cc61fdc0994d5545d2fe1f8819740e293050443aed8381c4c7","pubkey":"d1678e7ef965374bbea308a1215609a78376dc158277a7d657680f9d5efd5c38","created_at":1785406345,"kind":30817,"tags":[["d","nut-13"],["title","NUT-13: Deterministic Secrets"],["summary","Secrets derived from a seed phrase, so a wallet can regenerate its ecash after losing the device that held it."],["s","optional"],["t","cashu"],["t","ecash"],["t","nut"],["alt","A specification: NUT-13: Deterministic Secrets"],["client","openspecs-import"],["published_at","1711096853"],["proxy","https://github.com/cashubtc/nuts/blob/a845dfc998abae501fc3419592d53dc995d34b12/13.md","web"],["x","b2ae002c8ef8bdce5d133de2b83705431668adf967bb8824ab484673b75534b8"]],"content":"# NUT-13: Deterministic Secrets\n\n`optional`\n\n`depends on: NUT-09`\n\n---\n\nIn 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][09].\n\n## Deterministic secret derivation\n\nAn 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`.\n\nThe wallet generates a `seed` derived from a 12-word [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) `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.\n\nIn 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.\n\n#### Versioned Secret Derivation\n\nThe secret derivation method depends on the keyset ID version the wallet derives secrets and blinding factors for.\n\n- **Keyset V2** (keyset IDs starting with `01`): Use [HMAC-SHA256 Derivation](#hmac-sha256-derivation)\n- **Keyset V1 (deprecated)** (keyset IDs starting with `00`): Use [BIP32 Legacy Derivation](#legacy-derivation-deprecated)\n\n### HMAC-SHA256 Derivation\n\nFor keysets with version byte `01`, the wallet uses `counter_k`, `seed` and `keyset_id` as inputs to a Key Derivation Function (KDF) which output is used to derive `secret` and `r`.\n\nThe HMAC-SHA256 KDF is built as the following:\n\n1. `message = b\"Cashu_KDF_HMAC_SHA256\" || keyset_id_bytes || counter_k_bytes || derivation_type_byte`, where:\n   - `\"Cashu_KDF_HMAC_SHA256\"` is the domain separation or purpose string, encoded to bytes as UTF-8.\n   - `keyset_id_bytes` are the raw bytes of `keyset_id` (hex decoded).\n   - `counter_k_bytes` is the counter encoded as an unsigned 64-bit integer in big-endian format.\n   - `derivation_type_byte` is exactly 1 byte specifying the type of derivation required:\n     - `0x00` for secrets\n     - `0x01` for blinded messages\n2. `hmac_digest = HMAC_SHA256(seed, message)`, where `HMAC_SHA256` is the [hash-based message authentication code](https://en.wikipedia.org/wiki/HMAC) using SHA-256 as the hashing algorithm.\n3. `secret = hmac_digest` and `blinding_factor = hmac_digest % N`.\n\n#### P2PK Derivation\n\nWallets are able to generate private keys in a deterministic way to have proofs locked to them.\n\nThe following BIP32 derivation path for derivation of the key: `m/129373'/10'/0'/0'/{counter}`:\n\n- 129373': Purpose picked for P2PK derivation.\n- 10': Account for generating private keys for usage in P2PK.\n- `{counter}` is an incrementing, non-hardened BIP32 child index.\n\nThis will allow wallets to swap proof that are still locked to a public key during a restore process.\n\n### Code Examples\n\n#### Versioned Secret Derivation\n\nBelow are code examples with keyset version-dependent derivation.\n\nPython:\n\n```python\nimport hmac\nimport hashlib\n\ndef derive_secret_and_r(seed: bytes, keyset_id: str, counter_k: int):\n    \"\"\"\n    Derive secret and blinding factor using appropriate method based on keyset version\n    \"\"\"\n    # Determine keyset version from first two characters\n    keyset_version = keyset_id[:2]\n\n    if keyset_version == \"00\":\n        # Use legacy BIP32 derivation for version 00\n        return derive_secret_and_r_bip32(seed, keyset_id, counter_k)\n    elif keyset_version == \"01\":\n        # Use HMAC-SHA256 derivation for version 01\n        return derive_secret_and_r_hmac(seed, keyset_id, counter_k)\n    else:\n        raise ValueError(f\"Unsupported keyset version: {keyset_version}\")\n\ndef derive_secret_and_r_hmac(seed: bytes, keyset_id: str, counter_k: int):\n    \"\"\"\n    HMAC-SHA256 derivation for keyset version 01\n\n    Semantics:\n      - secret = HMAC-SHA256(seed, msg || 0x00)\n      - r      = OS2IP(HMAC-SHA256(seed, msg || 0x01)) mod N\n      - reject r == 0 (astronomically unlikely)\n    \"\"\"\n    # secp256k1 scalar field (group) order\n    SECP256K1_N = int(\n        \"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141\", 16\n    )\n\n    # Step 1: Create the message\n    message = b\"Cashu_KDF_HMAC_SHA256\" + bytes.fromhex(keyset_id) + counter_k.to_bytes(8, 'big')\n\n    # Step 2: Compute HMAC-SHA256\n    secret = hmac.new(seed, message + b\"\\x00\", hashlib.sha256).digest()\n    blinding_factor_digest = hmac.new(seed, message + b\"\\x01\", hashlib.sha256).digest()\n\n    # Step 3: Interpret digest as integer and reduce mod N\n    x = int.from_bytes(blinding_factor_digest, \"big\", signed=False)\n    r = x % SECP256K1_N\n\n    if r == 0:\n        raise RuntimeError(\"Derived invalid blinding scalar r == 0\")\n\n    return secret, r\n\ndef derive_secret_and_r_bip32(seed: bytes, keyset_id: str, counter_k: int):\n    \"\"\"\n    Legacy BIP32 derivation for keyset version 00\n    \"\"\"\n    # Convert seed to mnemonic and derive master key\n    bip32 = BIP32.from_seed(seed)\n\n    # Calculate keyset_id_int for BIP32 derivation path\n    keyset_id_int = int.from_bytes(bytes.fromhex(keyset_id), \"big\") % (2**31 - 1)\n\n    # Derive secret and r using BIP32 paths\n    secret_path = f\"m/129372'/0'/{keyset_id_int}'/{counter_k}'/0\"\n    r_path = f\"m/129372'/0'/{keyset_id_int}'/{counter_k}'/1\"\n\n    secret = bip32.get_privkey_from_path(secret_path)\n    r = bip32.get_privkey_from_path(r_path)\n\n    return secret, r\n```\n\nTypeScript:\n\n```typescript\nimport * as crypto from \"crypto\";\n// secp256k1 scalar field (group) order\nconst SECP256K1_N = BigInt(\n  \"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141\",\n);\n\nfunction deriveSecretAndR(seed: Buffer, keysetId: string, counterK: number) {\n  // Determine keyset version from first two characters\n  const keysetVersion = keysetId.substring(0, 2);\n\n  if (keysetVersion === \"00\") {\n    // Use legacy BIP32 derivation for version 00\n    return deriveSecretAndRBip32(seed, keysetId, counterK);\n  } else if (keysetVersion === \"01\") {\n    // Use HMAC-SHA256 derivation for version 01\n    return deriveSecretAndRHmac(seed, keysetId, counterK);\n  } else {\n    throw new Error(`Unsupported keyset version: ${keysetVersion}`);\n  }\n}\n\nfunction deriveSecretAndRHmac(\n  seed: Buffer,\n  keysetId: string,\n  counterK: number,\n) {\n  // Step 1: Create message\n  const counterBuffer = Buffer.alloc(8);\n  counterBuffer.writeBigUInt64BE(BigInt(counterK));\n  const message = Buffer.concat([\n    Buffer.from(\"Cashu_KDF_HMAC_SHA256\"),\n    Buffer.from(keysetId, \"hex\"),\n    counterBuffer,\n  ]);\n\n  const secretDerivation = Buffer.from([0]);\n  const blindingFactorDerivation = Buffer.from([1]);\n\n  // Step 2: Compute HMAC-SHA256\n  const secret = crypto\n    .createHmac(\"sha256\", seed)\n    .update(Buffer.concat([message, secretDerivation]))\n    .digest();\n  const blindingFactorDigest = crypto\n    .createHmac(\"sha256\", seed)\n    .update(Buffer.concat([message, blindingFactorDerivation]))\n    .digest();\n\n  // Step 3: OS2IP + modulo reduction\n  const x = BigInt(\"0x\" + blindingFactorDigest.toString(\"hex\"));\n  const r = x % SECP256K1_N;\n\n  if (r === 0n) {\n    throw new Error(\"Derived invalid blinding scalar r == 0\");\n  }\n\n  return { secret, r };\n}\n\nfunction deriveSecretAndRBip32(\n  seed: Buffer,\n  keysetId: string,\n  counterK: number,\n) {\n  // Legacy BIP32 derivation for version 00\n  // Implementation would use BIP32 library (e.g., bip32, bitcoinjs-lib)\n  // Calculate keyset_id_int for BIP32 derivation path\n  const keysetIdInt = BigInt(`0x${keysetId}`) % BigInt(2 ** 31 - 1);\n\n  // This is pseudocode - actual implementation depends on BIP32 library\n  const secretPath = `m/129372'/0'/${keysetIdInt}'/${counterK}'/0`;\n  const rPath = `m/129372'/0'/${keysetIdInt}'/${counterK}'/1`;\n\n  // const secret = bip32.derivePath(secretPath).privateKey;\n  // const r = bip32.derivePath(rPath).privateKey;\n\n  // Return placeholder for demonstration\n  throw new Error(\n    \"BIP32 derivation requires additional library implementation\",\n  );\n}\n```\n\n**Note:** See the [test vectors][tests].\n\n### Legacy Derivation (For Keyset Version `00`)\n\n> [!NOTE]\n> This derivation method is used for keysets with version `00` (legacy keysets).\n> Wallets **MUST** use this method when working with keysets that have IDs starting with `00`.\n\n[BIP32](https://en.bitcoin.it/wiki/BIP_0032) derivation paths are used.\nThe derivation path depends on the [keyset ID][02] of `keyset_k`, and the `counter_k` of that keyset.\n\n- Purpose' = `129372'` (UTF-8 for 🥜)\n- Coin type' = Always `0'`\n- Keyset id' = Keyset ID represented as an integer (`keyset_k_int`)\n- Coin counter' = `counter'` (this value is incremented)\n- `secret` or `r` = `0` or `1`\n\n`m / 129372' / 0' / keyset_k_int' / counter' / secret||r`\n\nThis results in the following derivation paths:\n\n```\nsecret_derivation_path = `m/129372'/0'/{keyset_k_int}'/{counter_k}'/0`\nr_derivation_path = `m/129372'/0'/{keyset_id_k_int}'/{counter_k}'/1`\n```\n\nHere, `{keyset_k_int}` and `{counter_k}` are the only variables that can change. `keyset_id_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.\n\n> [!NOTE]\n> For examples, see the [test vectors][tests].\n\n#### Counter\n\nThe 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.\n\nWhen 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.\n\n#### Keyset ID to Integer Mapping (deprecated)\n\n> [!CAUTION]\n> This mapping is deprecated and unsafe to use due to its small keyspace.\n\nThe integer representation `keyset_id_int` of a keyset is calculated from its [hexadecimal ID][02] 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.\n\nExample in Python:\n\n```python\nkeyset_id_int = int.from_bytes(bytes.fromhex(keyset_id_hex), \"big\") % (2**31 - 1)\n```\n\nExample in JavaScript:\n\n```javascript\nkeysetIdInt = BigInt(`0x${keysetIdHex}`) % BigInt(2 ** 31 - 1);\n```\n\n## Restore from seed phrase\n\nUsing 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 requests the mint to re-issue past `BlindSignatures` on the regenerated `BlindedMessages` (see [NUT-09][09]) or by downloading the entire database of the mint (TBD).\n\nThe wallet takes the following steps during recovery:\n\n1. Determine the keyset version from the keyset ID\n2. Generate `secret` and `r` from `counter` and `keyset` using the appropriate derivation method:\n   - For keyset version `00`: Use legacy BIP32 derivation\n   - For keyset version `01`: Use HMAC-SHA256 derivation\n3. Generate `BlindedMessage` from `secret`\n4. Obtain `BlindSignature` for `secret` from the mint\n5. Unblind `BlindSignature` to `C` using `r`\n6. Restore `Proof = (secret, C)`\n7. Check if `Proof` is already spent\n\n#### Generate `BlindedMessages`\n\nTo 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.\n\n**For keyset version `00` (legacy):**\n\n```python\nsecret = bip32.get_privkey_from_path(secret_derivation_path).hex()\nr = self.bip32.get_privkey_from_path(r_derivation_path)\n```\n\n**For keyset version `01` (HMAC-SHA256):**\n\n```python\nsecret, r = derive_secret_and_r_hmac(seed, keyset_id, counter)\n```\n\n> [!NOTE]\n> For examples, see the [test vectors][tests].\n\nUsing 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.\n\nThe user's wallet can now request the corresponding `BlindSignatures` for theses `BlindedMessages` from the mint using the [NUT-09][09] restore endpoint or by downloading the entire mint's database.\n\n#### Generate `Proofs`\n\nUsing the restored `BlindSignatures` and the `r` generated in the previous step, the wallet can [unblind][00] the signature to `C`. The triple `(secret, C, amount)` is a restored `Proof`.\n\n#### Check `Proofs` states\n\nIf the wallet used the restore endpoint [NUT-09][09] for regenerating the `Proofs`, it additionally needs to check for the `Proofs` spent state using [NUT-07][07]. The wallet deletes all `Proofs` which are already spent and keeps the unspent ones in its database.\n\n### Restoring batches\n\nUsually, 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.\n\nThe following approach is recommended:\n\n- Set `counter = 0`\n- Select key derivation function: legacy for `00` and HMAC-SHA256 for `01` keysets\n- Restore `Proofs` in batches of 100, and increment `counter`\n- Repeat restore until three consecutive batches are returned empty\n- Reset `counter` to the value at the last successful restore + 1\n\nWallets 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.\n\n[00]: nostr:naddr1qvzqqqrcvypzp5t83el0jefhfwl2xz9py9tqnfurwmwptqnh5lt9w6q0n4006hpcqqrxuat595crqqpd6d9\n[02]: nostr:naddr1qvzqqqrcvypzp5t83el0jefhfwl2xz9py9tqnfurwmwptqnh5lt9w6q0n4006hpcqqrxuat595cry0t0uzl\n[07]: nostr:naddr1qvzqqqrcvypzp5t83el0jefhfwl2xz9py9tqnfurwmwptqnh5lt9w6q0n4006hpcqqrxuat595crwz62n00\n[09]: nostr:naddr1qvzqqqrcvypzp5t83el0jefhfwl2xz9py9tqnfurwmwptqnh5lt9w6q0n4006hpcqqrxuat595crjx9yptm\n[tests]: https://github.com/cashubtc/nuts/blob/a845dfc998abae501fc3419592d53dc995d34b12/tests/13-tests.md\n","sig":"280851b6ffeaa6bb01781f6d1a17fdd2ca78e110d037f6f2d23431e114f035bd2ac53332a62026e9c8e913804a6b6b632940238af944f4a04df1bdeb8eb51421"}