Skip to main content

Deterministic Nostr sub-identity derivation (NIP-IDENTITY-TREES) — Python

Project description

nsec-tree-py

PyPI Python CI License: MIT

Deterministic Nostr sub-identity derivation — Python implementation of the nsec-tree protocol (NIP-IDENTITY-TREES v1.1).

Derives a tree of independent secp256k1 key pairs from a single master nsec. Each child key is a fully usable Nostr identity (nsec + npub) bound to a human-readable purpose string and a numeric index. Children are cryptographically unlinkable without an explicit linkage proof.

  • Deterministic and cross-implementation. The same (purpose, index) yields the same key in the Python, TypeScript, and Rust implementations — asserted byte-for-byte by a frozen 61-case differential suite against the TypeScript v1.5.1 reference.
  • Two entry points. Derive from a Nostr nsec, or from a BIP-39 mnemonic (pip install nsec-tree[mnemonic]).
  • Unlinkable children, provable on demand. Independent identities that link only with an explicit BIP-340 Schnorr linkage proof (blind or full), publishable as a NIP-78 event.
  • Typed and lean. Ships py.typed; depends only on coincurve and bech32. MIT.

Status: 1.0.0 — conformance-tested against the full frozen vector suite (§6.1–6.6), cross-verified against the TypeScript reference, not independently audited. Semantic versioning; breaking changes only on major bumps. See Status & security before using it to protect high-value keys.


Install

pip install nsec-tree

Requires Python 3.11 or later.

Mnemonic entry point (optional)

pip install nsec-tree[mnemonic]
root = nsec_tree.from_mnemonic("abandon abandon ... about")

The mnemonic path (BIP-39 → BIP-32 m/44'/1237'/727'/0'/0') and the nsec path produce different tree roots from the same secret — choose one entry point.


Quick start

1. Build a tree root from your nsec

import nsec_tree

root = nsec_tree.from_nsec("nsec1...")

print(root.master_npub)  # the root's public identity

The raw nsec bytes are never used directly as the derivation key — a one-way HMAC step creates separation between your signing key and the tree root (PROTOCOL.md §1.2).

2. Derive a sub-identity

# Derive a child key for a purpose string and index
social = nsec_tree.derive(root, "social")          # index 0 by default
commerce = nsec_tree.derive(root, "commerce", 0)

print(social.nsec)    # bech32 nsec — use as a Nostr signing key
print(social.npub)    # bech32 npub — share as your Nostr public key
print(social.purpose) # "social"
print(social.index)   # 0 (may differ from requested if curve-order retry fired)

Any purpose string works as long as it is non-empty, at most 255 bytes, contains no null bytes, and is not whitespace-only. Purpose strings are case-sensitive and byte-exact.

3. Derive a named persona

A persona is a convenience wrapper over derive that uses the reserved nostr:persona:<name> namespace:

writer = nsec_tree.derive_persona(root, "writer")
print(writer.name)          # "writer"
print(writer.identity.npub) # the derived Nostr identity

# Recover known personas across an index range
found = nsec_tree.recover_personas(root, ["writer", "work"], scan_range=5)

Personas derived with the same name in any conformant nsec-tree implementation (TypeScript, Rust, Python) produce identical keys.

4. Derive from an existing identity (hierarchy)

# Add a layer beneath an existing child — the child's private key becomes
# the HMAC key for the next derivation.
social = nsec_tree.derive(root, "social")
sub = nsec_tree.derive_from_identity(social, "commerce")

5. Recover known purposes

Scan a range of indices to reconstruct previously derived identities from a list of known purpose strings:

recovered = nsec_tree.recover(root, ["social", "commerce"], scan_range=20)

for purpose, identities in recovered.items():
    for identity in identities:
        print(identity.index, identity.npub)

6. Encode and decode keys

# Raw bytes ↔ bech32 nsec / npub
raw = nsec_tree.encoding.decode_nsec(social.nsec)
back = nsec_tree.encoding.encode_nsec(raw)  # roundtrips exactly

pub_raw = nsec_tree.encoding.decode_npub(social.npub)

7. Linkage proofs

A linkage proof lets the master identity prove ownership of a child key — either blind (proving ownership without revealing purpose/index) or full (revealing the derivation parameters). Proofs are BIP-340 Schnorr signatures over a pipe-delimited attestation string, cross-verified interoperable with the TypeScript nsec-tree (@noble) implementation.

import nsec_tree

root = nsec_tree.from_nsec("nsec1...")
child = nsec_tree.derive(root, "social")

# Blind proof — proves master → child without revealing purpose or index
blind = nsec_tree.create_blind_proof(root, child)
assert nsec_tree.verify_proof(blind)

# Full proof — reveals purpose and index
full = nsec_tree.create_full_proof(root, child)
assert nsec_tree.verify_proof(full)

# Serialise / deserialise (camelCase wire format, compatible with TS impl)
wire = nsec_tree.proof_to_dict(full)
restored = nsec_tree.proof_from_dict(wire)
assert nsec_tree.verify_proof(restored)

8. Publish a proof as a Nostr event (NIP-78)

A linkage proof can be wrapped as an unsigned NIP-78 (Kind 30078) Nostr event, signed and published with your own Nostr library, then parsed back later. The tag layout is interoperable with the TypeScript nsec-tree implementation.

import dataclasses
import nsec_tree

root = nsec_tree.from_nsec("nsec1...")
child = nsec_tree.derive(root, "social")
proof = nsec_tree.create_full_proof(root, child)

# Wrap → an unsigned event; sign/publish it with your Nostr client
event = nsec_tree.to_unsigned_event(proof)
event_dict = dataclasses.asdict(event)   # {kind, pubkey, created_at, tags, content}

# Parse a received event back into a proof, then verify
restored = nsec_tree.from_event(event_dict)   # also accepts the UnsignedEvent directly
assert nsec_tree.verify_proof(restored)

to_unsigned_event does not sign — it produces the unsigned event for your Nostr library to finalise. from_event rejects duplicate nsec-tree tags (a "duplicate-tag smuggling" guard) and malformed fields.

9. Zeroisation

Call zeroise to clear an identity's private key. This is best-effort — CPython cannot scrub immutable bytes/str in place; zeroise rebinds both private_key and nsec to cleared values and drops the references:

nsec_tree.zeroise(social)

Call root.destroy() to wipe the tree root secret:

root.destroy()

API reference

from_nsec(nsec: str | bytes) -> TreeRoot

Build a tree root from a bech32 nsec string or raw 32-byte key material.

from_mnemonic(mnemonic: str, passphrase: str | None = None) -> TreeRoot

Build a tree root from a BIP-39 mnemonic phrase. Requires pip install nsec-tree[mnemonic]. Derives at m/44'/1237'/727'/0'/0' (all hardened); the resulting key is used directly as the tree-root secret (no extra HMAC — distinct from from_nsec). Raises NsecTreeError if the extra is not installed or the mnemonic is invalid.

TreeRoot

Attribute Type Description
secret bytes 32-byte tree root secret (sensitive)
master_pubkey bytes x-only public key (32 bytes)
master_npub str bech32 npub of the master identity

root.destroy() is best-effort — rebinds secret to null bytes; CPython cannot scrub the original bytes object in place.

derive(root, purpose, index=0) -> Identity

Derive a child identity. Raises IndexOverflow if every index up to 0xFFFFFFFF fails the curve-order check (astronomically unlikely in practice).

derive_persona(root, name, index=0) -> Persona

Derive the child at purpose nostr:persona:<name>. Returns a Persona — see below.

Persona

Attribute Type Description
identity Identity The derived Nostr identity
name str The persona name (without the nostr:persona: prefix)
index int Actual index used (may be higher than requested)

Persona is frozen (immutable). Access the Nostr keys via persona.identity.nsec / persona.identity.npub.

derive_from_persona(persona, purpose, index=0) -> Identity

Derive a sub-identity within a persona (two-level hierarchy). Uses the persona's private key as the HMAC key for a further derivation step — matching derive_from_identity but taking a Persona directly.

recover_personas(root, names=DEFAULT_PERSONA_NAMES, scan_range=1) -> dict[str, list[Persona]]

Scan a range of indices to reconstruct previously derived personas from a list of known names. Returns a dict mapping each name to a list of Persona objects for indices 0 .. scan_range-1. names defaults to DEFAULT_PERSONA_NAMES (personal, bitcoiner, work, social, anonymous).

derive_from_identity(identity, purpose, index=0) -> Identity

Use identity.private_key as the HMAC key for a further derivation step.

recover(root, purposes, scan_range=20) -> dict[str, list[Identity]]

Return a dict mapping each purpose to a list of Identity objects for indices 0 .. scan_range-1.

Identity

Attribute Type Description
private_key bytes 32-byte private key (sensitive)
public_key bytes x-only public key (32 bytes)
nsec str bech32 nsec
npub str bech32 npub
purpose str Purpose string used in derivation
index int Actual index used (may be higher than requested)

Identity is frozen (immutable). Call zeroise(identity) for best-effort secret clearance — see zeroise below.

zeroise(identity: Identity) -> None

Best-effort secret clearance — rebinds private_key to null bytes and nsec to "". CPython cannot scrub immutable bytes/str in place; the attribute is merely rebound and the original key material remains in memory until the garbage collector reclaims it.

create_blind_proof(root, child) -> LinkageProof

Create a blind linkage proof — proves that the master key owns the child key without revealing the purpose string or index.

create_full_proof(root, child) -> LinkageProof

Create a full linkage proof — reveals purpose and index alongside the Schnorr attestation. Raises InvalidPurpose if the purpose contains | or control characters (which would break the pipe-delimited attestation format).

verify_proof(proof: LinkageProof) -> bool

Verify a linkage proof. Returns False (never raises) for any invalid or tampered proof.

LinkageProof

Attribute Type Description
master_pubkey str Lowercase hex x-only master public key (64 chars)
child_pubkey str Lowercase hex x-only child public key (64 chars)
attestation str The signed attestation string
signature str Lowercase hex BIP-340 Schnorr signature (128 chars)
purpose str | None Purpose string (full proofs only)
index int | None Derivation index (full proofs only)

LinkageProof is frozen (immutable).

proof_to_dict(proof) -> dict

Serialise to the camelCase wire format exchanged with the TypeScript implementation.

proof_from_dict(d: dict) -> LinkageProof

Deserialise from the camelCase wire format.

to_unsigned_event(proof, created_at=None) -> UnsignedEvent

Wrap a LinkageProof as an unsigned NIP-78 (Kind 30078) Nostr event. created_at defaults to the current Unix time; pass it explicitly for deterministic output. Raises NsecTreeError if the proof is structurally malformed.

from_event(event) -> LinkageProof

Extract a LinkageProof from a NIP-78 event — accepts an UnsignedEvent or a mapping with pubkey and tags. Raises NsecTreeError on missing, duplicate, or malformed tags. Pass the result to verify_proof.

UnsignedEvent

Frozen dataclass whose fields are the Nostr event JSON keys — kind, pubkey, created_at, tags, content — so dataclasses.asdict(ev) is a ready-to-sign event.

NSEC_TREE_EVENT_KIND / NSEC_TREE_D_PREFIX

The NIP-78 kind (30078) and the d-tag namespace prefix (nsec-tree:).

encoding module

Function Description
encode_nsec(privkey: bytes) -> str Raw bytes → bech32 nsec
decode_nsec(nsec: str) -> bytes bech32 nsec → raw bytes
encode_npub(pubkey: bytes) -> str Raw bytes → bech32 npub
decode_npub(npub: str) -> bytes bech32 npub → raw bytes

Conformance

nsec-tree-py is verified against the full frozen vector suite (PROTOCOL.md §6.1–6.6), covering both the nsec path (§6.1–6.3) and the mnemonic path (§6.4–6.6).

The canonical test-vector inputs (32 bytes of 0x01):

nsec_bytes   = 0101...01  (32 bytes)

tree root (nsec path, §1.2):
  tree_root   = 8d2db9ce9548534e7ae924d05e311355e3a12744214c88e65b39fa2bf2df6d6f
  master_pub  = 8c03e047ae60c01e942a8337e71d17e3517fcc63ee6ceff8173bbd23fabe649d
  master_npub = npub13sp7q3awvrqpa9p2svm7w8ghudghlnrraekwl7qh8w7j8747vjwskvzy2u

vector 1 — purpose "social", index 0:
  child_priv  = 98e98b476eab3c2bcb5020e4a679a41b74eebfb30a07944c4361c906501265e7
  child_pub   = cdc4cd2a01ba1b8afd3299b66c38d13043a19acb687c334f0527cffaf464b372
  child_nsec  = nsec1nr5ck3mw4v7zhj6syrj2v7dyrd6wa0anpgregnzrv8ysv5qjvhnsafv7mx
  child_npub  = npub1ehzv62sphgdc4lfjnxmxcwx3xpp6rxktdp7rxnc9yl8l4arykdeqyfhrxy

vector 2 — purpose "commerce", index 0:
  child_priv  = fc62a2ec7f91970c485f9d7453268d1a6a07273ee829cf44c87685f78758f04f
  child_pub   = 8441f7e2a73fea0742ccd12858bd5b95ccae385fbcb2856b7d7177880198a663

vector 3 — purpose "social", index 1:
  child_priv  = 802a2fd31d25517bd2bb9b7196c377e6cc2f32728b916c2c3ea71ca703767917
  child_pub   = aed0bc4ccccdb868156e38cabf3a6acb98f8fa8a4abe0dcc68851d8468a87cd1

The protocol vectors run on every commit (tests/test_vectors.py, tests/test_mnemonic.py). Separately, a 61-case differential suite (tests/test_reference_vectors.py) asserts byte-for-byte equality with the TypeScript v1.5.1 reference across every public function — derivation, personas, hierarchy, linkage proofs, and NIP-78 events — so parity is proven, not assumed.


Status & security

nsec-tree-py is 1.0.0. It is:

  • Conformance-tested against the frozen PROTOCOL.md vectors (§6.1–6.6) on every commit; and
  • Interop-verified — its linkage proofs and NIP-78 event tags are cross-checked against the TypeScript nsec-tree (@noble) in both directions, with genuine reference outputs frozen into the test suite.

It has not had an independent security audit. Review it yourself before trusting it with high-value keys. Two honest limits:

  • Zeroisation is best-effort. CPython cannot scrub immutable bytes/str in place; zeroise and destroy drop references but cannot guarantee the old bytes leave memory. Prefer short-lived secrets; do not rely on wiping. The mnemonic path additionally cannot scrub BIP-39/BIP-32 intermediate material (seed bytes) for the same reason.

Report anything you find via issues.


Value-for-value

MIT-licensed. If nsec-tree-py saves you time, consider a tip:


Licence

MIT — see LICENSE. Copyright © TheCryptoDonkey.


Part of the ForgeSworn toolkit

nsec-tree-py is the Python port of forgesworn/nsec-tree, the canonical TypeScript implementation of the nsec-tree protocol. A Rust implementation exists too; all three produce identical keys for the same inputs.

For LLM and coding-agent consumers, a condensed machine-readable summary lives at llms.txt.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nsec_tree-1.0.1.tar.gz (41.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nsec_tree-1.0.1-py3-none-any.whl (20.7 kB view details)

Uploaded Python 3

File details

Details for the file nsec_tree-1.0.1.tar.gz.

File metadata

  • Download URL: nsec_tree-1.0.1.tar.gz
  • Upload date:
  • Size: 41.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nsec_tree-1.0.1.tar.gz
Algorithm Hash digest
SHA256 c3cd641cc9e0f4319e80f87fd169910c25437711a9a2b12886b8b066859ce51b
MD5 40bef46f07c8f7d11201533374c14d88
BLAKE2b-256 00e3739a9b4fa972e3b88881a686278b079f59b1c2b28b0e026a1d12c7d1d9b0

See more details on using hashes here.

Provenance

The following attestation bundles were made for nsec_tree-1.0.1.tar.gz:

Publisher: release.yml on forgesworn/nsec-tree-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nsec_tree-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: nsec_tree-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 20.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nsec_tree-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 452d0a46e5a34087fb11e7491a9023a7d1595a04544871e93648141d9e36f740
MD5 4a9fca64f9c7466bb427e440064502ac
BLAKE2b-256 2569de1701ac775610a506c2004ff20012dcccfaacaf77c7153678628e45ece6

See more details on using hashes here.

Provenance

The following attestation bundles were made for nsec_tree-1.0.1-py3-none-any.whl:

Publisher: release.yml on forgesworn/nsec-tree-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page