nsec-tree Architecture
April 7, 2026 · View on GitHub
This component is part of the ForgeSworn Identity Stack. See the ecosystem overview for how it connects to the other components.
nsec-tree is a deterministic Nostr key derivation library. One master secret generates unlimited child identities using HMAC-SHA256. Unlinkable by default, with optional BIP-340 Schnorr linkage proofs for selective disclosure. No relay changes, no client changes -- derived keys are standard Nostr keypairs.
Derivation hierarchy
Two independent paths create the tree root. From there, HMAC-SHA256 derivation produces arbitrarily deep hierarchies.
graph TB
subgraph "Root creation"
MNEMONIC["BIP-39 Mnemonic"] -->|"BIP-32: m/44'/1237'/727'/0'/0'"| ROOT
NSEC["Existing nsec"] -->|"HMAC-SHA256(nsec, 'nsec-tree-root')"| ROOT
end
ROOT["Tree Root (32 bytes)"]
ROOT -->|"derive(root, 'nostr:persona:personal')"| P1["Persona: personal"]
ROOT -->|"derive(root, 'nostr:persona:work')"| P2["Persona: work"]
ROOT -->|"derive(root, 'nostr:persona:bitcoiner')"| P3["Persona: bitcoiner"]
P1 -->|"deriveFromPersona (new sub-root)"| G1["Group: family-chat"]
P1 -->|"deriveFromPersona (new sub-root)"| G2["Group: close-friends"]
P2 -->|"deriveFromPersona (new sub-root)"| G3["Group: company:acme"]
G3 -->|"deriveFromIdentity (new sub-root)"| L1["Leaf: payroll"]
style MNEMONIC fill:#1e293b,color:#e2e8f0
style NSEC fill:#1e293b,color:#e2e8f0
style ROOT fill:#ef4444,color:#fff
style P1 fill:#f59e0b,color:#000
style P2 fill:#f59e0b,color:#000
style P3 fill:#f59e0b,color:#000
style G1 fill:#3b82f6,color:#fff
style G2 fill:#3b82f6,color:#fff
style G3 fill:#3b82f6,color:#fff
style L1 fill:#3b82f6,color:#fff
Personas derive directly from the master root. Groups and deeper nodes derive from a new sub-root created from the parent's private key via fromNsec(). This re-keying step is the core isolation property: compromising a persona key lets an attacker derive its groups, but the groups cannot be derived from the master root with a longer purpose string.
Each derivation step computes HMAC-SHA256(parent_secret, context) where context is:
"nsec-tree\0" || purpose_bytes || "\0" || index_u32_be
If the result exceeds the secp256k1 curve order (probability ~3.7 x ), the index auto-increments and retries. The caller receives the actual index used.
Linkage proofs
Prove two pubkeys share a master without revealing the master or derivation path.
sequenceDiagram
box rgb(249, 158, 11) Prover
participant O as Key Owner
end
box rgb(59, 130, 246) Consumer
participant V as Verifier
end
Note over O: Holds master + child private keys
O->>O: Create attestation string
Note over O: Blind: "nsec-tree:own|master_pub|child_pub"
Note over O: Full: "nsec-tree:link|master_pub|child_pub|purpose|index"
O->>O: BIP-340 Schnorr sign with master key
O->>V: Publish as NIP-78 event (kind 30078)
Note over V: Tags: d, p, attestation, proof-sig
V->>V: Extract attestation + signature
V->>V: Verify Schnorr signature against master pubkey
alt Valid
Note over V: Child belongs to master
else Invalid
Note over V: Proof rejected
end
Blind proofs hide the derivation path (purpose and index). Use for: persona rotation, proving ownership without revealing structure.
Full proofs reveal the derivation path. Use for: accountability, audit trails, transparent identity hierarchies.
Module structure
Tree-shakeable entry points for minimal bundle size:
| Import path | Contents | BIP-32 dependency? |
|---|---|---|
nsec-tree | Full API | Yes |
nsec-tree/core | fromNsec, derive, recover, zeroise | No |
nsec-tree/mnemonic | fromMnemonic only | Yes |
nsec-tree/proof | createBlindProof, createFullProof, verifyProof | No |
nsec-tree/persona | derivePersona, deriveFromPersona, recoverPersonas | No |
nsec-tree/event | toUnsignedEvent, fromEvent | No |
nsec-tree/encoding | NIP-19 bech32 helpers | No |
Security model
Why HMAC-SHA256 instead of BIP-32? In BIP-32, an extended public key plus any single child private key reveals the parent private key. In Nostr, child keys sign public events (observable). HMAC-SHA256 is strictly one-way: compromising a child reveals nothing about the root or siblings.
Unlinkable by default. Two child npubs are cryptographically indistinguishable from independent keys. No observer can prove they share a master without a linkage proof. Metadata correlation (timing, IP, content) remains an operational security concern.
Compromise blast radius:
| Compromised | Affected | Unaffected | Recovery |
|---|---|---|---|
| Group key | That group only | Persona, master, siblings | Rotate to new index |
| Persona key | Persona + all its groups | Master, other personas | New persona index + blind proof |
| Master secret | Everything | Nothing | New mnemonic, migrate all |
Zeroisation. Tree roots use WeakMap + FinalizationRegistry for automatic secret cleanup. destroy() zeroes the secret immediately. zeroise(identity) overwrites the private key bytes. The bech32 nsec string is an immutable JS string and cannot be overwritten -- store privateKey bytes, not nsec, for signing.
Cryptographic primitives
All from audited libraries by Paul Miller (no custom cryptography):
- HMAC-SHA256: @noble/hashes
- BIP-32 / BIP-39: @scure/bip32, @scure/bip39
- BIP-340 Schnorr: @noble/curves/secp256k1
- Bech32: @scure/base
Integration points
- Heartwood: heartwood-core re-implements nsec-tree's derivation in Rust. Frozen test vectors ensure byte-level compatibility between the TypeScript and Rust implementations.
- Bark: Bark's persona UI maps to nsec-tree's derivation model. Persona switching, derivation, and identity listing are Heartwood RPC calls that use nsec-tree under the hood.
- Sapwood: Uses the TypeScript nsec-tree library for provisioning (computing derived roots in-browser before sending to the device).
- canary-kit: Uses nsec-tree group keys for duress-resistant verification ceremonies.
- ForgeSworn Identity Stack: nsec-tree is the cryptographic foundation of the signing stack.