5364854881
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.
5.4 KiB
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
- 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.
- secp256k1, briefly. Two or three sentences on why this curve and why x-only / Schnorr keys (BIP-340). No cryptography lecture.
- The
PublicKeytype. Newtype aroundsecp256k1::XOnlyPublicKey, with hex and npub encoding.Displayshows hex.FromStrauto-detects. - The
SecretKeytype. Newtype aroundsecp256k1::SecretKey. RedactedDebug, noDisplay, explicit.to_hex()/.to_nsec().FromStrauto-detects. Random generation. - Deriving a public key from a secret key.
SecretKey::public_key(). - NIP-19: the
npub/nsecbech32 envelope. Short explanation of what bech32 is doing for us and why the user-facing encoding is different from the wire format. - Tests. Round-trip coverage for hex, bech32,
FromStrauto-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.rsre-exports the public types:pub mod keys;andpub use keys::{PublicKey, SecretKey, KeyError};.- No cross-crate coupling. Signing (which consumes
SecretKey) is the next chapter and will live incoracle-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 theBech32variant (NIP-19 uses the original bech32, not bech32m).rand = "0.8"— forSecretKey::generate(). secp256k1'srandfeature 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
SecretKeyhas noDisplay: it's easy to accidentallyprintln!("{}", 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 aSecretKeyis common, and we don't want that to dump the key. - Briefly mention that
secp256k1::SecretKeyzeroes 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
secp256k1directly, don't depend onbitcoin. 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
CopyonSecretKey. Following rust-nostr's safety posture.PublicKeyisCopy(it's just 32 public bytes). - No
DisplayonSecretKey, customDebug. The research flagged that rust-nostr derivesDebugonSecretKeyand relies on the compositeKeystype 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
KeyErrorusers 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
bech320.11 API turns out to be awkward for a teaching context, fall back to writing a small helper and explain it.