Package Exports
- nsec-tree
- nsec-tree/core
- nsec-tree/encoding
- nsec-tree/mnemonic
- nsec-tree/persona
- nsec-tree/proof
Readme
nsec-tree
Deterministic Nostr sub-identity derivation. One master secret, unlimited identities.
npm install nsec-treeESM-only. Zero custom crypto — all primitives from @noble/@scure.
Why nsec-tree?
NIP-06 standardises mnemonic-based key derivation, but most clients surface one primary key. nsec-tree gives you a purpose-tagged identity tree.
- Unlinkable by default — no observer can prove two child npubs share a master
- Recoverable — 12 words recreate your entire identity tree
- Purpose-tagged — human-readable derivation (
"social","commerce","trott:rider")
Children are ordinary Nostr keypairs. Clients that do not understand linkage proofs will treat them as separate identities.
Quick start
From a mnemonic (greenfield)
import { fromMnemonic, derive } from 'nsec-tree'
const root = fromMnemonic('abandon abandon ... about')
const social = derive(root, 'social')
const commerce = derive(root, 'commerce')
console.log(social.npub) // npub1...
console.log(commerce.npub) // npub1... (different, unlinkable)
root.destroy()From an existing nsec (existing users)
import { fromNsec, derive } from 'nsec-tree/core' // no BIP deps
const root = fromNsec('nsec1...')
const throwaway = derive(root, 'throwaway', 42)Prove ownership (linkage proofs)
import { createBlindProof, verifyProof } from 'nsec-tree/proof'
const proof = createBlindProof(root, child)
// Send proof to verifier...
const valid = verifyProof(proof) // trueAPI
fromMnemonic(mnemonic, passphrase?)
Create a TreeRoot from a BIP-39 mnemonic. Derives the tree root at m/44'/1237'/727'/0'/0'.
fromNsec(nsec)
Create a TreeRoot from a bech32 nsec string or raw 32-byte key. An intermediate HMAC separates the signing key from the derivation key.
derive(root, purpose, index?)
Derive a child Identity from a TreeRoot. Returns { nsec, npub, privateKey, publicKey, purpose, index }. The index defaults to 0.
recover(root, purposes, scanRange?)
Scan multiple purposes and indices, returning Map<string, Identity[]>. Default scan range: 20 (BIP-44 gap limit).
zeroise(identity)
Zero the private key bytes of a derived identity.
createBlindProof(root, child)
BIP-340 Schnorr proof that the master owns a child — without revealing the derivation slot.
createFullProof(root, child)
Like blind proof, but also reveals the purpose and index.
verifyProof(proof)
Verify a LinkageProof. Returns boolean.
Subpath exports
| Import | What | BIP deps? |
|---|---|---|
nsec-tree |
Full API | Yes |
nsec-tree/core |
fromNsec, derive, recover, zeroise | No |
nsec-tree/mnemonic |
fromMnemonic | Yes |
nsec-tree/proof |
Linkage proofs | No |
nsec-tree/persona |
Persona derivation, two-level hierarchy, recovery | No |
Use nsec-tree/core if you only need nsec-based derivation — it avoids pulling in BIP-32/39 dependencies.
How it works
- Tree root from mnemonic (BIP-32 at
m/44'/1237'/727'/0'/0') or nsec (intermediate HMAC) - Child keys:
HMAC-SHA256(tree_root, "nsec-tree\0" || purpose || "\0" || index_be32) - Linkage proofs: BIP-340 Schnorr signatures over attestation strings
- See
PROTOCOL.mdfor the full derivation spec with test vectors
Personas
A persona is a named Nostr identity derived from your master secret using the
convention nostr:persona:{name}. Each persona gets its own keypair — suitable
for a separate kind-0 profile — and is unlinkable to other personas by default.
Deriving personas
import { fromMnemonic } from 'nsec-tree'
import { derivePersona } from 'nsec-tree/persona'
const root = fromMnemonic('abandon abandon ... about')
const personal = derivePersona(root, 'personal')
const bitcoiner = derivePersona(root, 'bitcoiner')
const work = derivePersona(root, 'work')
console.log(personal.identity.npub) // npub1...
console.log(bitcoiner.identity.npub) // npub1... (different, unlinkable)Two-level hierarchy
deriveFromPersona creates sub-identities beneath a persona. This is useful
for group signing keys — each group gets an isolated keypair derived from the
persona, not the master.
import { deriveFromPersona } from 'nsec-tree/persona'
const meetup = deriveFromPersona(bitcoiner, 'canary:group:local-meetup')
const conference = deriveFromPersona(bitcoiner, 'canary:group:btcpp-2026')The hierarchy is: master → persona → group identity. Compromising a group key does not expose the persona key, and compromising a persona does not expose the master.
Recovery
recoverPersonas re-derives all personas from a mnemonic by scanning a list of
known names. When no names are provided it uses DEFAULT_PERSONA_NAMES:
personal, bitcoiner, work, social, anonymous.
import { recoverPersonas, DEFAULT_PERSONA_NAMES } from 'nsec-tree/persona'
const root = fromMnemonic('abandon abandon ... about')
const recovered = recoverPersonas(root, DEFAULT_PERSONA_NAMES, 2)
for (const [name, personas] of recovered) {
console.log(`${name}: ${personas.length} indices scanned`)
}Recovery is deterministic — the same mnemonic always produces the same personas. You only need to know (or conventionalise) the persona names to scan.
Rotation
If a persona is compromised, derive it at a higher index:
const bitcoinerV0 = derivePersona(root, 'bitcoiner', 0) // compromised
const bitcoinerV1 = derivePersona(root, 'bitcoiner', 1) // replacementUse a blind linkage proof to prove continuity — the new persona is controlled by the same master — without revealing which derivation slot was used:
import { createBlindProof, verifyProof } from 'nsec-tree/proof'
const proof = createBlindProof(root, bitcoinerV1.identity)
verifyProof(proof) // true — same master, new identityEcosystem integration
nsec-tree personas are designed to compose with other libraries:
- canary-kit —
deriveFromPersona(persona, 'canary:group:...')produces the group signing key that canary-kit uses for encrypted location beacons, duress alerts, and liveness checks. - spoken-token — bind a spoken verification token to a persona's public key for identity confirmation over voice calls.
Security model
Compromise at different levels has different blast radii:
| Compromised | Impact | Mitigation |
|---|---|---|
| Group key | One group identity exposed | Rotate the group key (new purpose or index) |
| Persona key | All group keys under that persona derivable | Rotate the persona (increment index), issue linkage proof |
| Master secret | All personas and group keys derivable | Rotate the mnemonic, migrate all identities |
The two-level hierarchy ensures that a group key compromise does not escalate to the persona, and a persona compromise does not escalate to the master.
Examples
Runnable examples in the examples/ directory:
| Example | What it shows |
|---|---|
| basic-derivation.ts | Derive social + commerce identities |
| existing-nsec.ts | Use an existing nsec, no mnemonic needed |
| recovery.ts | Recover all identities from a mnemonic |
| linkage-proofs.ts | Blind and full ownership proofs |
| bot-fleet.ts | 10 bots from one seed |
| nostr-event-signing.ts | Sign a kind-1 event with nostr-tools |
| persona.ts | Persona derivation, groups, rotation, recovery |
Run any example: npx tsx examples/<name>.ts
Further reading
- FAQ — common questions and objections
- Comparison — nsec-tree vs NIP-06, NIP-26, linked subkeys
- NIP draft — formal specification in NIP format
- PROTOCOL.md — full derivation spec with test vectors
Security
- Zero custom crypto — HMAC-SHA256 (RFC 2104), BIP-32, BIP-340 Schnorr. All from @noble/@scure.
- Unlinkable by default — selective disclosure only via linkage proofs
- Zeroisation — call
root.destroy()andzeroise(identity)when done.FinalizationRegistryprovides best-effort cleanup if you forget. - Master compromise — if the master secret leaks, all child keys are derivable. Protect it with the same rigour as any nsec.
- See
PROTOCOL.mdfor the full threat model
Licence
MIT
If you find nsec-tree useful, consider sending a tip:
- Lightning:
thedonkey@strike.me - Nostr zaps:
npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2