Files
Jon Staab 5364854881 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.
2026-04-13 20:19:45 -07:00

132 lines
5.4 KiB
Markdown

# 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.