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:
@@ -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.
|
||||
Reference in New Issue
Block a user