nsec-tree Derivation Protocol
June 22, 2026 · View on GitHub
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 purpose1237'— Nostr coin type (NIP-06)727'— nsec-tree account index (avoids collision with NIP-06's0')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:
- Non-empty — minimum 1 byte when UTF-8 encoded
- Maximum 255 bytes — when UTF-8 encoded (not characters)
- No embedded null bytes — a
0x00within the purpose would create ambiguous parsing of the derivation message - No whitespace-only strings — at least one non-whitespace character
- 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 <name> is the
child identity derived at the purpose string:
nostr:persona:<name>
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:<name> string is the purpose that is hashed, so
<name> 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 . 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 - 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 , 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 charactershex(child_xonly_pub)— lowercase hexadecimal x-only public key, 64 characterspurpose— the raw purpose string (UTF-8), as used in derivationdecimal(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:<name>, 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.