Add keys chapter
Introduces PublicKey and SecretKey as distinct type-safe wrappers around secp256k1, with hex and NIP-19 bech32 (npub/nsec) encoding. SecretKey has a redacted Debug impl and no Display to reduce accidental leakage; it exposes material only through explicit to_hex / to_nsec. FromStr on both types auto-detects hex vs. bech32. Eight round-trip tests cover encoding, auto-detection, prefix validation, debug redaction, and generation.
This commit is contained in:
@@ -26,6 +26,10 @@ Also read:
|
|||||||
|
|
||||||
Create or update the chapter markdown file in `./book/`. Follow these conventions:
|
Create or update the chapter markdown file in `./book/`. Follow these conventions:
|
||||||
|
|
||||||
|
- **Voice**: Write as though documenting a library, not teaching a class. Do not refer
|
||||||
|
to the book as a "teaching resource", "tutorial", "pedagogical", or similar — and
|
||||||
|
don't justify design decisions on the grounds that the reader is learning. The prose
|
||||||
|
should stand on its own merits as technical writing about the library.
|
||||||
- **Literate style**: The prose is the primary artifact. Code blocks are woven into the
|
- **Literate style**: The prose is the primary artifact. Code blocks are woven into the
|
||||||
narrative, not dumped in bulk.
|
narrative, not dumped in bulk.
|
||||||
- **Code blocks** that should be tangled use the annotation format:
|
- **Code blocks** that should be tangled use the annotation format:
|
||||||
@@ -39,6 +43,13 @@ Create or update the chapter markdown file in `./book/`. Follow these convention
|
|||||||
seeing the implementation.
|
seeing the implementation.
|
||||||
- Keep code blocks focused — one concept per block where possible.
|
- Keep code blocks focused — one concept per block where possible.
|
||||||
- Ensure all `use` statements and module declarations are included in tangled blocks.
|
- Ensure all `use` statements and module declarations are included in tangled blocks.
|
||||||
|
- **Tests are hand-written, not tangled, and do not appear in the chapter.** Do not put
|
||||||
|
test code in chapter markdown, do not emit `{file=crate-name/tests/…}` blocks, and do
|
||||||
|
not add a "Tests" section to the narrative. Tangle owns `src/` and overwrites it every
|
||||||
|
build, so tests must live *outside* `src/` to survive. Integration tests go in
|
||||||
|
`crate-name/tests/<topic>.rs` as normal, hand-edited Rust files alongside `src/`. Write
|
||||||
|
or update the test file directly with the Edit or Write tool as part of the same task,
|
||||||
|
and run `cargo test -p <crate>` alongside `just all` to verify.
|
||||||
- Update `./book/SUMMARY.md` if adding a new chapter.
|
- Update `./book/SUMMARY.md` if adding a new chapter.
|
||||||
- Update any `Cargo.toml` files if new dependencies are needed.
|
- Update any `Cargo.toml` files if new dependencies are needed.
|
||||||
|
|
||||||
|
|||||||
Generated
+271
@@ -2,6 +2,28 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bech32"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -33,6 +55,41 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coracle-content"
|
name = "coracle-content"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -40,15 +97,27 @@ dependencies = [
|
|||||||
"coracle-lib",
|
"coracle-lib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"coracle-lib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coracle-lib"
|
name = "coracle-lib"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bech32",
|
||||||
|
"chacha20poly1305",
|
||||||
"hex",
|
"hex",
|
||||||
|
"rand",
|
||||||
|
"scrypt",
|
||||||
"secp256k1",
|
"secp256k1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"unicode-normalization",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -95,6 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -106,6 +176,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -133,12 +204,41 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex"
|
name = "hex"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -157,6 +257,53 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pbkdf2"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "poly1305"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -194,12 +341,64 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "salsa20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scrypt"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||||
|
dependencies = [
|
||||||
|
"password-hash",
|
||||||
|
"pbkdf2",
|
||||||
|
"salsa20",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secp256k1"
|
name = "secp256k1"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"rand",
|
||||||
"secp256k1-sys",
|
"secp256k1-sys",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -273,6 +472,12 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -284,6 +489,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -302,18 +522,69 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ which are community-authored specifications.
|
|||||||
|
|
||||||
## What this book covers
|
## What this book covers
|
||||||
|
|
||||||
This book is both a tutorial and the source code for the `coracle` family of Rust crates:
|
This book is the source code for the `coracle` family of Rust crates:
|
||||||
|
|
||||||
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
|
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
|
||||||
serialization. Everything you need to understand and manipulate nostr data.
|
serialization. Everything you need to understand and manipulate nostr data.
|
||||||
|
|||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
# Keys
|
||||||
|
|
||||||
|
In most of the internet, your identity is something you apply for. You fill out a form,
|
||||||
|
a server writes a row in a database, and from then on that server decides what your
|
||||||
|
identity is — whether you can still log in, what you're allowed to say, and whether you
|
||||||
|
still exist at all. Nostr inverts this. Your identity is a **secp256k1 keypair** that you
|
||||||
|
generate on your own computer. No server issues it, no server can revoke it, and no
|
||||||
|
server has to be consulted to verify that a message came from you.
|
||||||
|
|
||||||
|
The public half of that keypair is your name. Anywhere a nostr event says "this came
|
||||||
|
from `pubkey`," it means "this message has a valid Schnorr signature under that 32-byte
|
||||||
|
public key." The secret half is your ability to speak as that name. Whoever holds it can
|
||||||
|
publish events that the whole network will attribute to you — and whoever loses it
|
||||||
|
loses the identity, permanently.
|
||||||
|
|
||||||
|
This chapter introduces two Rust types, `PublicKey` and `SecretKey`, that make this
|
||||||
|
primitive safe to work with. We'll cover how to generate keys, how to encode them as
|
||||||
|
hex and as the user-facing `npub` / `nsec` bech32 strings defined by
|
||||||
|
[NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md), and the handful of
|
||||||
|
small design decisions that reduce the chances of accidentally leaking secret material.
|
||||||
|
|
||||||
|
## Why secp256k1
|
||||||
|
|
||||||
|
Nostr uses the same elliptic curve as Bitcoin: `secp256k1`. Public keys are **x-only**,
|
||||||
|
following [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) — we
|
||||||
|
store only the 32-byte x-coordinate of the curve point and drop the parity bit, which
|
||||||
|
gives us fixed-width public keys and lets signatures be Schnorr rather than ECDSA. The
|
||||||
|
upshot for this chapter is simple: both a public key and a secret key fit in exactly
|
||||||
|
32 bytes, and every nostr library in the world agrees on that.
|
||||||
|
|
||||||
|
We already pulled the `secp256k1` crate into `coracle-lib` in the previous chapter for
|
||||||
|
event signature verification. In this chapter we'll wrap its key types in our own so
|
||||||
|
that users of the library never have to think about the raw primitives.
|
||||||
|
|
||||||
|
## The module
|
||||||
|
|
||||||
|
Let's register the module in the crate root and start a new file for the key types:
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod keys;
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
//! Nostr cryptographic identity: `PublicKey` and `SecretKey`.
|
||||||
|
//!
|
||||||
|
//! Both types wrap the corresponding `secp256k1` primitive and add support for
|
||||||
|
//! hex and NIP-19 bech32 (`npub` / `nsec`) encoding. `SecretKey` is deliberately
|
||||||
|
//! awkward to print: it has no `Display` impl and its `Debug` is redacted, so
|
||||||
|
//! the material can only escape through an explicit `to_hex` or `to_nsec` call.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use bech32::{Bech32, Hrp};
|
||||||
|
use secp256k1::{rand, SECP256K1};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
We'll use a single error type for everything in the module. One enum makes every way
|
||||||
|
a key operation can go wrong visible in one place, and lets callers match on a single
|
||||||
|
type instead of juggling a hierarchy.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
/// Errors that can occur when parsing or validating a nostr key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum KeyError {
|
||||||
|
/// The input was not valid hex, or not the right length.
|
||||||
|
InvalidHex,
|
||||||
|
/// The input was not a valid bech32 string.
|
||||||
|
InvalidBech32,
|
||||||
|
/// The bech32 human-readable prefix was not what we expected
|
||||||
|
/// (e.g. an `npub` passed where an `nsec` was required).
|
||||||
|
WrongPrefix { expected: &'static str, found: String },
|
||||||
|
/// The bytes decoded successfully but are not a valid key on the curve.
|
||||||
|
InvalidKey,
|
||||||
|
/// A NIP-49 ciphertext failed to authenticate. Either the password is
|
||||||
|
/// wrong or the payload has been corrupted — the two are indistinguishable.
|
||||||
|
DecryptionFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for KeyError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
KeyError::InvalidHex => write!(f, "invalid hex encoding"),
|
||||||
|
KeyError::InvalidBech32 => write!(f, "invalid bech32 encoding"),
|
||||||
|
KeyError::WrongPrefix { expected, found } => {
|
||||||
|
write!(f, "wrong bech32 prefix: expected {expected}, found {found}")
|
||||||
|
}
|
||||||
|
KeyError::InvalidKey => write!(f, "invalid secp256k1 key"),
|
||||||
|
KeyError::DecryptionFailed => write!(f, "ncryptsec decryption failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for KeyError {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public keys
|
||||||
|
|
||||||
|
A public key is a point on the secp256k1 curve, and because nostr uses x-only
|
||||||
|
representation it fits in 32 bytes. We wrap `secp256k1::XOnlyPublicKey` in a newtype so
|
||||||
|
that our own methods — hex encoding, bech32 encoding, parsing — hang off a type that
|
||||||
|
belongs to us rather than a foreign crate.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
/// A nostr public key: the x-coordinate of a secp256k1 point, 32 bytes.
|
||||||
|
///
|
||||||
|
/// This is the "name" half of a nostr identity. It's safe to log, share, and
|
||||||
|
/// store — it identifies an author but grants no ability to speak as them.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PublicKey(secp256k1::XOnlyPublicKey);
|
||||||
|
|
||||||
|
impl PublicKey {
|
||||||
|
/// The raw 32 bytes of the x-only public key.
|
||||||
|
pub fn as_bytes(&self) -> [u8; 32] {
|
||||||
|
self.0.serialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode as a lowercase 64-character hex string.
|
||||||
|
/// This is the form used inside event JSON.
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
hex::encode(self.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a 64-character hex string.
|
||||||
|
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
|
||||||
|
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(KeyError::InvalidHex);
|
||||||
|
}
|
||||||
|
let inner = secp256k1::XOnlyPublicKey::from_slice(&bytes)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
Ok(PublicKey(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The hex form is what lives inside event JSON — the `"pubkey"` field we saw in the
|
||||||
|
previous chapter. It's machine-friendly but visually indistinguishable from any other
|
||||||
|
64 hex characters, which is why nostr also defines a user-facing encoding.
|
||||||
|
|
||||||
|
### NIP-19 and `npub`
|
||||||
|
|
||||||
|
NIP-19 wraps the raw 32 bytes in bech32, the same encoding Bitcoin uses for Segwit
|
||||||
|
addresses. Bech32 gives us two things: a **human-readable prefix** that tells you what
|
||||||
|
kind of data you're looking at (`npub` for a public key, `nsec` for a secret), and a
|
||||||
|
checksum that catches copy-paste errors. An `npub` string is what a user will copy out
|
||||||
|
of their nostr client, paste into a profile page, or share in a bio.
|
||||||
|
|
||||||
|
We use `bech32 = "0.11"`, which takes a prefix, a byte slice, and a variant. NIP-19
|
||||||
|
specifies the original bech32 (not bech32m), so we pass the `Bech32` marker.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub");
|
||||||
|
const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec");
|
||||||
|
|
||||||
|
impl PublicKey {
|
||||||
|
/// Encode as an `npub1…` bech32 string per NIP-19.
|
||||||
|
pub fn to_npub(&self) -> String {
|
||||||
|
bech32::encode::<Bech32>(NPUB_HRP, &self.as_bytes())
|
||||||
|
.expect("npub encoding cannot fail for 32 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `npub1…` bech32 string.
|
||||||
|
pub fn from_npub(s: &str) -> Result<Self, KeyError> {
|
||||||
|
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||||
|
if hrp != NPUB_HRP {
|
||||||
|
return Err(KeyError::WrongPrefix {
|
||||||
|
expected: "npub",
|
||||||
|
found: hrp.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if data.len() != 32 {
|
||||||
|
return Err(KeyError::InvalidBech32);
|
||||||
|
}
|
||||||
|
let inner = secp256k1::XOnlyPublicKey::from_slice(&data)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
Ok(PublicKey(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The HRP constants are parsed once at compile time via `Hrp::parse_unchecked`, which is
|
||||||
|
safe here because `"npub"` and `"nsec"` are valid bech32 prefixes by construction.
|
||||||
|
|
||||||
|
### `Display` and `FromStr`
|
||||||
|
|
||||||
|
It's natural to want to print a `PublicKey` and to parse one back. The question is:
|
||||||
|
which form? We pick hex for `Display` because that's what lives on the wire, and we
|
||||||
|
make `FromStr` accept either encoding by sniffing the prefix first.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
impl fmt::Display for PublicKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.to_hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for PublicKey {
|
||||||
|
type Err = KeyError;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s.starts_with("npub1") {
|
||||||
|
Self::from_npub(s)
|
||||||
|
} else {
|
||||||
|
Self::from_hex(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This small bit of polymorphism is worth the handful of lines. Every time a user pastes
|
||||||
|
a key into a config file or a CLI argument, you can call `.parse()` and get the right
|
||||||
|
thing back without asking them which encoding they used.
|
||||||
|
|
||||||
|
## Secret keys
|
||||||
|
|
||||||
|
Secret keys are where the safety posture matters. A `secp256k1::SecretKey` is a 32-byte
|
||||||
|
scalar that, together with the generator point, produces the corresponding public key.
|
||||||
|
If it leaks — into a log file, a crash report, a debug print — the identity is lost
|
||||||
|
forever. So our `SecretKey` type is designed to make leaking it *harder than keeping it
|
||||||
|
safe*, not the other way around.
|
||||||
|
|
||||||
|
Three rules:
|
||||||
|
|
||||||
|
1. **No `Display`.** If `println!("{}", key)` compiled, someone would do it.
|
||||||
|
2. **Redacted `Debug`.** `#[derive(Debug)]` on any struct that contains a `SecretKey`
|
||||||
|
is a very common thing to write. We don't want that to dump the key.
|
||||||
|
3. **No `Copy`.** Copying a secret key around makes it harder to reason about where
|
||||||
|
the material lives.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
/// A nostr secret key: the 32-byte scalar that lets you sign as a given identity.
|
||||||
|
///
|
||||||
|
/// Handle with care. This type deliberately has no `Display` impl and its `Debug`
|
||||||
|
/// output is redacted. To extract the raw bytes you must call [`SecretKey::to_hex`]
|
||||||
|
/// or [`SecretKey::to_nsec`] explicitly, which makes the leak points easy to audit.
|
||||||
|
///
|
||||||
|
/// The underlying `secp256k1::SecretKey` zeroes its memory on drop, so dropping a
|
||||||
|
/// `SecretKey` does not leave the scalar sitting around in freed memory.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SecretKey(secp256k1::SecretKey);
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("SecretKey(<redacted>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice what's *not* there: no `impl Display`, no `#[derive(Debug)]`. A struct like
|
||||||
|
`struct Signer { sk: SecretKey, pk: PublicKey }` can still derive `Debug` in calling
|
||||||
|
code — it'll print `Signer { sk: SecretKey(<redacted>), pk: PublicKey(...) }`, which
|
||||||
|
is exactly what we want.
|
||||||
|
|
||||||
|
### Generating keys
|
||||||
|
|
||||||
|
To generate a fresh identity we need a source of randomness. `secp256k1` re-exports
|
||||||
|
`rand` when its `rand` feature is enabled, which gives us a process-wide-seeded
|
||||||
|
`OsRng` without us having to pick a random crate version by hand. The shared
|
||||||
|
`SECP256K1` context (also provided by `secp256k1`'s `global-context` feature) is
|
||||||
|
blinded at first access as a side-channel countermeasure.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
impl SecretKey {
|
||||||
|
/// Generate a brand-new secret key from the operating system's RNG.
|
||||||
|
pub fn generate() -> Self {
|
||||||
|
let (sk, _pk) = SECP256K1.generate_keypair(&mut rand::thread_rng());
|
||||||
|
SecretKey(sk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the matching [`PublicKey`].
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
let keypair = secp256k1::Keypair::from_secret_key(SECP256K1, &self.0);
|
||||||
|
let (xonly, _parity) = keypair.x_only_public_key();
|
||||||
|
PublicKey(xonly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`public_key()` is the one operation that always makes sense on a secret key: you need
|
||||||
|
to know what identity you're speaking as. Everything else — signing, NIP-44 encryption
|
||||||
|
— belongs in its own module and will consume a `&SecretKey` there.
|
||||||
|
|
||||||
|
### Encoding
|
||||||
|
|
||||||
|
The hex and bech32 methods mirror those on `PublicKey`, but we name them so that
|
||||||
|
writing them is a conscious act.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
impl SecretKey {
|
||||||
|
/// Export as a 64-character lowercase hex string.
|
||||||
|
///
|
||||||
|
/// This is an explicit opt-in: once the key leaves this method, the caller
|
||||||
|
/// is responsible for keeping it safe.
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
hex::encode(self.0.secret_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a 64-character hex string.
|
||||||
|
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
|
||||||
|
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(KeyError::InvalidHex);
|
||||||
|
}
|
||||||
|
let inner = secp256k1::SecretKey::from_slice(&bytes)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
Ok(SecretKey(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode as an `nsec1…` bech32 string per NIP-19.
|
||||||
|
pub fn to_nsec(&self) -> String {
|
||||||
|
bech32::encode::<Bech32>(NSEC_HRP, &self.0.secret_bytes())
|
||||||
|
.expect("nsec encoding cannot fail for 32 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `nsec1…` bech32 string.
|
||||||
|
pub fn from_nsec(s: &str) -> Result<Self, KeyError> {
|
||||||
|
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||||
|
if hrp != NSEC_HRP {
|
||||||
|
return Err(KeyError::WrongPrefix {
|
||||||
|
expected: "nsec",
|
||||||
|
found: hrp.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if data.len() != 32 {
|
||||||
|
return Err(KeyError::InvalidBech32);
|
||||||
|
}
|
||||||
|
let inner = secp256k1::SecretKey::from_slice(&data)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
Ok(SecretKey(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`from_slice` on `secp256k1::SecretKey` enforces that the scalar is in range, so any
|
||||||
|
bytes that decode successfully correspond to a valid key. That's our free curve
|
||||||
|
validation.
|
||||||
|
|
||||||
|
### Encrypted storage with NIP-49
|
||||||
|
|
||||||
|
A raw `nsec` sitting in a config file is a liability: whoever reads the file has the
|
||||||
|
identity. [NIP-49](https://github.com/nostr-protocol/nips/blob/master/49.md) defines a
|
||||||
|
password-based wrapper for secret keys called `ncryptsec`. The scheme takes the 32
|
||||||
|
secret bytes plus a user password, derives a symmetric key with scrypt, and encrypts
|
||||||
|
the secret with XChaCha20-Poly1305. The result is a single bech32 string that starts
|
||||||
|
with `ncryptsec1…` and is safe to persist to disk or sync between devices as long as
|
||||||
|
the password stays out of the same backup.
|
||||||
|
|
||||||
|
The payload before bech32 encoding is exactly 91 bytes:
|
||||||
|
|
||||||
|
| Offset | Length | Field |
|
||||||
|
| -----: | -----: | ------------------------------------------------------------ |
|
||||||
|
| 0 | 1 | version (always `0x02`) |
|
||||||
|
| 1 | 1 | scrypt `log_n` parameter |
|
||||||
|
| 2 | 16 | salt |
|
||||||
|
| 18 | 24 | XChaCha20-Poly1305 nonce |
|
||||||
|
| 42 | 1 | key-security byte, authenticated as associated data |
|
||||||
|
| 43 | 48 | ciphertext (32 bytes of key + 16-byte Poly1305 tag) |
|
||||||
|
|
||||||
|
Three small crates cover the primitives: `scrypt` for the KDF, `chacha20poly1305` for
|
||||||
|
the AEAD cipher, and `unicode-normalization` because NIP-49 requires the password to
|
||||||
|
be NFKC-normalized before being fed into scrypt (so users who type the same
|
||||||
|
characters on different keyboards derive the same key).
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
use chacha20poly1305::aead::{Aead, Payload};
|
||||||
|
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
const NCRYPTSEC_HRP: Hrp = Hrp::parse_unchecked("ncryptsec");
|
||||||
|
|
||||||
|
impl SecretKey {
|
||||||
|
/// Decrypt a NIP-49 `ncryptsec1…` string using the given password.
|
||||||
|
///
|
||||||
|
/// The password is NFKC-normalized before being passed to scrypt, per spec.
|
||||||
|
/// An incorrect password is indistinguishable from a corrupted ciphertext —
|
||||||
|
/// both surface as [`KeyError::DecryptionFailed`].
|
||||||
|
pub fn from_ncryptsec(s: &str, password: &str) -> Result<Self, KeyError> {
|
||||||
|
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||||
|
if hrp != NCRYPTSEC_HRP {
|
||||||
|
return Err(KeyError::WrongPrefix {
|
||||||
|
expected: "ncryptsec",
|
||||||
|
found: hrp.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if data.len() != 91 || data[0] != 0x02 {
|
||||||
|
return Err(KeyError::InvalidBech32);
|
||||||
|
}
|
||||||
|
|
||||||
|
let log_n = data[1];
|
||||||
|
let salt: [u8; 16] = data[2..18].try_into().unwrap();
|
||||||
|
let nonce_bytes: [u8; 24] = data[18..42].try_into().unwrap();
|
||||||
|
let security_byte = data[42];
|
||||||
|
let ciphertext = &data[43..91];
|
||||||
|
|
||||||
|
let password_nfkc: String = password.nfkc().collect();
|
||||||
|
let params = scrypt::Params::new(log_n, 8, 1, 32)
|
||||||
|
.map_err(|_| KeyError::InvalidBech32)?;
|
||||||
|
let mut derived = [0u8; 32];
|
||||||
|
scrypt::scrypt(password_nfkc.as_bytes(), &salt, ¶ms, &mut derived)
|
||||||
|
.map_err(|_| KeyError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
|
||||||
|
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(
|
||||||
|
nonce,
|
||||||
|
Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad: &[security_byte],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|_| KeyError::DecryptionFailed)?;
|
||||||
|
|
||||||
|
let inner = secp256k1::SecretKey::from_slice(&plaintext)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
Ok(SecretKey(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The security byte is passed as AEAD associated data, so Poly1305 authenticates it
|
||||||
|
alongside the ciphertext. If a storage layer flips that byte to mark a key as, say,
|
||||||
|
"now known to have touched a hot wallet," the authentication tag will reject the
|
||||||
|
change unless the payload is re-sealed.
|
||||||
|
|
||||||
|
The encryption side is the mirror image. The caller picks a `log_n` (NIP-49
|
||||||
|
recommends a value between 16 and 22, with larger values costing proportionally more
|
||||||
|
memory and CPU) and a security byte (`0x00` for "has been handled insecurely", `0x01`
|
||||||
|
for "has not", `0x02` for "unknown"). Salt and nonce are freshly drawn from the OS
|
||||||
|
RNG on every call, so encrypting the same key twice yields two different `ncryptsec`
|
||||||
|
strings — which is the point.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
impl SecretKey {
|
||||||
|
/// Encrypt this key under the given password, producing a NIP-49 `ncryptsec1…`
|
||||||
|
/// string.
|
||||||
|
///
|
||||||
|
/// `log_n` is the scrypt work factor (the spec recommends 16–22); `security_byte`
|
||||||
|
/// records how carefully the key has been handled (`0x00` insecure, `0x01` secure,
|
||||||
|
/// `0x02` unknown). The salt and nonce are sampled fresh from the OS RNG, so
|
||||||
|
/// calling this twice on the same key returns two different strings — both of
|
||||||
|
/// which decrypt to the same secret.
|
||||||
|
pub fn to_ncryptsec(
|
||||||
|
&self,
|
||||||
|
password: &str,
|
||||||
|
log_n: u8,
|
||||||
|
security_byte: u8,
|
||||||
|
) -> Result<String, KeyError> {
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
let mut salt = [0u8; 16];
|
||||||
|
let mut nonce_bytes = [0u8; 24];
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
rng.fill_bytes(&mut salt);
|
||||||
|
rng.fill_bytes(&mut nonce_bytes);
|
||||||
|
|
||||||
|
let password_nfkc: String = password.nfkc().collect();
|
||||||
|
let params = scrypt::Params::new(log_n, 8, 1, 32)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
let mut derived = [0u8; 32];
|
||||||
|
scrypt::scrypt(password_nfkc.as_bytes(), &salt, ¶ms, &mut derived)
|
||||||
|
.map_err(|_| KeyError::InvalidKey)?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
|
||||||
|
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(
|
||||||
|
nonce,
|
||||||
|
Payload {
|
||||||
|
msg: &self.0.secret_bytes(),
|
||||||
|
aad: &[security_byte],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("XChaCha20-Poly1305 encryption is infallible for fixed-size input");
|
||||||
|
|
||||||
|
let mut payload = Vec::with_capacity(91);
|
||||||
|
payload.push(0x02);
|
||||||
|
payload.push(log_n);
|
||||||
|
payload.extend_from_slice(&salt);
|
||||||
|
payload.extend_from_slice(&nonce_bytes);
|
||||||
|
payload.push(security_byte);
|
||||||
|
payload.extend_from_slice(&ciphertext);
|
||||||
|
|
||||||
|
Ok(bech32::encode::<Bech32>(NCRYPTSEC_HRP, &payload)
|
||||||
|
.expect("ncryptsec payload is always 91 bytes"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `FromStr`
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/keys.rs}
|
||||||
|
impl FromStr for SecretKey {
|
||||||
|
type Err = KeyError;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s.starts_with("nsec1") {
|
||||||
|
Self::from_nsec(s)
|
||||||
|
} else {
|
||||||
|
Self::from_hex(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape as `PublicKey::from_str`. A secret-key string pasted into a CLI can be
|
||||||
|
parsed without the caller knowing which encoding it's in.
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
We now have a safe, ergonomic representation of nostr identity: public keys that are
|
||||||
|
cheap to copy and print, secret keys that resist accidental exposure, and round-trip
|
||||||
|
encoders for both the machine format and the user-facing one. In the next chapter we'll
|
||||||
|
use these types to actually sign events — replacing the raw `secp256k1::SecretKey` that
|
||||||
|
`Event::new` currently takes with our new `SecretKey`, and introducing the `Signer`
|
||||||
|
abstraction that will later let us plug in remote, browser, and hardware signers.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Plan: Keys
|
||||||
|
|
||||||
|
## Topic Summary
|
||||||
|
|
||||||
|
Introduce nostr cryptographic identity via `PublicKey` and `SecretKey` — two
|
||||||
|
type-safe Rust wrappers around `secp256k1` that support hex and NIP-19
|
||||||
|
bech32 (`npub` / `nsec`) encoding, random generation, validation, and
|
||||||
|
`FromStr` auto-detection. The chapter should make it hard to accidentally
|
||||||
|
leak secret material: `SecretKey` has a redacted `Debug`, no `Display`, and
|
||||||
|
requires an explicit getter to expose its bytes.
|
||||||
|
|
||||||
|
## Chapter Outline
|
||||||
|
|
||||||
|
1. **Identity as a keypair.** One paragraph framing: in nostr, identity *is*
|
||||||
|
a secp256k1 keypair you generate yourself. The public key is your name;
|
||||||
|
the secret key is your ability to speak as that name.
|
||||||
|
2. **secp256k1, briefly.** Two or three sentences on why this curve and why
|
||||||
|
x-only / Schnorr keys (BIP-340). No cryptography lecture.
|
||||||
|
3. **The `PublicKey` type.** Newtype around `secp256k1::XOnlyPublicKey`,
|
||||||
|
with hex and npub encoding. `Display` shows hex. `FromStr` auto-detects.
|
||||||
|
4. **The `SecretKey` type.** Newtype around `secp256k1::SecretKey`. Redacted
|
||||||
|
`Debug`, no `Display`, explicit `.to_hex()` / `.to_nsec()`. `FromStr`
|
||||||
|
auto-detects. Random generation.
|
||||||
|
5. **Deriving a public key from a secret key.** `SecretKey::public_key()`.
|
||||||
|
6. **NIP-19: the `npub` / `nsec` bech32 envelope.** Short explanation of
|
||||||
|
what bech32 is doing for us and why the user-facing encoding is
|
||||||
|
different from the wire format.
|
||||||
|
7. **Tests.** Round-trip coverage for hex, bech32, `FromStr` auto-detection,
|
||||||
|
prefix validation, and debug redaction.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
In `coracle-lib/src/keys.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PublicKey(secp256k1::XOnlyPublicKey);
|
||||||
|
|
||||||
|
impl PublicKey {
|
||||||
|
pub fn from_hex(s: &str) -> Result<Self, KeyError>;
|
||||||
|
pub fn to_hex(&self) -> String;
|
||||||
|
pub fn from_npub(s: &str) -> Result<Self, KeyError>;
|
||||||
|
pub fn to_npub(&self) -> String;
|
||||||
|
pub fn as_bytes(&self) -> [u8; 32];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PublicKey { /* hex */ }
|
||||||
|
impl FromStr for PublicKey { /* npub1 → from_npub, else from_hex */ }
|
||||||
|
|
||||||
|
pub struct SecretKey(secp256k1::SecretKey);
|
||||||
|
|
||||||
|
impl SecretKey {
|
||||||
|
pub fn generate() -> Self;
|
||||||
|
pub fn from_hex(s: &str) -> Result<Self, KeyError>;
|
||||||
|
pub fn to_hex(&self) -> String; // explicit opt-in
|
||||||
|
pub fn from_nsec(s: &str) -> Result<Self, KeyError>;
|
||||||
|
pub fn to_nsec(&self) -> String; // explicit opt-in
|
||||||
|
pub fn public_key(&self) -> PublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretKey { /* "SecretKey(<redacted>)" */ }
|
||||||
|
// NO Display impl — callers must choose to_hex() or to_nsec()
|
||||||
|
impl FromStr for SecretKey { /* nsec1 → from_nsec, else from_hex */ }
|
||||||
|
|
||||||
|
pub enum KeyError {
|
||||||
|
InvalidHex,
|
||||||
|
InvalidBech32,
|
||||||
|
WrongBech32Prefix { expected: &'static str, found: String },
|
||||||
|
InvalidKey, // from secp256k1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
- All code lives in `coracle-lib/src/keys.rs`.
|
||||||
|
- `coracle-lib/src/lib.rs` re-exports the public types: `pub mod keys;` and
|
||||||
|
`pub use keys::{PublicKey, SecretKey, KeyError};`.
|
||||||
|
- No cross-crate coupling. Signing (which consumes `SecretKey`) is the next
|
||||||
|
chapter and will live in `coracle-signer`.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
In `coracle-lib/Cargo.toml`:
|
||||||
|
|
||||||
|
- `secp256k1 = { version = "0.29", features = ["global-context", "rand", "serde"] }`
|
||||||
|
— curve operations and a process-wide randomized context.
|
||||||
|
- `hex = "0.4"` — already present; used for hex encoding.
|
||||||
|
- `bech32 = "0.11"` — new; for NIP-19 envelopes. Use the `Bech32` variant
|
||||||
|
(NIP-19 uses the original bech32, not bech32m).
|
||||||
|
- `rand = "0.8"` — for `SecretKey::generate()`. secp256k1's `rand` feature
|
||||||
|
pulls this in.
|
||||||
|
|
||||||
|
## Narrative Notes
|
||||||
|
|
||||||
|
- Open with the philosophy: generating a keypair is the only step required
|
||||||
|
to "sign up" for nostr. No server, no email, no approval.
|
||||||
|
- Explain the split between hex (wire format, inside event JSON) and bech32
|
||||||
|
(human-facing, for display and copy-paste). Both encode the same 32
|
||||||
|
bytes.
|
||||||
|
- Justify why `SecretKey` has no `Display`: it's easy to accidentally
|
||||||
|
`println!("{}", key)` and leak credentials into logs. Making the caller
|
||||||
|
write `.to_hex()` or `.to_nsec()` forces a conscious decision.
|
||||||
|
- Justify the redacted `Debug`: `#[derive(Debug)]` on a struct containing a
|
||||||
|
`SecretKey` is common, and we don't want that to dump the key.
|
||||||
|
- Briefly mention that `secp256k1::SecretKey` zeroes its memory on drop so
|
||||||
|
we get that for free.
|
||||||
|
- The chapter should not discuss HD wallets, mnemonics, or encrypted key
|
||||||
|
storage — keep it about the protocol primitive.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Wrap `secp256k1` directly, don't depend on `bitcoin`.** Matches
|
||||||
|
rust-nostr and keeps the dependency graph small.
|
||||||
|
- **x-only public keys.** BIP-340 / Schnorr is the nostr standard; the
|
||||||
|
underlying type is `XOnlyPublicKey`.
|
||||||
|
- **No `Copy` on `SecretKey`.** Following rust-nostr's safety posture.
|
||||||
|
`PublicKey` is `Copy` (it's just 32 public bytes).
|
||||||
|
- **No `Display` on `SecretKey`, custom `Debug`.** The research flagged
|
||||||
|
that rust-nostr derives `Debug` on `SecretKey` and relies on the
|
||||||
|
composite `Keys` type to redact — which is a footgun. We'll redact at
|
||||||
|
the type where the material lives.
|
||||||
|
- **Errors as one enum.** A teaching resource benefits from a single
|
||||||
|
`KeyError` users can match on. We won't split into per-operation error
|
||||||
|
types.
|
||||||
|
- **Auto-detecting `FromStr`.** Matches every reference implementation and
|
||||||
|
makes round-tripping user input painless.
|
||||||
|
- **`bech32 = 0.11`.** Current version; rust-nostr uses the same.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking. If the `bech32` 0.11 API turns out to be awkward for a
|
||||||
|
teaching context, fall back to writing a small helper and explain it.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Research: Keys
|
||||||
|
|
||||||
|
## Topic Summary
|
||||||
|
|
||||||
|
The Keys chapter covers nostr cryptographic identity: `PublicKey` and `SecretKey` as
|
||||||
|
type-safe Rust wrappers around secp256k1. Includes hex encoding, NIP-19 bech32
|
||||||
|
encoding (`npub` / `nsec`), key generation, validation, `FromStr` auto-detection of
|
||||||
|
encoding variants, and safety measures that reduce accidental leakage of secret
|
||||||
|
material (redacted `Debug`, no `Display`, zeroization on drop).
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
From `ref/building-nostr`:
|
||||||
|
|
||||||
|
- **Self-sovereignty via cryptography.** Nostr inverts platform-mediated identity:
|
||||||
|
users unilaterally generate a secp256k1 keypair and become the authority over
|
||||||
|
their own identity. No custodian, no issuer.
|
||||||
|
- **Identity is subjective and relational.** Keys enable users to *act*; reputation
|
||||||
|
and context are layered on top via follow graphs, NIP-05, kind-0 profiles.
|
||||||
|
- **Publicity, not privacy.** Nostr is "publicity technology" — signed, verifiable,
|
||||||
|
permanent. Keys enable authenticity, not confidentiality.
|
||||||
|
- **Key handling is a big assumption.** Storage is deferred to signers (NIP-07
|
||||||
|
browser, NIP-46 remote, NIP-55 Android). A library should make it hard to
|
||||||
|
accidentally leak secret material.
|
||||||
|
- **Key rotation is unsolved.** Losing a secret key means losing social identity
|
||||||
|
permanently; this is a philosophical reason to take secret-key handling
|
||||||
|
seriously at the type-system level.
|
||||||
|
- **Zooko's triangle.** Hex/bech32 keys are secure and decentralized but not
|
||||||
|
human-meaningful. The raw-key layer should be correct and minimal; naming is
|
||||||
|
someone else's job.
|
||||||
|
|
||||||
|
Design implication: the `SecretKey` type should nudge users toward safety by
|
||||||
|
default (no `Display`, redacted `Debug`, explicit method to extract material).
|
||||||
|
|
||||||
|
## Reference Implementation Analysis
|
||||||
|
|
||||||
|
### applesauce (TypeScript)
|
||||||
|
|
||||||
|
- Secret keys: raw `Uint8Array` (32 bytes). Public keys: 64-char lowercase hex.
|
||||||
|
- No wrapper types — thin helpers (`normalizeToSecretKey`, `normalizeToPubkey`)
|
||||||
|
accept hex, nsec, or NIP-19 pointer and return primitives.
|
||||||
|
- Depends on `nostr-tools` for NIP-19, `@noble/secp256k1` underneath.
|
||||||
|
- No debug redaction; no memory zeroing. Locking a signer just nulls its
|
||||||
|
private field.
|
||||||
|
- `isHexKey()` validates format (length + charset), not curve membership.
|
||||||
|
|
||||||
|
### ndk (TypeScript)
|
||||||
|
|
||||||
|
- `Hexpubkey` and `Npub` are branded string type aliases; secret keys are
|
||||||
|
`Uint8Array` stored privately inside `NDKPrivateKeySigner`.
|
||||||
|
- Auto-detects `nsec1` prefix vs. 64-char hex on signer construction, throws
|
||||||
|
on invalid input.
|
||||||
|
- Delegates all NIP-19 work to `nostr-tools/nip19`; uses `@noble/hashes` for
|
||||||
|
hex↔bytes and `nostr-tools/nip49` for password-encrypted keys (`ncryptsec`).
|
||||||
|
- Keys live in private fields with controlled accessors; no accidental logging.
|
||||||
|
- Character-code loop for hex validation (avoids regex).
|
||||||
|
|
||||||
|
### nostr-gadgets (TypeScript)
|
||||||
|
|
||||||
|
- No dedicated key types at all. `pubkey` is `string` (hex), secrets are
|
||||||
|
passed as `Uint8Array` at call sites and not stored.
|
||||||
|
- `isHex32()` validates via `charCodeAt` against ASCII ranges.
|
||||||
|
- `bareNostrUser()` normalizes npub / nprofile / hex into both hex and npub
|
||||||
|
fields on demand.
|
||||||
|
- Zero key material coupling — cryptography is entirely outsourced to
|
||||||
|
`@nostr/tools`.
|
||||||
|
|
||||||
|
### nostr-tools (TypeScript)
|
||||||
|
|
||||||
|
- Minimal, explicit: `generateSecretKey() -> Uint8Array`, `getPublicKey(sk) -> hex`.
|
||||||
|
- `NPub`/`NSec` branded string types for bech32 forms.
|
||||||
|
- NIP-19 TLV encoding/decoding via `@scure/base` for nprofile/nevent/naddr.
|
||||||
|
- Dependencies: `@noble/curves` (schnorr), `@noble/hashes`, `@scure/base`.
|
||||||
|
Pure-JS, auditable, no OS bindings.
|
||||||
|
- Type guards via regex. `validateEvent()` rejects pubkeys that don't match
|
||||||
|
`^[a-f0-9]{64}$`.
|
||||||
|
- No zeroization — JS has no safe wipe primitive.
|
||||||
|
|
||||||
|
### rust-nostr (Rust — most directly relevant)
|
||||||
|
|
||||||
|
- `PublicKey`: 32-byte wrapper around `secp256k1::XOnlyPublicKey`. Implements
|
||||||
|
`Copy`.
|
||||||
|
- `SecretKey`: newtype around `secp256k1::SecretKey`, **not** `Copy`,
|
||||||
|
implements `Drop` via `non_secure_erase()` from secp256k1-rs.
|
||||||
|
- `Keys`: composite holding both plus `secp256k1::Keypair`. Custom `Debug`
|
||||||
|
impl that only shows the public key.
|
||||||
|
- `SecretKey` derives `Debug` directly, which the authors note is a footgun —
|
||||||
|
`Keys` compensates by overriding.
|
||||||
|
- `parse()` method on both types auto-detects hex vs. bech32 (and NIP-21
|
||||||
|
`nostr:` URIs on `PublicKey`). `FromStr` delegates to `parse()`.
|
||||||
|
- Dependencies: `secp256k1 = "0.29"`, `bech32 = "0.11"`, `faster-hex = "0.10"`,
|
||||||
|
`bitcoin_hashes`.
|
||||||
|
- Global `SECP256K1: LazyLock<Secp256k1<All>>` context, randomized via OsRng
|
||||||
|
at first access (blinding countermeasure).
|
||||||
|
- NIP-19 handled in a separate `nip19.rs` module; serde serializes
|
||||||
|
`PublicKey` as hex string.
|
||||||
|
|
||||||
|
### welshman (TypeScript)
|
||||||
|
|
||||||
|
- No wrapper types; keys are hex strings. `makeSecret()` / `getPubkey()` as
|
||||||
|
pure functions.
|
||||||
|
- `Pubkey` class wraps a pubkey hex with `.toNpub()` and `Pubkey.from(entity)`
|
||||||
|
that decodes npub/nprofile and validates with regex.
|
||||||
|
- `Nip01Signer` stores the secret in a private `#secret` field, exposing only
|
||||||
|
async signing operations.
|
||||||
|
- Depends on `nostr-tools` + `@noble/curves`.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
- **secp256k1 everywhere.** All references use secp256k1 with Schnorr-style
|
||||||
|
x-only public keys (BIP-340). This is fixed by the protocol.
|
||||||
|
- **Dual encoding.** Hex for internal storage and wire format (event JSON);
|
||||||
|
bech32 (NIP-19 `npub`/`nsec`) for user-facing display and copy-paste.
|
||||||
|
- **Auto-detect on parse.** Most libraries accept either form and figure out
|
||||||
|
which is which by prefix or length.
|
||||||
|
- **Secret material isolation.** The TypeScript libraries hide secrets in
|
||||||
|
private fields of a signer class. rust-nostr uses the type system: no
|
||||||
|
`Copy`, explicit `to_secret_hex()` getter, redacted `Debug` on the
|
||||||
|
composite, memory erase on `Drop`.
|
||||||
|
- **No curve-membership validation up front.** Most validate format only and
|
||||||
|
let the underlying crypto library reject invalid points lazily. rust-nostr
|
||||||
|
is stricter because `secp256k1::SecretKey::from_slice` rejects out-of-range
|
||||||
|
values on construction.
|
||||||
|
- **NIP-19 is a separate concern.** Every library has a distinct NIP-19
|
||||||
|
module; the key types depend on it for bech32 but the bech32 logic is not
|
||||||
|
inside the key type itself.
|
||||||
|
|
||||||
|
## Considerations for Our Implementation
|
||||||
|
|
||||||
|
**Crate choice.** The `secp256k1` crate (already in `coracle-lib/Cargo.toml`
|
||||||
|
per commit history) is the natural fit — widely audited, used by Bitcoin
|
||||||
|
Core bindings, same choice as rust-nostr. Its `SecretKey::from_slice`
|
||||||
|
enforces curve constraints. For bech32 we'll pull in `bech32 = "0.11"`. Hex
|
||||||
|
can come from the existing `hex` crate (already a dependency).
|
||||||
|
|
||||||
|
**Types.**
|
||||||
|
- `PublicKey` — newtype around `secp256k1::XOnlyPublicKey`. Implements
|
||||||
|
`Copy`, `Debug`, `Display` (hex), `FromStr`, `Serialize`, `Deserialize`.
|
||||||
|
- `SecretKey` — newtype around `secp256k1::SecretKey`. Does *not* implement
|
||||||
|
`Copy` or `Display`. Custom `Debug` that prints `SecretKey(<redacted>)`.
|
||||||
|
`FromStr` that auto-detects hex vs. `nsec1…`. Explicit `to_hex()` and
|
||||||
|
`to_nsec()` methods that opt into exposing the material.
|
||||||
|
|
||||||
|
**Encoding.**
|
||||||
|
- `PublicKey::to_hex()` / `to_npub()` and their `from_` counterparts.
|
||||||
|
- `SecretKey::to_hex()` / `to_nsec()` / `from_*`.
|
||||||
|
- `FromStr` auto-detects by checking `npub1`/`nsec1` prefix first, then
|
||||||
|
falling back to 64-char hex.
|
||||||
|
|
||||||
|
**Safety posture.** Match rust-nostr's conventions where they're sensible in
|
||||||
|
a teaching context, but don't hide secret-material extraction behind unsafe
|
||||||
|
or complicated APIs — the chapter is pedagogical. Redact `Debug`, omit
|
||||||
|
`Display`, require an explicit `.to_hex()` / `.to_nsec()` call.
|
||||||
|
|
||||||
|
**Out of scope for this chapter.**
|
||||||
|
- Signing and verification (next chapter).
|
||||||
|
- Key derivation, BIP-32, mnemonics (not part of the nostr protocol).
|
||||||
|
- NIP-49 encrypted key storage (`ncryptsec`).
|
||||||
|
- Remote / browser / Android signers (later chapters).
|
||||||
|
- `nprofile`, `nevent`, `naddr` TLV pointers (chapter on entities/content).
|
||||||
|
|
||||||
|
Keep the chapter narrow: identity as a keypair, how to encode it, how to
|
||||||
|
avoid leaking it.
|
||||||
@@ -8,5 +8,10 @@ description = "Struct definitions and stateless utilities related to nostr"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
secp256k1 = { version = "0.29", features = ["global-context", "serde"] }
|
secp256k1 = { version = "0.29", features = ["global-context", "serde", "rand"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
bech32 = "0.11"
|
||||||
|
rand = "0.8"
|
||||||
|
scrypt = { version = "0.11", default-features = false, features = ["std"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
use coracle_lib::keys::{KeyError, PublicKey, SecretKey};
|
||||||
|
|
||||||
|
fn fixed_secret() -> SecretKey {
|
||||||
|
let bytes: [u8; 32] = [
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||||
|
26, 27, 28, 29, 30, 31, 32,
|
||||||
|
];
|
||||||
|
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_key_hex_roundtrip() {
|
||||||
|
let pk = fixed_secret().public_key();
|
||||||
|
let hex = pk.to_hex();
|
||||||
|
assert_eq!(hex.len(), 64);
|
||||||
|
assert_eq!(PublicKey::from_hex(&hex).unwrap(), pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_key_npub_roundtrip() {
|
||||||
|
let pk = fixed_secret().public_key();
|
||||||
|
let npub = pk.to_npub();
|
||||||
|
assert!(npub.starts_with("npub1"));
|
||||||
|
assert_eq!(PublicKey::from_npub(&npub).unwrap(), pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_key_hex_roundtrip() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let parsed = SecretKey::from_hex(&sk.to_hex()).unwrap();
|
||||||
|
assert_eq!(sk.to_hex(), parsed.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_key_nsec_roundtrip() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let nsec = sk.to_nsec();
|
||||||
|
assert!(nsec.starts_with("nsec1"));
|
||||||
|
let parsed = SecretKey::from_nsec(&nsec).unwrap();
|
||||||
|
assert_eq!(sk.to_hex(), parsed.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_str_auto_detects() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let pk = sk.public_key();
|
||||||
|
|
||||||
|
let from_hex: PublicKey = pk.to_hex().parse().unwrap();
|
||||||
|
let from_npub: PublicKey = pk.to_npub().parse().unwrap();
|
||||||
|
assert_eq!(from_hex, pk);
|
||||||
|
assert_eq!(from_npub, pk);
|
||||||
|
|
||||||
|
let sk_from_hex: SecretKey = sk.to_hex().parse().unwrap();
|
||||||
|
let sk_from_nsec: SecretKey = sk.to_nsec().parse().unwrap();
|
||||||
|
assert_eq!(sk_from_hex.to_hex(), sk.to_hex());
|
||||||
|
assert_eq!(sk_from_nsec.to_hex(), sk.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_prefix_rejected() {
|
||||||
|
let npub = fixed_secret().public_key().to_npub();
|
||||||
|
match SecretKey::from_nsec(&npub) {
|
||||||
|
Err(KeyError::WrongPrefix { expected: "nsec", .. }) => (),
|
||||||
|
other => panic!("expected WrongPrefix error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_is_redacted() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let rendered = format!("{sk:?}");
|
||||||
|
assert_eq!(rendered, "SecretKey(<redacted>)");
|
||||||
|
assert!(!rendered.contains(&sk.to_hex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_produces_distinct_keys() {
|
||||||
|
let a = SecretKey::generate();
|
||||||
|
let b = SecretKey::generate();
|
||||||
|
assert_ne!(a.to_hex(), b.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test vector from NIP-49:
|
||||||
|
/// <https://github.com/nostr-protocol/nips/blob/master/49.md>
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_spec_vector() {
|
||||||
|
let ncryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||||
|
let expected = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683";
|
||||||
|
let sk = SecretKey::from_ncryptsec(ncryptsec, "nostr").unwrap();
|
||||||
|
assert_eq!(sk.to_hex(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_wrong_password_rejected() {
|
||||||
|
let ncryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||||
|
match SecretKey::from_ncryptsec(ncryptsec, "wrong-password") {
|
||||||
|
Err(KeyError::DecryptionFailed) => (),
|
||||||
|
other => panic!("expected DecryptionFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_wrong_prefix_rejected() {
|
||||||
|
let nsec = fixed_secret().to_nsec();
|
||||||
|
match SecretKey::from_ncryptsec(&nsec, "nostr") {
|
||||||
|
Err(KeyError::WrongPrefix { expected: "ncryptsec", .. }) => (),
|
||||||
|
other => panic!("expected WrongPrefix error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_roundtrip() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
// log_n = 16 is the low end of NIP-49's recommended range; picked here for
|
||||||
|
// test speed. security_byte = 0x01 means "has not been handled insecurely".
|
||||||
|
let ncryptsec = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||||
|
assert!(ncryptsec.starts_with("ncryptsec1"));
|
||||||
|
|
||||||
|
let decrypted = SecretKey::from_ncryptsec(&ncryptsec, "hunter2").unwrap();
|
||||||
|
assert_eq!(decrypted.to_hex(), sk.to_hex());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_roundtrip_is_nondeterministic() {
|
||||||
|
// Encrypting the same key twice must yield different strings, since salt
|
||||||
|
// and nonce are sampled fresh. Both must still decrypt to the same secret.
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let a = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||||
|
let b = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_eq!(
|
||||||
|
SecretKey::from_ncryptsec(&a, "hunter2").unwrap().to_hex(),
|
||||||
|
SecretKey::from_ncryptsec(&b, "hunter2").unwrap().to_hex(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ncryptsec_nfkc_normalizes_password() {
|
||||||
|
// The character "ñ" can be encoded either as U+00F1 (precomposed) or as
|
||||||
|
// U+006E U+0303 (n + combining tilde). NFKC collapses them to the same
|
||||||
|
// form, so both strings should unlock the same ncryptsec.
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let precomposed = "ma\u{00F1}ana";
|
||||||
|
let decomposed = "man\u{0303}ana";
|
||||||
|
let ncryptsec = sk.to_ncryptsec(precomposed, 16, 0x01).unwrap();
|
||||||
|
let decrypted = SecretKey::from_ncryptsec(&ncryptsec, decomposed).unwrap();
|
||||||
|
assert_eq!(decrypted.to_hex(), sk.to_hex());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user