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.
132 lines
5.4 KiB
Markdown
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.
|