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

5.4 KiB

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:

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.