From 536485488141dd4eb375d4787ac3d48be9383fec Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 13 Apr 2026 15:16:32 -0700 Subject: [PATCH] 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. --- .agents/skills/write-chapter/SKILL.md | 11 + Cargo.lock | 271 ++++++++++++++ book/01-introduction.md | 2 +- book/02-keys.md | 517 ++++++++++++++++++++++++++ book/{02-events.md => 06-events.md} | 0 book/plan/keys.md | 131 +++++++ book/research/keys.md | 163 ++++++++ coracle-lib/Cargo.toml | 7 +- coracle-lib/tests/keys.rs | 148 ++++++++ 9 files changed, 1248 insertions(+), 2 deletions(-) create mode 100644 book/02-keys.md rename book/{02-events.md => 06-events.md} (100%) create mode 100644 book/plan/keys.md create mode 100644 book/research/keys.md create mode 100644 coracle-lib/tests/keys.rs diff --git a/.agents/skills/write-chapter/SKILL.md b/.agents/skills/write-chapter/SKILL.md index f65ccca..e32f743 100644 --- a/.agents/skills/write-chapter/SKILL.md +++ b/.agents/skills/write-chapter/SKILL.md @@ -26,6 +26,10 @@ Also read: 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 narrative, not dumped in bulk. - **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. - Keep code blocks focused — one concept per block where possible. - 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/.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 ` alongside `just all` to verify. - Update `./book/SUMMARY.md` if adding a new chapter. - Update any `Cargo.toml` files if new dependencies are needed. diff --git a/Cargo.lock b/Cargo.lock index 5c63a3d..3db07b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # It is not intended for manual editing. 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]] name = "bitflags" version = "2.11.0" @@ -33,6 +55,41 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "coracle-content" version = "0.1.0" @@ -40,15 +97,27 @@ dependencies = [ "coracle-lib", ] +[[package]] +name = "coracle-domain" +version = "0.1.0" +dependencies = [ + "coracle-lib", +] + [[package]] name = "coracle-lib" version = "0.1.0" dependencies = [ + "bech32", + "chacha20poly1305", "hex", + "rand", + "scrypt", "secp256k1", "serde", "serde_json", "sha2", + "unicode-normalization", ] [[package]] @@ -95,6 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -106,6 +176,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -133,12 +204,41 @@ dependencies = [ "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]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "itoa" version = "1.0.18" @@ -157,6 +257,53 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro2" version = "1.0.106" @@ -194,12 +341,64 @@ dependencies = [ "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]] name = "secp256k1" version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ + "rand", "secp256k1-sys", "serde", ] @@ -273,6 +472,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -284,6 +489,21 @@ dependencies = [ "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]] name = "typenum" version = "1.19.0" @@ -302,18 +522,69 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zmij" version = "1.0.21" diff --git a/book/01-introduction.md b/book/01-introduction.md index 897f7a4..996d2f4 100644 --- a/book/01-introduction.md +++ b/book/01-introduction.md @@ -13,7 +13,7 @@ which are community-authored specifications. ## 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 serialization. Everything you need to understand and manipulate nostr data. diff --git a/book/02-keys.md b/book/02-keys.md new file mode 100644 index 0000000..31986f4 --- /dev/null +++ b/book/02-keys.md @@ -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 { + 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::(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 { + 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 { + 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()") + } +} +``` + +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(), 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 { + 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::(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 { + 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 { + 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 { + 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::(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 { + 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. diff --git a/book/02-events.md b/book/06-events.md similarity index 100% rename from book/02-events.md rename to book/06-events.md diff --git a/book/plan/keys.md b/book/plan/keys.md new file mode 100644 index 0000000..a822e30 --- /dev/null +++ b/book/plan/keys.md @@ -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; + pub fn to_hex(&self) -> String; + pub fn from_npub(s: &str) -> Result; + 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; + pub fn to_hex(&self) -> String; // explicit opt-in + pub fn from_nsec(s: &str) -> Result; + pub fn to_nsec(&self) -> String; // explicit opt-in + pub fn public_key(&self) -> PublicKey; +} + +impl fmt::Debug for SecretKey { /* "SecretKey()" */ } +// 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. diff --git a/book/research/keys.md b/book/research/keys.md new file mode 100644 index 0000000..a7fa848 --- /dev/null +++ b/book/research/keys.md @@ -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>` 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()`. + `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. diff --git a/coracle-lib/Cargo.toml b/coracle-lib/Cargo.toml index 5ec95e1..3c9ad9a 100644 --- a/coracle-lib/Cargo.toml +++ b/coracle-lib/Cargo.toml @@ -8,5 +8,10 @@ description = "Struct definitions and stateless utilities related to nostr" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -secp256k1 = { version = "0.29", features = ["global-context", "serde"] } +secp256k1 = { version = "0.29", features = ["global-context", "serde", "rand"] } 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" diff --git a/coracle-lib/tests/keys.rs b/coracle-lib/tests/keys.rs new file mode 100644 index 0000000..ec6ba11 --- /dev/null +++ b/coracle-lib/tests/keys.rs @@ -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()"); + 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: +/// +#[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()); +}