Deterministic Nostr sub-identity derivation (NIP-IDENTITY-TREES) — Python
Project description
nsec-tree-py
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.
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 nsec-path vector suite (§6.1–6.3) is exercised on every commit via tests/test_vectors.py.
Status & security
nsec-tree-py is 1.0.0. It is:
- Conformance-tested against the frozen
PROTOCOL.mdvectors (§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/strin place;zeroiseanddestroydrop 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:
- ⚡ thedonkey@strike.me · https://strike.me/thedonkey
- https://ko-fi.com/brays
- https://geyser.fund/project/forgesworn
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nsec_tree-1.0.0.tar.gz.
File metadata
- Download URL: nsec_tree-1.0.0.tar.gz
- Upload date:
- Size: 39.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8cb0aed23dce3f4bc7d79c8db5b6d288d6d09411929fb1c1f162d6e7a3b4cf8a
|
|
| MD5 |
e61bb20b793fa5640a0db83de7934d23
|
|
| BLAKE2b-256 |
f56e34a28010fd2047f0a531d602aaeed1711ee56a6d9b45d6e482dd6879bbae
|
Provenance
The following attestation bundles were made for nsec_tree-1.0.0.tar.gz:
Publisher:
release.yml on forgesworn/nsec-tree-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nsec_tree-1.0.0.tar.gz -
Subject digest:
8cb0aed23dce3f4bc7d79c8db5b6d288d6d09411929fb1c1f162d6e7a3b4cf8a - Sigstore transparency entry: 2036064881
- Sigstore integration time:
-
Permalink:
forgesworn/nsec-tree-py@4a760a23179df416b1e5cd25bc1b2bc074669624 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/forgesworn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4a760a23179df416b1e5cd25bc1b2bc074669624 -
Trigger Event:
push
-
Statement type:
File details
Details for the file nsec_tree-1.0.0-py3-none-any.whl.
File metadata
- Download URL: nsec_tree-1.0.0-py3-none-any.whl
- Upload date:
- Size: 20.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
162d3c6a1f5b3408f9706bdbe60b18d508506a9b4411fed496db9250066af1a1
|
|
| MD5 |
28f3707b10cb43a4320de362ad872ae9
|
|
| BLAKE2b-256 |
2678836461616cc59df0cae3f3067b67e2a1be05642d75e0e9b08eeab8c24ed5
|
Provenance
The following attestation bundles were made for nsec_tree-1.0.0-py3-none-any.whl:
Publisher:
release.yml on forgesworn/nsec-tree-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nsec_tree-1.0.0-py3-none-any.whl -
Subject digest:
162d3c6a1f5b3408f9706bdbe60b18d508506a9b4411fed496db9250066af1a1 - Sigstore transparency entry: 2036064966
- Sigstore integration time:
-
Permalink:
forgesworn/nsec-tree-py@4a760a23179df416b1e5cd25bc1b2bc074669624 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/forgesworn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4a760a23179df416b1e5cd25bc1b2bc074669624 -
Trigger Event:
push
-
Statement type: