# nsec-tree nsec-tree is a TypeScript library for deterministic Nostr identity hierarchies, allowing users to derive unlimited sub-identities from a single master secret (BIP-39 mnemonic or existing nsec). It uses HMAC-SHA256 for child key derivation with human-readable purpose strings, producing standard secp256k1 keypairs that work with any Nostr client. All child identities are unlinkable by default—no observer can prove two npubs share a master without an explicit linkage proof. The library supports arbitrary-depth hierarchies (e.g., `work → company:a → payroll → hot-wallet`), making it suitable for modeling real organizational structures. It provides BIP-340 Schnorr linkage proofs for selective ownership disclosure, persona management for maintaining separate Nostr profiles, key rotation with continuity proofs, and NIP-78 event conversion for publishing proofs to relays. nsec-tree uses zero custom cryptography—all primitives come from @noble/@scure libraries. ## Installation ```bash npm install nsec-tree ``` ## fromMnemonic - Create TreeRoot from BIP-39 Mnemonic Creates a TreeRoot from a BIP-39 mnemonic phrase, deriving the tree root at path `m/44'/1237'/727'/0'/0'`. This is the recommended entry point for new users who want full recoverability from 12 words. ```typescript import { fromMnemonic, derive } from 'nsec-tree' const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' // Create tree root from mnemonic (optional passphrase as second argument) const root = fromMnemonic(mnemonic) // Derive purpose-tagged identities const social = derive(root, 'social') const commerce = derive(root, 'commerce') console.log(social.npub) // npub1rf... console.log(commerce.npub) // npub1gsp... (different, unlinkable) // Deterministic: same mnemonic always produces the same keys const root2 = fromMnemonic(mnemonic) const social2 = derive(root2, 'social') console.log(social.npub === social2.npub) // true // Clean up secrets when done root.destroy() root2.destroy() ``` ## fromNsec - Create TreeRoot from Existing nsec Creates a TreeRoot from an existing bech32 nsec string or raw 32-byte key. Uses an intermediate HMAC (`HMAC-SHA256(nsec, "nsec-tree-root")`) to separate the signing key from the derivation key. Import from `nsec-tree/core` to avoid BIP-32/39 dependencies. ```typescript import { fromNsec, derive, zeroise } from 'nsec-tree/core' // Use an existing nsec — no mnemonic migration needed const root = fromNsec('nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw') // Derive child identities const social = derive(root, 'social') const throwaway = derive(root, 'throwaway', 42) // Custom index console.log(social.npub) // npub1... console.log(throwaway.npub) // npub1... (index 42) console.log(throwaway.index) // 42 // Also accepts raw 32-byte Uint8Array const rawKey = new Uint8Array(32).fill(1) const rootFromBytes = fromNsec(rawKey) // Clean up root.destroy() rootFromBytes.destroy() ``` ## derive - Derive Child Identity from TreeRoot Derives a child Identity from a TreeRoot using a purpose string and optional index. Returns an object containing `nsec`, `npub`, `privateKey`, `publicKey`, `purpose`, and `index`. Purpose strings are case-sensitive and support colon-separated namespaces. ```typescript import { fromMnemonic, derive, zeroise } from 'nsec-tree' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') // Basic derivation (index defaults to 0) const social = derive(root, 'social') // Access all identity properties console.log({ nsec: social.nsec, // nsec1... (bech32 private key) npub: social.npub, // npub1... (bech32 public key) privateKey: social.privateKey, // Uint8Array (32 bytes) publicKey: social.publicKey, // Uint8Array (32 bytes, x-only) purpose: social.purpose, // 'social' index: social.index, // 0 }) // Derive with explicit index (useful for rotation) const socialV1 = derive(root, 'social', 1) console.log(social.npub !== socialV1.npub) // true // Purpose strings support namespaced conventions const apiKey = derive(root, '402:api:v2:prod') const riderKey = derive(root, 'trott:rider') // Zero the private key when done (nsec string cannot be zeroed) zeroise(social) console.log(social.privateKey.every(b => b === 0)) // true root.destroy() ``` ## deriveFromIdentity - Build Arbitrary-Depth Hierarchies Derives a child Identity from any existing Identity, enabling arbitrary-depth key hierarchies. Internally creates a transient TreeRoot from the parent identity's private key, derives the child, and destroys the transient root. ```typescript import { fromMnemonic, deriveFromIdentity } from 'nsec-tree' import { derivePersona } from 'nsec-tree/persona' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') // First level: persona const work = derivePersona(root, 'work') // Second level: companies under work persona const companyA = deriveFromIdentity(work.identity, 'company:a') const companyB = deriveFromIdentity(work.identity, 'company:b') // Third level: departments under companies const payroll = deriveFromIdentity(companyA, 'payroll') const ops = deriveFromIdentity(companyB, 'ops') // Fourth level: specific keys under departments const hotWallet = deriveFromIdentity(payroll, 'hot-wallet') const emergency = deriveFromIdentity(ops, 'emergency') console.log('Hierarchy:') console.log(` work: ${work.identity.npub}`) console.log(` work → company:a: ${companyA.npub}`) console.log(` work → company:a → payroll: ${payroll.npub}`) console.log(` work → company:a → payroll → hot-wallet: ${hotWallet.npub}`) console.log(` work → company:b: ${companyB.npub}`) console.log(` work → company:b → ops: ${ops.npub}`) // Each branch is isolated: compromising companyA doesn't expose companyB root.destroy() ``` ## derivePersona - Named Identity Derivation Derives a named persona from a TreeRoot using the convention `nostr:persona:{name}`. Each persona is suitable for a separate kind-0 Nostr profile and is unlinkable to other personas by default. Supports rotation via the index parameter. ```typescript import { fromMnemonic } from 'nsec-tree' import { derivePersona, deriveFromPersona, DEFAULT_PERSONA_NAMES } from 'nsec-tree/persona' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') // Derive multiple personas (each gets its own Nostr profile) const personal = derivePersona(root, 'personal') const bitcoiner = derivePersona(root, 'bitcoiner') const work = derivePersona(root, 'work') console.log('Personas:') console.log(` personal: ${personal.identity.npub}`) console.log(` bitcoiner: ${bitcoiner.identity.npub}`) console.log(` work: ${work.identity.npub}`) // Access persona properties console.log(personal.name) // 'personal' console.log(personal.index) // 0 // Derive sub-identities within a persona (two-level hierarchy) const familyGroup = deriveFromPersona(personal, 'canary:group:family-2026') const meetupGroup = deriveFromPersona(bitcoiner, 'canary:group:local-meetup') console.log('\nGroup identities:') console.log(` personal → family: ${familyGroup.npub}`) console.log(` bitcoiner → meetup: ${meetupGroup.npub}`) // Persona rotation (if compromised, derive at higher index) const bitcoinerCompromised = derivePersona(root, 'bitcoiner', 0) const bitcoinerRotated = derivePersona(root, 'bitcoiner', 1) console.log('\nRotation:') console.log(` bitcoiner[0]: ${bitcoinerCompromised.identity.npub}`) console.log(` bitcoiner[1]: ${bitcoinerRotated.identity.npub}`) // Default persona names for recovery conventions console.log('\nDefault personas:', DEFAULT_PERSONA_NAMES) // ['personal', 'bitcoiner', 'work', 'social', 'anonymous'] root.destroy() ``` ## recover - Scan Multiple Purpose Strings Scans multiple purpose strings across a range of indices, returning a Map of all derived identities. Useful for reconstructing identity sets from a mnemonic when the exact derivation indices are unknown. ```typescript import { fromMnemonic, derive, recover } from 'nsec-tree' const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' // Create some identities const root = fromMnemonic(mnemonic) const social = derive(root, 'social') const commerce = derive(root, 'commerce') // Simulate loss — destroy root root.destroy() // Recover: same mnemonic, scan known purposes const recovered = fromMnemonic(mnemonic) const found = recover(recovered, ['social', 'commerce', 'bot'], 20) // scanRange = 20 (default) // found is Map console.log('Recovery results:') for (const [purpose, identities] of found) { console.log(` ${purpose}: ${identities.length} indices scanned`) console.log(` index 0: ${identities[0]!.npub}`) } // Verify recovery matches originals const recoveredSocial = found.get('social')![0]! console.log(`\nRecovered social matches? ${social.npub === recoveredSocial.npub}`) // true recovered.destroy() ``` ## recoverPersonas - Recover Named Personas Re-derives all personas from a mnemonic by scanning a list of known names. Uses DEFAULT_PERSONA_NAMES when no names provided. Returns a Map of persona name to array of Persona objects. ```typescript import { fromMnemonic } from 'nsec-tree' import { recoverPersonas, DEFAULT_PERSONA_NAMES, derivePersona } from 'nsec-tree/persona' const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' // Create some personas const root = fromMnemonic(mnemonic) const bitcoiner = derivePersona(root, 'bitcoiner') const bitcoinerRotated = derivePersona(root, 'bitcoiner', 1) // Recovery: scan default persona names at multiple indices const recovered = recoverPersonas(root, DEFAULT_PERSONA_NAMES, 5) // scan indices 0-4 console.log('Recovered personas:') for (const [name, personas] of recovered) { console.log(` ${name}:`) for (const persona of personas) { console.log(` [${persona.index}] ${persona.identity.npub.slice(0, 24)}...`) } } // Verify recovery const recoveredBitcoiners = recovered.get('bitcoiner')! console.log(`\nbitcoiner[0] matches? ${bitcoiner.identity.npub === recoveredBitcoiners[0]!.identity.npub}`) console.log(`bitcoiner[1] matches? ${bitcoinerRotated.identity.npub === recoveredBitcoiners[1]!.identity.npub}`) // Custom persona names for recovery const customRecovered = recoverPersonas(root, ['dev', 'staging', 'production'], 3) root.destroy() ``` ## createBlindProof - Ownership Proof Without Derivation Details Creates a BIP-340 Schnorr proof that the master owns a child identity without revealing which derivation slot (purpose/index) was used. Useful for proving identity continuity after rotation without exposing naming conventions. ```typescript import { fromMnemonic, derive } from 'nsec-tree' import { createBlindProof, verifyProof } from 'nsec-tree/proof' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') const social = derive(root, 'social') // Create blind proof — hides purpose and index const proof = createBlindProof(root, social) console.log('Blind proof:') console.log(` masterPubkey: ${proof.masterPubkey}`) // 64-char hex console.log(` childPubkey: ${proof.childPubkey}`) // 64-char hex console.log(` purpose: ${proof.purpose ?? '(hidden)'}`) // undefined console.log(` index: ${proof.index ?? '(hidden)'}`) // undefined console.log(` attestation: ${proof.attestation}`) // "nsec-tree:own||" console.log(` signature: ${proof.signature.slice(0, 32)}...`) // 128-char hex // Verify the proof (anyone can verify without secrets) const valid = verifyProof(proof) console.log(` valid: ${valid}`) // true // Use case: prove rotated identity is same master const socialRotated = derive(root, 'social', 1) const rotationProof = createBlindProof(root, socialRotated) console.log(`\nRotation proof valid? ${verifyProof(rotationProof)}`) // true root.destroy() ``` ## createFullProof - Ownership Proof with Derivation Details Creates a BIP-340 Schnorr proof that reveals both ownership and the derivation slot (purpose and index). Useful when you want to prove exactly which derivation path was used. ```typescript import { fromMnemonic, derive } from 'nsec-tree' import { createFullProof, verifyProof } from 'nsec-tree/proof' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') const social = derive(root, 'social') // Create full proof — reveals purpose and index const proof = createFullProof(root, social) console.log('Full proof:') console.log(` masterPubkey: ${proof.masterPubkey}`) console.log(` childPubkey: ${proof.childPubkey}`) console.log(` purpose: ${proof.purpose}`) // 'social' console.log(` index: ${proof.index}`) // 0 console.log(` attestation: ${proof.attestation}`) // "nsec-tree:link|||social|0" console.log(` signature: ${proof.signature.slice(0, 32)}...`) // Verify console.log(` valid: ${verifyProof(proof)}`) // true // Tampered proof fails verification const tampered = { ...proof, childPubkey: '00'.repeat(32) } console.log(`\nTampered proof valid? ${verifyProof(tampered)}`) // false root.destroy() ``` ## toUnsignedEvent - Convert Proof to NIP-78 Nostr Event Converts a LinkageProof to an unsigned NIP-78 Kind 30078 Nostr event for publishing to relays. The application signs and publishes the event using their own Nostr library. ```typescript import { fromMnemonic, derive } from 'nsec-tree' import { createBlindProof, createFullProof } from 'nsec-tree/proof' import { toUnsignedEvent, NSEC_TREE_EVENT_KIND, NSEC_TREE_D_PREFIX } from 'nsec-tree/event' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') const social = derive(root, 'social') // Create proofs const blindProof = createBlindProof(root, social) const fullProof = createFullProof(root, social) // Convert blind proof to unsigned event const blindEvent = toUnsignedEvent(blindProof) console.log('Unsigned NIP-78 event (blind proof):') console.log(` kind: ${blindEvent.kind}`) // 30078 console.log(` pubkey: ${blindEvent.pubkey}`) // master pubkey (hex) console.log(` created_at: ${blindEvent.created_at}`) console.log(` content: "${blindEvent.content}"`) // empty string console.log(` tags:`) for (const tag of blindEvent.tags) { console.log(` ${JSON.stringify(tag)}`) } // ["d", "nsec-tree:"] // ["p", ""] // ["attestation", "nsec-tree:own|..."] // ["proof-sig", ""] // Full proof includes purpose and index tags const fullEvent = toUnsignedEvent(fullProof) console.log('\nFull proof tags:') for (const tag of fullEvent.tags) { console.log(` ${JSON.stringify(tag)}`) } // Additional tags: ["purpose", "social"], ["index", "0"] // Sign and publish with nostr-tools or any Nostr library // import { finalizeEvent } from 'nostr-tools/pure' // const signed = finalizeEvent(blindEvent, root.privateKey) // await relay.publish(signed) console.log(`\nEvent kind constant: ${NSEC_TREE_EVENT_KIND}`) // 30078 console.log(`D-tag prefix: ${NSEC_TREE_D_PREFIX}`) // "nsec-tree:" root.destroy() ``` ## fromEvent - Extract Proof from NIP-78 Event Extracts a LinkageProof from a NIP-78 event's tags. Pass the result to verifyProof() to check cryptographic validity. Throws NsecTreeError if the event doesn't contain a valid nsec-tree proof. ```typescript import { fromMnemonic, derive } from 'nsec-tree' import { createFullProof, verifyProof } from 'nsec-tree/proof' import { toUnsignedEvent, fromEvent } from 'nsec-tree/event' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') const social = derive(root, 'social') // Create and convert proof to event const originalProof = createFullProof(root, social) const event = toUnsignedEvent(originalProof) // Simulate receiving event from relay — extract proof from tags const extractedProof = fromEvent(event) console.log('Extracted proof from event:') console.log(` masterPubkey: ${extractedProof.masterPubkey}`) console.log(` childPubkey: ${extractedProof.childPubkey}`) console.log(` purpose: ${extractedProof.purpose}`) console.log(` index: ${extractedProof.index}`) console.log(` attestation: ${extractedProof.attestation}`) // Verify the extracted proof const valid = verifyProof(extractedProof) console.log(`\n Valid? ${valid}`) // true // Extracted proof matches original console.log(` Matches original? ${extractedProof.masterPubkey === originalProof.masterPubkey}`) // Error handling for invalid events try { fromEvent({ pubkey: 'invalid', tags: [] }) } catch (err) { console.log(`\nError: ${err.message}`) // "Missing or invalid d tag: expected nsec-tree: prefix" } root.destroy() ``` ## NIP-19 Encoding Utilities The encoding subpath provides helpers for converting between raw bytes and NIP-19 bech32 format (nsec/npub). These are also used internally by the library. ```typescript import { encodeNsec, decodeNsec, encodeNpub, decodeNpub } from 'nsec-tree/encoding' // Encode raw bytes to bech32 const privateKey = new Uint8Array(32).fill(1) const nsec = encodeNsec(privateKey) console.log(`nsec: ${nsec}`) // nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw // Decode bech32 to raw bytes const decoded = decodeNsec(nsec) console.log(`Decoded length: ${decoded.length}`) // 32 console.log(`Matches original: ${privateKey.every((b, i) => b === decoded[i])}`) // true // Same for public keys const publicKey = new Uint8Array(32).fill(2) const npub = encodeNpub(publicKey) console.log(`npub: ${npub}`) const decodedPub = decodeNpub(npub) console.log(`Decoded pubkey matches: ${publicKey.every((b, i) => b === decodedPub[i])}`) // Error handling try { decodeNsec('npub1...') // Wrong prefix } catch (err) { console.log(`Error: ${err.message}`) // 'Expected prefix "nsec", got "npub"' } ``` ## Integration with nostr-tools nsec-tree identities work directly with nostr-tools for signing Nostr events. The `privateKey` property is a Uint8Array that nostr-tools accepts directly. ```typescript import { fromMnemonic, derive } from 'nsec-tree' import { finalizeEvent, verifyEvent } from 'nostr-tools/pure' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') const social = derive(root, 'social') console.log(`Signing identity: ${social.npub}`) // finalizeEvent accepts Uint8Array — identity.privateKey works directly const event = finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: 'Hello from a derived nsec-tree identity!', }, social.privateKey) console.log('Signed event:') console.log(` id: ${event.id}`) console.log(` pubkey: ${event.pubkey}`) console.log(` kind: ${event.kind}`) console.log(` content: ${event.content}`) console.log(` sig: ${event.sig.slice(0, 32)}...`) // Verify — standard Nostr verification const valid = verifyEvent(event) console.log(`\nValid Nostr event? ${valid}`) // true root.destroy() ``` ## Bot Fleet Pattern Derive multiple identities at sequential indices for bot fleets, automated services, or any scenario requiring many unlinkable identities from one seed. ```typescript import { fromMnemonic, derive, recover } from 'nsec-tree' const root = fromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') console.log('Deriving 10 bot identities:\n') // Derive bots at indices 0-9 const bots = Array.from({ length: 10 }, (_, i) => derive(root, 'bot', i)) for (const bot of bots) { console.log(` bot/${bot.index}: ${bot.npub}`) } // Recovery: scan the 'bot' purpose to rediscover all console.log('\nRecovering bot fleet...\n') const found = recover(root, ['bot'], 10) const recovered = found.get('bot')! console.log(`Recovered ${recovered.length} bots`) console.log(`All match? ${bots.every((b, i) => b.npub === recovered[i]!.npub)}`) // true root.destroy() ``` ## Subpath Exports Reference | 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` | createBlindProof, createFullProof, verifyProof | No | | `nsec-tree/persona` | derivePersona, deriveFromPersona, recoverPersonas | No | | `nsec-tree/event` | toUnsignedEvent, fromEvent | No | | `nsec-tree/encoding` | encodeNsec, decodeNsec, encodeNpub, decodeNpub | No | ## Summary nsec-tree is designed for applications requiring multiple Nostr identities with cryptographic isolation and recoverability. Common use cases include managing separate personas (personal, work, anonymous) from a single backup phrase, creating bot fleets where each bot has its own identity but all derive from one seed, implementing key rotation with linkage proofs for continuity, and modeling organizational hierarchies (teams, departments, services) as deterministic key trees. The library integrates seamlessly with existing Nostr tools—derived identities work directly with nostr-tools for event signing and any NIP-19 compatible client. For integration, start with `fromMnemonic` for new projects or `fromNsec` for existing users, use `derive` for flat identity sets or `deriveFromIdentity` for hierarchical structures, and always call `root.destroy()` and `zeroise(identity)` when secrets are no longer needed. The subpath exports allow minimal bundle sizes—use `nsec-tree/core` to avoid BIP-32/39 dependencies entirely. Linkage proofs enable selective disclosure of identity relationships without compromising the unlinkability of other identities, making nsec-tree suitable for privacy-conscious applications that still need identity verification capabilities.