nsec-tree Derivation Protocol ============================== Deterministic Nostr sub-identity derivation via HMAC-SHA256. `v1.1` `stable` ## Abstract nsec-tree derives deterministic Nostr sub-identities from a single master secret. A tree root is established from either a BIP-39 mnemonic or an existing nsec, then child keys are derived via HMAC-SHA256 using a human-readable purpose string and a numeric index. Each child is a fully independent secp256k1 key pair with its own nsec/npub. Children are unlinkable by default; optional Schnorr linkage proofs allow selective disclosure of the master-child relationship. This document is precise enough for independent reimplementation in any language without reference to the TypeScript source. ## Notation | Symbol | Meaning | |--------|---------| | `HMAC-SHA256(key, msg)` | RFC 2104 HMAC with SHA-256 | | `utf8(s)` | UTF-8 encoding of string `s` | | `uint32_be(n)` | 4-byte unsigned big-endian integer | | `\|\|` | Byte concatenation | | `0x00` | Single null byte | | `secp256k1_x_only(sk)` | BIP-340 x-only public key from private key `sk` | ## 1. Tree Root Derivation Two entry points produce a 32-byte **tree root secret**. The tree root is a valid secp256k1 private key. Its x-only public key (BIP-340) is the **master pubkey** — the identity used in linkage proofs and recovery. The two paths are intentionally distinct: the same underlying key material fed through the mnemonic path and the nsec path produces different tree roots. Users must choose one entry point and use it consistently. ### 1.1 Mnemonic Path ``` BIP-39 mnemonic (with optional passphrase) -> BIP-32 seed -> derive child at m/44'/1237'/727'/0'/0' (all five levels hardened) -> 32-byte private key = tree_root ``` The derivation path `m/44'/1237'/727'/0'/0'` uses: - `44'` — BIP-44 purpose - `1237'` — Nostr coin type (NIP-06) - `727'` — nsec-tree account index (avoids collision with NIP-06's `0'`) - `0'/0'` — hardened change and address indices All levels are hardened because the tree root is a leaf key used only as an HMAC secret and linkage proof signer — never for BIP-32 extended public key derivation. The master pubkey is: ``` master_pubkey = secp256k1_x_only(tree_root) ``` ### 1.2 nsec Path The nsec is never used directly as the HMAC key. An intermediate HMAC creates one-way separation between the signing key and the derivation key, following the HKDF-Extract / BIP-85 pattern: ``` tree_root = HMAC-SHA256(key = nsec_bytes, msg = utf8("nsec-tree-root")) ``` Where: - `nsec_bytes` — the raw 32-byte private key (decoded from bech32 nsec or provided directly) - `"nsec-tree-root"` — the fixed ASCII label (14 bytes: `6e7365632d747265652d726f6f74`) The master pubkey is: ``` master_pubkey = secp256k1_x_only(tree_root) ``` This ensures: - The tree root cannot be reversed to recover the nsec - Compromising a child key does not reveal the nsec (two HMAC layers) - The nsec remains usable as a signing key without dual-purpose risk ## 2. Child Key Derivation All child keys are derived from the tree root via HMAC-SHA256: ``` message = utf8("nsec-tree") || 0x00 || utf8(purpose) || 0x00 || uint32_be(index) child_privkey = HMAC-SHA256(key = tree_root, msg = message) child_pubkey = secp256k1_x_only(child_privkey) ``` ### 2.1 Byte Layout The HMAC message is constructed by concatenating: | Component | Encoding | Description | |-----------|----------|-------------| | Domain prefix | `utf8("nsec-tree")` | 9 bytes: `6e7365632d74726565` | | Separator | `0x00` | 1 byte | | Purpose | `utf8(purpose)` | Variable length | | Separator | `0x00` | 1 byte | | Index | `uint32_be(index)` | 4 bytes, big-endian | ### 2.2 Worked Example Purpose `"social"`, index `0`: ``` 6e7365632d74726565 00 736f6369616c 00 00000000 |________________| | |__________| | |______| nsec-tree \0 social \0 index=0 (big-endian) ``` Total message: 21 bytes (`6e7365632d7472656500736f6369616c0000000000`). Purpose `"commerce"`, index `0`: ``` 6e7365632d74726565 00 636f6d6d65726365 00 00000000 |________________| | |______________| | |______| nsec-tree \0 commerce \0 index=0 (big-endian) ``` Total message: 23 bytes. The null byte separators prevent concatenation ambiguity: purpose `"a"` at index `0` is a different message from purpose `"a\x00"` at any index (which is rejected by validation), and from any other purpose/index combination. ## 3. Purpose String Rules Purpose strings MUST satisfy all of the following: 1. **Non-empty** — minimum 1 byte when UTF-8 encoded 2. **Maximum 255 bytes** — when UTF-8 encoded (not characters) 3. **No embedded null bytes** — a `0x00` within the purpose would create ambiguous parsing of the derivation message 4. **No whitespace-only strings** — at least one non-whitespace character 5. **Case-sensitive, byte-exact** — `"Social"` and `"social"` are different purposes; no normalisation is applied Recommended format: lowercase, colon-separated namespaces (e.g. `"social"`, `"commerce"`, `"trott:rider"`, `"402:api:v2:prod"`). ### 3.1 Reserved Purpose: Personas A **persona** is an application-level convenience over child derivation: a human-named, top-level identity meant to be used directly (posting, signing, messaging) rather than as an intermediate node. A persona named `` is the child identity derived at the purpose string: ``` nostr:persona: ``` with the index and curve-order handling of any other child (§2, §4). For example, persona `social` is purpose `nostr:persona:social`; persona `natural-person` is purpose `nostr:persona:natural-person`. The complete `nostr:persona:` string is the purpose that is hashed, so `` MUST be chosen such that the whole string still satisfies §3. Any implementation that exposes a dedicated "persona" operation MUST construct this exact purpose string — so a persona derived in one tool reproduces byte-for-byte in every other. `nostr:persona:` is the ONLY purpose namespace reserved by this specification. Every other purpose string is raw and application-defined. In particular, the raw purpose `social` and the persona `nostr:persona:social` are **different identities** and MUST NOT be conflated. ## 4. Curve Order Handling HMAC-SHA256 output is 256 bits. The secp256k1 curve order `n` is: ``` n = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 ``` This is slightly less than 2^256. If the HMAC output, interpreted as a 256-bit unsigned integer, is greater than or equal to `n`, it is not a valid secp256k1 private key. **Behaviour:** If the output at the requested index is invalid, the implementation MUST increment the index and retry. The returned index reflects the **actual index used**, not the originally requested index. ``` current_index = requested_index while current_index <= 0xFFFFFFFF: msg = utf8("nsec-tree") || 0x00 || utf8(purpose) || 0x00 || uint32_be(current_index) candidate = HMAC-SHA256(key = tree_root, msg = msg) if candidate < n: return (candidate, current_index) current_index += 1 error("index overflow") ``` **Overflow:** If incrementing would exceed 2^32 - 1 (the maximum uint32 index), the derivation MUST fail with an error. The probability of any single index being invalid is approximately 3.7 x 10^-39, making this scenario effectively impossible. ## 5. Linkage Proofs Linkage proofs allow the tree root owner to prove that a child identity belongs to them. Two proof types exist: ### 5.1 Blind Attestation Proves ownership without revealing the derivation slot (purpose/index): ``` attestation = "nsec-tree:own|" || hex(master_xonly_pub) || "|" || hex(child_xonly_pub) ``` ### 5.2 Full Attestation Proves ownership and reveals the derivation slot: ``` attestation = "nsec-tree:link|" || hex(master_xonly_pub) || "|" || hex(child_xonly_pub) || "|" || purpose || "|" || decimal(index) ``` ### 5.3 Encoding Rules - `hex(master_xonly_pub)` — lowercase hexadecimal x-only public key, 64 characters - `hex(child_xonly_pub)` — lowercase hexadecimal x-only public key, 64 characters - `purpose` — the raw purpose string (UTF-8), as used in derivation - `decimal(index)` — the index as decimal ASCII, no leading zeroes (e.g. `"0"`, `"42"`, `"1000"`) - The complete attestation string is UTF-8 encoded to bytes ### 5.4 Signing and Verification The attestation is signed with BIP-340 Schnorr using the tree root secret (the master identity's private key): ``` signature = schnorr_sign(utf8(attestation), tree_root) ``` Verification: ``` expected_attestation = canonical_attestation_from_fields(proof) if expected_attestation != proof.attestation: return false valid = schnorr_verify(signature, utf8(attestation), master_xonly_pub) ``` Where `master_xonly_pub` is the 32-byte x-only public key of the tree root. ### 5.5 Proof Structure A serialised linkage proof contains: | Field | Type | Description | |-------|------|-------------| | `masterPubkey` | string | Lowercase hex x-only pubkey (64 chars) | | `childPubkey` | string | Lowercase hex x-only pubkey (64 chars) | | `purpose` | string? | Present in full proofs, absent in blind proofs | | `index` | number? | Present in full proofs, absent in blind proofs | | `attestation` | string | The signed message (UTF-8) | | `signature` | string | BIP-340 Schnorr signature (lowercase hex, 128 chars) | Proofs are serialised as JSON for exchange between parties. Verifiers MUST treat the attestation string as canonical. The duplicated JSON fields are for convenience only and MUST exactly match the canonical attestation; otherwise the proof is invalid. ## 6. Test Vectors All vectors are frozen. Any conformant nsec-tree implementation MUST produce identical outputs for these inputs. ### 6.1 Vector 1 — nsec root, purpose "social", index 0 **Input:** ``` nsec_bytes: 0101010101010101010101010101010101010101010101010101010101010101 purpose: "social" index: 0 ``` **Tree root derivation** (nsec path): ``` tree_root = HMAC-SHA256(key = nsec_bytes, msg = utf8("nsec-tree-root")) = 8d2db9ce9548534e7ae924d05e311355e3a12744214c88e65b39fa2bf2df6d6f master_pub = secp256k1_x_only(tree_root) = 8c03e047ae60c01e942a8337e71d17e3517fcc63ee6ceff8173bbd23fabe649d master_npub = npub13sp7q3awvrqpa9p2svm7w8ghudghlnrraekwl7qh8w7j8747vjwskvzy2u ``` **Child derivation:** ``` message = 6e7365632d7472656500736f6369616c0000000000 (21 bytes) child_priv = HMAC-SHA256(key = tree_root, msg = message) = 98e98b476eab3c2bcb5020e4a679a41b74eebfb30a07944c4361c906501265e7 child_pub = secp256k1_x_only(child_priv) = cdc4cd2a01ba1b8afd3299b66c38d13043a19acb687c334f0527cffaf464b372 child_nsec = nsec1nr5ck3mw4v7zhj6syrj2v7dyrd6wa0anpgregnzrv8ysv5qjvhnsafv7mx child_npub = npub1ehzv62sphgdc4lfjnxmxcwx3xpp6rxktdp7rxnc9yl8l4arykdeqyfhrxy actual_index = 0 ``` ### 6.2 Vector 2 — nsec root, purpose "commerce", index 0 **Input:** ``` nsec_bytes: 0101010101010101010101010101010101010101010101010101010101010101 purpose: "commerce" index: 0 ``` **Tree root:** Same as Vector 1 (same nsec). **Child derivation:** ``` message = 6e7365632d7472656500636f6d6d657263650000000000 (23 bytes) child_priv = fc62a2ec7f91970c485f9d7453268d1a6a07273ee829cf44c87685f78758f04f child_pub = 8441f7e2a73fea0742ccd12858bd5b95ccae385fbcb2856b7d7177880198a663 child_nsec = nsec1l3329mrljxtscjzln469xf5drf4qwfe7aq5u73xgw6zl0p6c7p8sd6vumk actual_index = 0 ``` ### 6.3 Vector 3 — nsec root, purpose "social", index 1 **Input:** ``` nsec_bytes: 0101010101010101010101010101010101010101010101010101010101010101 purpose: "social" index: 1 ``` **Tree root:** Same as Vector 1 (same nsec). **Child derivation:** ``` message = 6e7365632d7472656500736f6369616c0000000001 (21 bytes) child_priv = 802a2fd31d25517bd2bb9b7196c377e6cc2f32728b916c2c3ea71ca703767917 child_pub = aed0bc4ccccdb868156e38cabf3a6acb98f8fa8a4abe0dcc68851d8468a87cd1 child_nsec = nsec1sq4zl5cay4ghh54mndcedsmhumxz7vnj3wgkctp75uw2wqmk0yts3ny5vz actual_index = 1 ``` ### 6.4 Vector 4 — mnemonic root, purpose "social", index 0 **Input:** ``` mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" passphrase: (none) purpose: "social" index: 0 ``` **Tree root derivation** (mnemonic path): ``` BIP-32 path = m/44'/1237'/727'/0'/0' tree_root = cc92d213b5eccd19eb85c12c2cf6fd168f27c2cc347c51a7c4c62ac67795fc65 master_pub = 3eb14b67cc942c5388e03570b68d0887d40ff34af234662344e6c72a6298d656 master_npub = npub186c5ke7vjsk98z8qx4ctdrggsl2qlu627g6xvg6yumrj5c5c6etqcfaclx ``` **Child derivation:** ``` child_priv = f0e7c85f394df83212e108e60a7e226045742aa6d967ea1cfddf27ae65ac6ac8 child_pub = 1a4e31045ee7be1fc736954ffe7ea48fffc784865452a79545a027d0e712fc97 child_nsec = nsec17rnusheefhuryyhpprnq5l3zvpzhg24xm9n7588amun6uedvdtyqnpcsm4 actual_index = 0 ``` ### 6.5 Vector 5 — path independence The same BIP-39 mnemonic produces a **different** tree root when used via the mnemonic path vs. the nsec path. **Mnemonic path** (same as Vector 4): ``` mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" tree_root = cc92d213b5eccd19eb85c12c2cf6fd168f27c2cc347c51a7c4c62ac67795fc65 master_pub = 3eb14b67cc942c5388e03570b68d0887d40ff34af234662344e6c72a6298d656 master_npub = npub186c5ke7vjsk98z8qx4ctdrggsl2qlu627g6xvg6yumrj5c5c6etqcfaclx ``` **nsec path** (NIP-06 key from the same mnemonic, derived at `m/44'/1237'/0'/0/0`): ``` nsec_bytes = 5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731 tree_root = 3ac534dcff9286225e0a254aade75a991a1f41fcbe719cc7dd899dd833b6e4d6 master_pub = 4e444e24184d8b303bbbc6a7a4b97b8906ab8e475e2864bd71043d45819612ae master_npub = npub1fezyufqcfk9nqwamc6n6fwtm3yr2hrj8tc5xf0t3qs75tqvkz2hq40tnpd ``` The two master pubkeys differ, confirming that mnemonic-path and nsec-path derivations are independent. ### 6.6 Vector 6 — mnemonic root, persona "social" (purpose "nostr:persona:social"), index 0 Pins the reserved persona namespace (§3.1): a persona is nothing more than a child derived at `nostr:persona:`, so this vector also confirms the `derivePersona` convenience reduces to the child algorithm of §2. **Input:** ``` mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" passphrase: (none) persona: "social" purpose: "nostr:persona:social" index: 0 ``` **Tree root:** Same as Vector 4 (same mnemonic, mnemonic path): ``` tree_root = cc92d213b5eccd19eb85c12c2cf6fd168f27c2cc347c51a7c4c62ac67795fc65 master_npub = npub186c5ke7vjsk98z8qx4ctdrggsl2qlu627g6xvg6yumrj5c5c6etqcfaclx ``` **Child derivation:** ``` child_priv = 6fd98243f6cd0e64eaf1ff8f73ca4a45fcda3ed090f324258325ded7884285bc child_pub = 0344b499051575638115f3fd3f57c428d7eb7e292d2bf971419e730de2240a54 child_nsec = nsec1dlvcyslke58xf6h3l78h8jj2gh7d50ksjrejgfvryh0d0zzzsk7q3j90f2 child_npub = npub1qdztfxg9z46k8qg4707n747y9rt7kl3f954lju2pneesmc3ypf2q83gm0e actual_index = 0 ``` ## 7. Security Considerations ### One-way derivation Child keys cannot be reversed to recover the tree root or the master secret. The tree root cannot be reversed to recover the nsec (nsec path) or mnemonic (mnemonic path). Each layer of derivation is a one-way HMAC-SHA256 operation. ### Unlinkable by default No observer can prove that two child npubs share a master without a linkage proof. The derivation is entirely private — purpose strings, indices, and the tree root are HMAC inputs, never exposed in outputs. ### Zeroisation responsibility Implementations MUST provide a mechanism to zero secret material when it is no longer needed: - Tree root secret — zeroed on explicit destroy or when the root goes out of scope (best-effort in garbage-collected languages) - BIP-39 seed and BIP-32 intermediate keys — zeroed immediately after tree root extraction - Child private keys — zeroed via explicit function call String encodings (bech32 nsec/npub) cannot be zeroed in garbage-collected languages. Security-sensitive consumers should use raw byte arrays and avoid retaining string encodings. ### Relay correlation caveat If two child npubs post similar content at similar times from similar IP addresses, metadata correlation may reveal they share an operator. This is an operational security concern, not a cryptographic one. nsec-tree provides cryptographic unlinkability; network-level unlinkability requires additional measures (Tor, timing delays, distinct relay sets). ### Master compromise If the master secret (mnemonic or nsec) is compromised, all child keys are derivable. There is no forward secrecy — the tree is fully deterministic. Protect the master secret with the same rigour as any Nostr nsec. ### No custom cryptography All cryptographic primitives are standard: HMAC-SHA256 (RFC 2104), BIP-32 key derivation, BIP-340 Schnorr signatures. The only novel element is the derivation context format (the byte string structure), which is deterministic and fully specified above. ## Reference Implementation The canonical implementation is the `nsec-tree` npm package: ``` npm install nsec-tree ``` Source: https://github.com/forgesworn/nsec-tree ## Versioning This document is versioned as `v1.1`. The version covers: - Tree root derivation (mnemonic path and nsec path) - Child key derivation (HMAC message format) - Purpose string validation rules - Reserved purpose namespaces (personas, §3.1) - Linkage proof attestation formats - Test vectors Changes to any of the above require a version bump. Additions (new proof types, new entry points) that do not alter existing derivation outputs MAY be added without a version bump. `v1.1` added the reserved `nostr:persona:` namespace (§3.1) and Vector 6. This is purely additive — it names an existing raw-purpose derivation and changes no prior output — so any `v1.0` implementation remains conformant.