forgesworn/nsec-tree-py

GitHub: forgesworn/nsec-tree-py

Nostr 子身份确定性派生协议(NIP-IDENTITY-TREES)的 Python 实现,支持从单一主密钥派生不可链接的多棵子身份树并提供密码学关联证明。

Stars: 0 | Forks: 0

# nsec-tree-py [![PyPI](https://img.shields.io/pypi/v/nsec-tree)](https://pypi.org/project/nsec-tree/) [![Python](https://img.shields.io/pypi/pyversions/nsec-tree)](https://pypi.org/project/nsec-tree/) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/forgesworn/nsec-tree-py/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) 确定性的 Nostr 子身份派生 — [nsec-tree 协议](https://github.com/forgesworn/nsec-tree/blob/main/PROTOCOL.md) 的 Python 实现(NIP-IDENTITY-TREES `v1.1`)。 从单个主 nsec 派生出一棵独立的 secp256k1 密钥对树。 每个子密钥都是一个完全可用的 Nostr 身份(nsec + npub),绑定到一个 人类可读的 **purpose** 字符串和一个数字 **index**。如果没有显式的关联证明, 子身份在密码学上是不可链接的。 - **确定性与跨实现。** 相同的 `(purpose, index)` 在 Python、TypeScript 和 Rust 实现中会产生相同的密钥 — 这已通过针对 TypeScript v1.5.1 参考实现的 61 个固定用例的差分测试套件进行了逐字节的断言。 - **两个入口点。** 从 Nostr `nsec` 或从 BIP-39 助记词派生 (`pip install nsec-tree[mnemonic]`)。 - **不可链接的子身份,可按需证明。** 独立的身份仅通过显式的 BIP-340 Schnorr **关联证明**(盲证明或全证明)进行链接,并可作为 NIP-78 事件发布。 - **强类型且精简。** 附带 `py.typed`;仅依赖 `coincurve` 和 `bech32`。MIT 协议。 ## 安装 ``` pip install nsec-tree ``` 需要 Python 3.11 或更高版本。 ### 助记词入口点(可选) ``` pip install nsec-tree[mnemonic] ``` ``` root = nsec_tree.from_mnemonic("abandon abandon ... about") ``` 助记词路径(BIP-39 → BIP-32 `m/44'/1237'/727'/0'/0'`)和 nsec 路径 会从同一个密钥产生**不同**的树根 — 请选择其中一个入口点。 ## 快速开始 ### 1. 从你的 nsec 构建树根 ``` import nsec_tree root = nsec_tree.from_nsec("nsec1...") print(root.master_npub) # the root's public identity ``` 原始的 nsec 字节永远不会直接用作派生密钥 — 一个单向的 HMAC 步骤会在你的签名密钥和树根之间创建隔离(PROTOCOL.md §1.2)。 ### 2. 派生子身份 ``` # 为指定 purpose 字符串和 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) ``` 任何 purpose 字符串都可以,前提是它非空、最多 255 字节、 不包含空字节且不仅由空白字符组成。Purpose 字符串区分大小写且要求字节精确。 ### 3. 派生命名的人设 人设是 `derive` 的便捷封装,使用了保留的 `nostr:persona:` 命名空间: ``` writer = nsec_tree.derive_persona(root, "writer") print(writer.name) # "writer" print(writer.identity.npub) # the derived Nostr identity # 恢复 index 范围内的已知 persona found = nsec_tree.recover_personas(root, ["writer", "work"], scan_range=5) ``` 在任何符合规范的 nsec-tree 实现(TypeScript、Rust、Python)中, 使用相同名称派生的人设会产生相同的密钥。 ### 4. 从现有身份派生(层级结构) ``` # 在现有子节点下添加一层 — 子节点的私钥将作为 # 下一次派生的 HMAC 密钥。 social = nsec_tree.derive(root, "social") sub = nsec_tree.derive_from_identity(social, "commerce") ``` ### 5. 恢复已知的 purpose 扫描一系列索引,以便从已知的 purpose 字符串列表中重建先前派生的身份: ``` 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. 编码与解码密钥 ``` # 原始字节 ↔ 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. 关联证明 关联证明允许主身份证明其对子密钥的所有权 — 可以是*盲证明*(在不透露 purpose/index 的情况下证明所有权),也可以是*全证明*(透露派生参数)。证明是对以竖线分隔的证明字符串进行的 BIP-340 Schnorr 签名,**已与 TypeScript `nsec-tree` (@noble) 实现进行了交叉验证的互操作**。 ``` import nsec_tree root = nsec_tree.from_nsec("nsec1...") child = nsec_tree.derive(root, "social") # Blind proof — 证明 master → child,而不泄露 purpose 或 index blind = nsec_tree.create_blind_proof(root, child) assert nsec_tree.verify_proof(blind) # Full proof — 公开 purpose 和 index full = nsec_tree.create_full_proof(root, child) assert nsec_tree.verify_proof(full) # 序列化 / 反序列化(camelCase wire format,与 TS impl 兼容) wire = nsec_tree.proof_to_dict(full) restored = nsec_tree.proof_from_dict(wire) assert nsec_tree.verify_proof(restored) ``` ### 8. 将证明发布为 Nostr 事件(NIP-78) 关联证明可以被封装为未签名的 NIP-78(Kind `30078`)Nostr 事件, 使用你自己的 Nostr 库进行签名和发布,之后可以被重新解析。其 tag 布局与 TypeScript `nsec-tree` 实现互操作。 ``` 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) # 封装 → 一个 unsigned event;使用您的 Nostr client 对其进行签名/发布 event = nsec_tree.to_unsigned_event(proof) event_dict = dataclasses.asdict(event) # {kind, pubkey, created_at, tags, content} # 将接收到的 event 解析回 proof,然后进行验证 restored = nsec_tree.from_event(event_dict) # also accepts the UnsignedEvent directly assert nsec_tree.verify_proof(restored) ``` `to_unsigned_event` 不会进行签名 — 它会生成未签名的事件,以便你的 Nostr 库进行最终处理。`from_event` 会拒绝重复的 nsec-tree tag(这是一种防止“重复 tag 夹带”的保护措施)以及格式错误的字段。 ### 9. 内存清零 调用 `zeroise` 可清除身份的私钥。这是尽力而为的 — CPython 无法原地清除不可变的 `bytes`/`str`;`zeroise` 会将 `private_key` 和 `nsec` 重新绑定为已清除的值,并丢弃相关引用: ``` nsec_tree.zeroise(social) ``` 调用 `root.destroy()` 可擦除树根密钥: ``` root.destroy() ``` ## API 参考 ### `from_nsec(nsec: str | bytes) -> TreeRoot` 从 bech32 `nsec` 字符串或原始的 32 字节密钥材料构建树根。 ### `from_mnemonic(mnemonic: str, passphrase: str | None = None) -> TreeRoot` 从 BIP-39 助记词短语构建树根。需要 `pip install nsec-tree[mnemonic]`。 在 `m/44'/1237'/727'/0'/0'`(全部为硬化派生)处进行派生;生成的密钥将直接作为树根密钥(没有额外的 HMAC 步骤 — 这与 `from_nsec` 不同)。 如果未安装扩展包或助记词无效,将引发 `NsecTreeError`。 ### `TreeRoot` | 属性 | 类型 | 描述 | |-----------|------|-------------| | `secret` | `bytes` | 32 字节的树根密钥(敏感) | | `master_pubkey` | `bytes` | x-only 公钥(32 字节) | | `master_npub` | `str` | 主身份的 bech32 npub | `root.destroy()` 是尽力而为的 — 会将 `secret` 重新绑定为空字节;CPython 无法原地擦除原始的 `bytes` 对象。 ### `derive(root, purpose, index=0) -> Identity` 派生子身份。如果直到 `0xFFFFFFFF` 的每个索引都未通过曲线阶检查(在实践中极不可能发生),则引发 `IndexOverflow`。 ### `derive_persona(root, name, index=0) -> Persona` 在 purpose `nostr:persona:` 处派生子身份。返回一个 `Persona` — 见下文。 ### `Persona` | 属性 | 类型 | 描述 | |-----------|------|-------------| | `identity` | `Identity` | 派生出的 Nostr 身份 | | `name` | `str` | 人设名称(不带 `nostr:persona:` 前缀) | | `index` | `int` | 实际使用的索引(可能高于请求的索引) | `Persona` 是冻结的(不可变)。通过 `persona.identity.nsec` / `persona.identity.npub` 访问 Nostr 密钥。 ### `derive_from_persona(persona, purpose, index=0) -> Identity` 在人设内派生子身份(两级层级结构)。使用人设的私钥作为进一步派生步骤的 HMAC 密钥 — 作用与 `derive_from_identity` 匹配,但直接接收一个 `Persona` 对象。 ### `recover_personas(root, names=DEFAULT_PERSONA_NAMES, scan_range=1) -> dict[str, list[Persona]]` 扫描一系列索引,以便从已知名称列表中重建先前派生的人设。返回一个字典,将每个名称映射到对应索引 `0 .. scan_range-1` 的 `Persona` 对象列表。`names` 默认为 `DEFAULT_PERSONA_NAMES`(`personal`、`bitcoiner`、`work`、`social`、`anonymous`)。 ### `derive_from_identity(identity, purpose, index=0) -> Identity` 使用 `identity.private_key` 作为进一步派生步骤的 HMAC 密钥。 ### `recover(root, purposes, scan_range=20) -> dict[str, list[Identity]]` 返回一个字典,将每个 purpose 映射到对应索引 `0 .. scan_range-1` 的 `Identity` 对象列表。 ### `Identity` | 属性 | 类型 | 描述 | |-----------|------|-------------| | `private_key` | `bytes` | 32 字节私钥(敏感) | | `public_key` | `bytes` | x-only 公钥(32 字节) | | `nsec` | `str` | bech32 nsec | | `npub` | `str` | bech32 npub | | `purpose` | `str` | 派生中使用的 Purpose 字符串 | | `index` | `int` | 实际使用的索引(可能高于请求的索引) | `Identity` 是冻结的(不可变)。调用 `zeroise(identity)` 以尽力清除密钥 — 请参阅下文的 `zeroise`。 ### `zeroise(identity: Identity) -> None` 尽力而为的密钥清除 — 将 `private_key` 重新绑定为空字节,并将 `nsec` 绑定为 `""`。CPython 无法原地清除不可变的 `bytes`/`str`;属性仅仅是被重新绑定,原始密钥材料会一直保留在内存中,直到被垃圾回收器回收。 ### `create_blind_proof(root, child) -> LinkageProof` 创建盲关联证明 — 证明主密钥拥有该子密钥,且不泄露 purpose 字符串或 index。 ### `create_full_proof(root, child) -> LinkageProof` 创建完全关联证明 — 在 Schnorr 证明字符串旁边公开 purpose 和 index。如果 purpose 包含 `|` 或控制字符(这会破坏以竖线分隔的证明格式),将引发 `InvalidPurpose`。 ### `verify_proof(proof: LinkageProof) -> bool` 验证关联证明。对于任何无效或被篡改的证明,返回 `False`(绝不引发异常)。 ### `LinkageProof` | 属性 | 类型 | 描述 | |-----------|------|-------------| | `master_pubkey` | `str` | 小写十六进制 x-only 主公钥(64 字符) | | `child_pubkey` | `str` | 小写十六进制 x-only 子公钥(64 字符) | | `attestation` | `str` | 已签名的证明字符串 | | `signature` | `str` | 小写十六进制 BIP-340 Schnorr 签名(128 字符) | | `purpose` | `str \| None` | Purpose 字符串(仅限完全证明) | | `index` | `int \| None` | 派生索引(仅限完全证明) | `LinkageProof` 是冻结的(不可变)。 ### `proof_to_dict(proof) -> dict` 序列化为与 TypeScript 实现交换的 camelCase 传输格式。 ### `proof_from_dict(d: dict) -> LinkageProof` 从 camelCase 传输格式反序列化。 ### `to_unsigned_event(proof, created_at=None) -> UnsignedEvent` 将 `LinkageProof` 封装为未签名的 NIP-78(Kind 30078)Nostr 事件。`created_at` 默认为当前的 Unix 时间戳;显式传入该值可获得确定性输出。如果证明存在结构性格式错误,将引发 `NsecTreeError`。 ### `from_event(event) -> LinkageProof` 从 NIP-78 事件中提取 `LinkageProof` — 接收一个 `UnsignedEvent` 或包含 `pubkey` 和 `tags` 的映射。如果存在缺失、重复或格式错误的 tag,将引发 `NsecTreeError`。将结果传递给 `verify_proof`。 ### `UnsignedEvent` 冻结的 dataclass,其字段为 Nostr 事件的 JSON 键 — `kind`、`pubkey`、`created_at`、`tags`、`content` — 因此 `dataclasses.asdict(ev)` 就是一个随时可签名的事件。 ### `NSEC_TREE_EVENT_KIND` / `NSEC_TREE_D_PREFIX` NIP-78 的 kind(`30078`)以及 `d`-tag 的命名空间前缀(`nsec-tree:`)。 ### `encoding` 模块 | 函数 | 描述 | |----------|-------------| | `encode_nsec(privkey: bytes) -> str` | 原始字节 → bech32 nsec | | `decode_nsec(nsec: str) -> bytes` | bech32 nsec → 原始字节 | | `encode_npub(pubkey: bytes) -> str` | 原始字节 → bech32 npub | | `decode_npub(npub: str) -> bytes` | bech32 npub → 原始字节 | ## 一致性 nsec-tree-py 已通过**完整的固定测试向量套件** ([PROTOCOL.md §6.1–6.6](https://github.com/forgesworn/nsec-tree/blob/main/PROTOCOL.md#6-test-vectors))验证, 涵盖了 nsec 径(§6.1–6.3)和助记词路径(§6.4–6.6)。 规范的测试向量输入(32 字节的 `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 ``` 协议测试向量会在每次提交时运行(`tests/test_vectors.py`、`tests/test_mnemonic.py`)。 此外,**包含 61 个用例的差分测试套件**(`tests/test_reference_vectors.py`)断言了其与 TypeScript **v1.5.1** 参考实现在所有公共函数上的逐字节相等性 — 包括派生、人设、层级结构、关联证明和 NIP-78 事件 — 这证明了两者的一致性,而非仅仅假设。 ## 状态与安全性 nsec-tree-py 版本为 `1.0.0`。它具有以下特点: - **经过一致性测试** — 在每次提交时针对固定的 `PROTOCOL.md` 测试向量(§6.1–6.6)进行测试;并且 - **经过互操作验证** — 其关联证明*和* NIP-78 事件 tag 均已与 TypeScript `nsec-tree` (@noble) 进行了双向交叉检查,并将真实的参考输出固定在了测试套件中。 它**尚未**经过独立的安全审计。在将其用于管理高价值密钥之前,请自行审查。这里有两个客观的限制: - **内存清零是尽力而为的。** CPython 无法原地清除不可变的 `bytes`/`str`;`zeroise` 和 `destroy` 会丢弃引用,但无法保证旧的字节会从内存中抹去。建议使用短生命周期的密钥;不要单纯依赖擦除。出于同样的原因,助记词路径也无法清除 BIP-39/BIP-32 的中间材料(种子字节)。 请通过 [issues](https://github.com/forgesworn/nsec-tree-py/issues) 报告你发现的任何问题。 ## 价值回馈 MIT 许可证。如果 nsec-tree-py 为你节省了时间,可以考虑打赏: - ⚡ profusemeat89@walletofsatoshi.com · https://strike.me/thedonkey - https://ko-fi.com/brays - https://geyser.fund/project/forgesworn ## 许可证 MIT — 请参阅 [LICENSE](LICENSE)。版权所有 © TheCryptoDonkey。 ## ForgeSworn 工具包的一部分 nsec-tree-py 是 [`forgesworn/nsec-tree`](https://github.com/forgesworn/nsec-tree) 的 Python 移植版, 后者是 nsec-tree 协议的权威 TypeScript 实现。同时还有一个 Rust 实现; 这三者在输入相同的情况下都会生成完全一致的密钥。 针对 LLM 和编码智能体消费者,精简的机器可读摘要位于 [`llms.txt`](llms.txt)。
标签:Nostr, Python, 区块链, 密码学, 密钥派生, 开发库, 手动系统调用, 无后门, 逆向工具