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:
Jon Staab
2026-04-13 15:16:32 -07:00
parent ce4dce9779
commit 5364854881
9 changed files with 1248 additions and 2 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ which are community-authored specifications.
## What this book covers
This book is both a tutorial and the source code for the `coracle` family of Rust crates:
This book is the source code for the `coracle` family of Rust crates:
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
serialization. Everything you need to understand and manipulate nostr data.
+517
View File
@@ -0,0 +1,517 @@
# Keys
In most of the internet, your identity is something you apply for. You fill out a form,
a server writes a row in a database, and from then on that server decides what your
identity is — whether you can still log in, what you're allowed to say, and whether you
still exist at all. Nostr inverts this. Your identity is a **secp256k1 keypair** that you
generate on your own computer. No server issues it, no server can revoke it, and no
server has to be consulted to verify that a message came from you.
The public half of that keypair is your name. Anywhere a nostr event says "this came
from `pubkey`," it means "this message has a valid Schnorr signature under that 32-byte
public key." The secret half is your ability to speak as that name. Whoever holds it can
publish events that the whole network will attribute to you — and whoever loses it
loses the identity, permanently.
This chapter introduces two Rust types, `PublicKey` and `SecretKey`, that make this
primitive safe to work with. We'll cover how to generate keys, how to encode them as
hex and as the user-facing `npub` / `nsec` bech32 strings defined by
[NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md), and the handful of
small design decisions that reduce the chances of accidentally leaking secret material.
## Why secp256k1
Nostr uses the same elliptic curve as Bitcoin: `secp256k1`. Public keys are **x-only**,
following [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) — we
store only the 32-byte x-coordinate of the curve point and drop the parity bit, which
gives us fixed-width public keys and lets signatures be Schnorr rather than ECDSA. The
upshot for this chapter is simple: both a public key and a secret key fit in exactly
32 bytes, and every nostr library in the world agrees on that.
We already pulled the `secp256k1` crate into `coracle-lib` in the previous chapter for
event signature verification. In this chapter we'll wrap its key types in our own so
that users of the library never have to think about the raw primitives.
## The module
Let's register the module in the crate root and start a new file for the key types:
```rust {file=coracle-lib/src/lib.rs}
pub mod keys;
```
```rust {file=coracle-lib/src/keys.rs}
//! Nostr cryptographic identity: `PublicKey` and `SecretKey`.
//!
//! Both types wrap the corresponding `secp256k1` primitive and add support for
//! hex and NIP-19 bech32 (`npub` / `nsec`) encoding. `SecretKey` is deliberately
//! awkward to print: it has no `Display` impl and its `Debug` is redacted, so
//! the material can only escape through an explicit `to_hex` or `to_nsec` call.
use std::fmt;
use std::str::FromStr;
use bech32::{Bech32, Hrp};
use secp256k1::{rand, SECP256K1};
```
## Errors
We'll use a single error type for everything in the module. One enum makes every way
a key operation can go wrong visible in one place, and lets callers match on a single
type instead of juggling a hierarchy.
```rust {file=coracle-lib/src/keys.rs}
/// Errors that can occur when parsing or validating a nostr key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyError {
/// The input was not valid hex, or not the right length.
InvalidHex,
/// The input was not a valid bech32 string.
InvalidBech32,
/// The bech32 human-readable prefix was not what we expected
/// (e.g. an `npub` passed where an `nsec` was required).
WrongPrefix { expected: &'static str, found: String },
/// The bytes decoded successfully but are not a valid key on the curve.
InvalidKey,
/// A NIP-49 ciphertext failed to authenticate. Either the password is
/// wrong or the payload has been corrupted — the two are indistinguishable.
DecryptionFailed,
}
impl fmt::Display for KeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyError::InvalidHex => write!(f, "invalid hex encoding"),
KeyError::InvalidBech32 => write!(f, "invalid bech32 encoding"),
KeyError::WrongPrefix { expected, found } => {
write!(f, "wrong bech32 prefix: expected {expected}, found {found}")
}
KeyError::InvalidKey => write!(f, "invalid secp256k1 key"),
KeyError::DecryptionFailed => write!(f, "ncryptsec decryption failed"),
}
}
}
impl std::error::Error for KeyError {}
```
## Public keys
A public key is a point on the secp256k1 curve, and because nostr uses x-only
representation it fits in 32 bytes. We wrap `secp256k1::XOnlyPublicKey` in a newtype so
that our own methods — hex encoding, bech32 encoding, parsing — hang off a type that
belongs to us rather than a foreign crate.
```rust {file=coracle-lib/src/keys.rs}
/// A nostr public key: the x-coordinate of a secp256k1 point, 32 bytes.
///
/// This is the "name" half of a nostr identity. It's safe to log, share, and
/// store — it identifies an author but grants no ability to speak as them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PublicKey(secp256k1::XOnlyPublicKey);
impl PublicKey {
/// The raw 32 bytes of the x-only public key.
pub fn as_bytes(&self) -> [u8; 32] {
self.0.serialize()
}
/// Encode as a lowercase 64-character hex string.
/// This is the form used inside event JSON.
pub fn to_hex(&self) -> String {
hex::encode(self.as_bytes())
}
/// Parse from a 64-character hex string.
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
if bytes.len() != 32 {
return Err(KeyError::InvalidHex);
}
let inner = secp256k1::XOnlyPublicKey::from_slice(&bytes)
.map_err(|_| KeyError::InvalidKey)?;
Ok(PublicKey(inner))
}
}
```
The hex form is what lives inside event JSON — the `"pubkey"` field we saw in the
previous chapter. It's machine-friendly but visually indistinguishable from any other
64 hex characters, which is why nostr also defines a user-facing encoding.
### NIP-19 and `npub`
NIP-19 wraps the raw 32 bytes in bech32, the same encoding Bitcoin uses for Segwit
addresses. Bech32 gives us two things: a **human-readable prefix** that tells you what
kind of data you're looking at (`npub` for a public key, `nsec` for a secret), and a
checksum that catches copy-paste errors. An `npub` string is what a user will copy out
of their nostr client, paste into a profile page, or share in a bio.
We use `bech32 = "0.11"`, which takes a prefix, a byte slice, and a variant. NIP-19
specifies the original bech32 (not bech32m), so we pass the `Bech32` marker.
```rust {file=coracle-lib/src/keys.rs}
const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub");
const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec");
impl PublicKey {
/// Encode as an `npub1…` bech32 string per NIP-19.
pub fn to_npub(&self) -> String {
bech32::encode::<Bech32>(NPUB_HRP, &self.as_bytes())
.expect("npub encoding cannot fail for 32 bytes")
}
/// Parse an `npub1…` bech32 string.
pub fn from_npub(s: &str) -> Result<Self, KeyError> {
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
if hrp != NPUB_HRP {
return Err(KeyError::WrongPrefix {
expected: "npub",
found: hrp.to_string(),
});
}
if data.len() != 32 {
return Err(KeyError::InvalidBech32);
}
let inner = secp256k1::XOnlyPublicKey::from_slice(&data)
.map_err(|_| KeyError::InvalidKey)?;
Ok(PublicKey(inner))
}
}
```
The HRP constants are parsed once at compile time via `Hrp::parse_unchecked`, which is
safe here because `"npub"` and `"nsec"` are valid bech32 prefixes by construction.
### `Display` and `FromStr`
It's natural to want to print a `PublicKey` and to parse one back. The question is:
which form? We pick hex for `Display` because that's what lives on the wire, and we
make `FromStr` accept either encoding by sniffing the prefix first.
```rust {file=coracle-lib/src/keys.rs}
impl fmt::Display for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl FromStr for PublicKey {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.starts_with("npub1") {
Self::from_npub(s)
} else {
Self::from_hex(s)
}
}
}
```
This small bit of polymorphism is worth the handful of lines. Every time a user pastes
a key into a config file or a CLI argument, you can call `.parse()` and get the right
thing back without asking them which encoding they used.
## Secret keys
Secret keys are where the safety posture matters. A `secp256k1::SecretKey` is a 32-byte
scalar that, together with the generator point, produces the corresponding public key.
If it leaks — into a log file, a crash report, a debug print — the identity is lost
forever. So our `SecretKey` type is designed to make leaking it *harder than keeping it
safe*, not the other way around.
Three rules:
1. **No `Display`.** If `println!("{}", key)` compiled, someone would do it.
2. **Redacted `Debug`.** `#[derive(Debug)]` on any struct that contains a `SecretKey`
is a very common thing to write. We don't want that to dump the key.
3. **No `Copy`.** Copying a secret key around makes it harder to reason about where
the material lives.
```rust {file=coracle-lib/src/keys.rs}
/// A nostr secret key: the 32-byte scalar that lets you sign as a given identity.
///
/// Handle with care. This type deliberately has no `Display` impl and its `Debug`
/// output is redacted. To extract the raw bytes you must call [`SecretKey::to_hex`]
/// or [`SecretKey::to_nsec`] explicitly, which makes the leak points easy to audit.
///
/// The underlying `secp256k1::SecretKey` zeroes its memory on drop, so dropping a
/// `SecretKey` does not leave the scalar sitting around in freed memory.
#[derive(Clone)]
pub struct SecretKey(secp256k1::SecretKey);
impl fmt::Debug for SecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretKey(<redacted>)")
}
}
```
Notice what's *not* there: no `impl Display`, no `#[derive(Debug)]`. A struct like
`struct Signer { sk: SecretKey, pk: PublicKey }` can still derive `Debug` in calling
code — it'll print `Signer { sk: SecretKey(<redacted>), pk: PublicKey(...) }`, which
is exactly what we want.
### Generating keys
To generate a fresh identity we need a source of randomness. `secp256k1` re-exports
`rand` when its `rand` feature is enabled, which gives us a process-wide-seeded
`OsRng` without us having to pick a random crate version by hand. The shared
`SECP256K1` context (also provided by `secp256k1`'s `global-context` feature) is
blinded at first access as a side-channel countermeasure.
```rust {file=coracle-lib/src/keys.rs}
impl SecretKey {
/// Generate a brand-new secret key from the operating system's RNG.
pub fn generate() -> Self {
let (sk, _pk) = SECP256K1.generate_keypair(&mut rand::thread_rng());
SecretKey(sk)
}
/// Derive the matching [`PublicKey`].
pub fn public_key(&self) -> PublicKey {
let keypair = secp256k1::Keypair::from_secret_key(SECP256K1, &self.0);
let (xonly, _parity) = keypair.x_only_public_key();
PublicKey(xonly)
}
}
```
`public_key()` is the one operation that always makes sense on a secret key: you need
to know what identity you're speaking as. Everything else — signing, NIP-44 encryption
— belongs in its own module and will consume a `&SecretKey` there.
### Encoding
The hex and bech32 methods mirror those on `PublicKey`, but we name them so that
writing them is a conscious act.
```rust {file=coracle-lib/src/keys.rs}
impl SecretKey {
/// Export as a 64-character lowercase hex string.
///
/// This is an explicit opt-in: once the key leaves this method, the caller
/// is responsible for keeping it safe.
pub fn to_hex(&self) -> String {
hex::encode(self.0.secret_bytes())
}
/// Parse from a 64-character hex string.
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
if bytes.len() != 32 {
return Err(KeyError::InvalidHex);
}
let inner = secp256k1::SecretKey::from_slice(&bytes)
.map_err(|_| KeyError::InvalidKey)?;
Ok(SecretKey(inner))
}
/// Encode as an `nsec1…` bech32 string per NIP-19.
pub fn to_nsec(&self) -> String {
bech32::encode::<Bech32>(NSEC_HRP, &self.0.secret_bytes())
.expect("nsec encoding cannot fail for 32 bytes")
}
/// Parse an `nsec1…` bech32 string.
pub fn from_nsec(s: &str) -> Result<Self, KeyError> {
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
if hrp != NSEC_HRP {
return Err(KeyError::WrongPrefix {
expected: "nsec",
found: hrp.to_string(),
});
}
if data.len() != 32 {
return Err(KeyError::InvalidBech32);
}
let inner = secp256k1::SecretKey::from_slice(&data)
.map_err(|_| KeyError::InvalidKey)?;
Ok(SecretKey(inner))
}
}
```
`from_slice` on `secp256k1::SecretKey` enforces that the scalar is in range, so any
bytes that decode successfully correspond to a valid key. That's our free curve
validation.
### Encrypted storage with NIP-49
A raw `nsec` sitting in a config file is a liability: whoever reads the file has the
identity. [NIP-49](https://github.com/nostr-protocol/nips/blob/master/49.md) defines a
password-based wrapper for secret keys called `ncryptsec`. The scheme takes the 32
secret bytes plus a user password, derives a symmetric key with scrypt, and encrypts
the secret with XChaCha20-Poly1305. The result is a single bech32 string that starts
with `ncryptsec1…` and is safe to persist to disk or sync between devices as long as
the password stays out of the same backup.
The payload before bech32 encoding is exactly 91 bytes:
| Offset | Length | Field |
| -----: | -----: | ------------------------------------------------------------ |
| 0 | 1 | version (always `0x02`) |
| 1 | 1 | scrypt `log_n` parameter |
| 2 | 16 | salt |
| 18 | 24 | XChaCha20-Poly1305 nonce |
| 42 | 1 | key-security byte, authenticated as associated data |
| 43 | 48 | ciphertext (32 bytes of key + 16-byte Poly1305 tag) |
Three small crates cover the primitives: `scrypt` for the KDF, `chacha20poly1305` for
the AEAD cipher, and `unicode-normalization` because NIP-49 requires the password to
be NFKC-normalized before being fed into scrypt (so users who type the same
characters on different keyboards derive the same key).
```rust {file=coracle-lib/src/keys.rs}
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
use unicode_normalization::UnicodeNormalization;
const NCRYPTSEC_HRP: Hrp = Hrp::parse_unchecked("ncryptsec");
impl SecretKey {
/// Decrypt a NIP-49 `ncryptsec1…` string using the given password.
///
/// The password is NFKC-normalized before being passed to scrypt, per spec.
/// An incorrect password is indistinguishable from a corrupted ciphertext —
/// both surface as [`KeyError::DecryptionFailed`].
pub fn from_ncryptsec(s: &str, password: &str) -> Result<Self, KeyError> {
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
if hrp != NCRYPTSEC_HRP {
return Err(KeyError::WrongPrefix {
expected: "ncryptsec",
found: hrp.to_string(),
});
}
if data.len() != 91 || data[0] != 0x02 {
return Err(KeyError::InvalidBech32);
}
let log_n = data[1];
let salt: [u8; 16] = data[2..18].try_into().unwrap();
let nonce_bytes: [u8; 24] = data[18..42].try_into().unwrap();
let security_byte = data[42];
let ciphertext = &data[43..91];
let password_nfkc: String = password.nfkc().collect();
let params = scrypt::Params::new(log_n, 8, 1, 32)
.map_err(|_| KeyError::InvalidBech32)?;
let mut derived = [0u8; 32];
scrypt::scrypt(password_nfkc.as_bytes(), &salt, &params, &mut derived)
.map_err(|_| KeyError::DecryptionFailed)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
let nonce = XNonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: &[security_byte],
},
)
.map_err(|_| KeyError::DecryptionFailed)?;
let inner = secp256k1::SecretKey::from_slice(&plaintext)
.map_err(|_| KeyError::InvalidKey)?;
Ok(SecretKey(inner))
}
}
```
The security byte is passed as AEAD associated data, so Poly1305 authenticates it
alongside the ciphertext. If a storage layer flips that byte to mark a key as, say,
"now known to have touched a hot wallet," the authentication tag will reject the
change unless the payload is re-sealed.
The encryption side is the mirror image. The caller picks a `log_n` (NIP-49
recommends a value between 16 and 22, with larger values costing proportionally more
memory and CPU) and a security byte (`0x00` for "has been handled insecurely", `0x01`
for "has not", `0x02` for "unknown"). Salt and nonce are freshly drawn from the OS
RNG on every call, so encrypting the same key twice yields two different `ncryptsec`
strings — which is the point.
```rust {file=coracle-lib/src/keys.rs}
impl SecretKey {
/// Encrypt this key under the given password, producing a NIP-49 `ncryptsec1…`
/// string.
///
/// `log_n` is the scrypt work factor (the spec recommends 1622); `security_byte`
/// records how carefully the key has been handled (`0x00` insecure, `0x01` secure,
/// `0x02` unknown). The salt and nonce are sampled fresh from the OS RNG, so
/// calling this twice on the same key returns two different strings — both of
/// which decrypt to the same secret.
pub fn to_ncryptsec(
&self,
password: &str,
log_n: u8,
security_byte: u8,
) -> Result<String, KeyError> {
use rand::RngCore;
let mut salt = [0u8; 16];
let mut nonce_bytes = [0u8; 24];
let mut rng = rand::thread_rng();
rng.fill_bytes(&mut salt);
rng.fill_bytes(&mut nonce_bytes);
let password_nfkc: String = password.nfkc().collect();
let params = scrypt::Params::new(log_n, 8, 1, 32)
.map_err(|_| KeyError::InvalidKey)?;
let mut derived = [0u8; 32];
scrypt::scrypt(password_nfkc.as_bytes(), &salt, &params, &mut derived)
.map_err(|_| KeyError::InvalidKey)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
let nonce = XNonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: &self.0.secret_bytes(),
aad: &[security_byte],
},
)
.expect("XChaCha20-Poly1305 encryption is infallible for fixed-size input");
let mut payload = Vec::with_capacity(91);
payload.push(0x02);
payload.push(log_n);
payload.extend_from_slice(&salt);
payload.extend_from_slice(&nonce_bytes);
payload.push(security_byte);
payload.extend_from_slice(&ciphertext);
Ok(bech32::encode::<Bech32>(NCRYPTSEC_HRP, &payload)
.expect("ncryptsec payload is always 91 bytes"))
}
}
```
### `FromStr`
```rust {file=coracle-lib/src/keys.rs}
impl FromStr for SecretKey {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.starts_with("nsec1") {
Self::from_nsec(s)
} else {
Self::from_hex(s)
}
}
}
```
Same shape as `PublicKey::from_str`. A secret-key string pasted into a CLI can be
parsed without the caller knowing which encoding it's in.
## What's next
We now have a safe, ergonomic representation of nostr identity: public keys that are
cheap to copy and print, secret keys that resist accidental exposure, and round-trip
encoders for both the machine format and the user-facing one. In the next chapter we'll
use these types to actually sign events — replacing the raw `secp256k1::SecretKey` that
`Event::new` currently takes with our new `SecretKey`, and introducing the `Signer`
abstraction that will later let us plug in remote, browser, and hardware signers.
+131
View File
@@ -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.
+163
View File
@@ -0,0 +1,163 @@
# Research: Keys
## Topic Summary
The Keys chapter covers nostr cryptographic identity: `PublicKey` and `SecretKey` as
type-safe Rust wrappers around secp256k1. Includes hex encoding, NIP-19 bech32
encoding (`npub` / `nsec`), key generation, validation, `FromStr` auto-detection of
encoding variants, and safety measures that reduce accidental leakage of secret
material (redacted `Debug`, no `Display`, zeroization on drop).
## Philosophy
From `ref/building-nostr`:
- **Self-sovereignty via cryptography.** Nostr inverts platform-mediated identity:
users unilaterally generate a secp256k1 keypair and become the authority over
their own identity. No custodian, no issuer.
- **Identity is subjective and relational.** Keys enable users to *act*; reputation
and context are layered on top via follow graphs, NIP-05, kind-0 profiles.
- **Publicity, not privacy.** Nostr is "publicity technology" — signed, verifiable,
permanent. Keys enable authenticity, not confidentiality.
- **Key handling is a big assumption.** Storage is deferred to signers (NIP-07
browser, NIP-46 remote, NIP-55 Android). A library should make it hard to
accidentally leak secret material.
- **Key rotation is unsolved.** Losing a secret key means losing social identity
permanently; this is a philosophical reason to take secret-key handling
seriously at the type-system level.
- **Zooko's triangle.** Hex/bech32 keys are secure and decentralized but not
human-meaningful. The raw-key layer should be correct and minimal; naming is
someone else's job.
Design implication: the `SecretKey` type should nudge users toward safety by
default (no `Display`, redacted `Debug`, explicit method to extract material).
## Reference Implementation Analysis
### applesauce (TypeScript)
- Secret keys: raw `Uint8Array` (32 bytes). Public keys: 64-char lowercase hex.
- No wrapper types — thin helpers (`normalizeToSecretKey`, `normalizeToPubkey`)
accept hex, nsec, or NIP-19 pointer and return primitives.
- Depends on `nostr-tools` for NIP-19, `@noble/secp256k1` underneath.
- No debug redaction; no memory zeroing. Locking a signer just nulls its
private field.
- `isHexKey()` validates format (length + charset), not curve membership.
### ndk (TypeScript)
- `Hexpubkey` and `Npub` are branded string type aliases; secret keys are
`Uint8Array` stored privately inside `NDKPrivateKeySigner`.
- Auto-detects `nsec1` prefix vs. 64-char hex on signer construction, throws
on invalid input.
- Delegates all NIP-19 work to `nostr-tools/nip19`; uses `@noble/hashes` for
hex↔bytes and `nostr-tools/nip49` for password-encrypted keys (`ncryptsec`).
- Keys live in private fields with controlled accessors; no accidental logging.
- Character-code loop for hex validation (avoids regex).
### nostr-gadgets (TypeScript)
- No dedicated key types at all. `pubkey` is `string` (hex), secrets are
passed as `Uint8Array` at call sites and not stored.
- `isHex32()` validates via `charCodeAt` against ASCII ranges.
- `bareNostrUser()` normalizes npub / nprofile / hex into both hex and npub
fields on demand.
- Zero key material coupling — cryptography is entirely outsourced to
`@nostr/tools`.
### nostr-tools (TypeScript)
- Minimal, explicit: `generateSecretKey() -> Uint8Array`, `getPublicKey(sk) -> hex`.
- `NPub`/`NSec` branded string types for bech32 forms.
- NIP-19 TLV encoding/decoding via `@scure/base` for nprofile/nevent/naddr.
- Dependencies: `@noble/curves` (schnorr), `@noble/hashes`, `@scure/base`.
Pure-JS, auditable, no OS bindings.
- Type guards via regex. `validateEvent()` rejects pubkeys that don't match
`^[a-f0-9]{64}$`.
- No zeroization — JS has no safe wipe primitive.
### rust-nostr (Rust — most directly relevant)
- `PublicKey`: 32-byte wrapper around `secp256k1::XOnlyPublicKey`. Implements
`Copy`.
- `SecretKey`: newtype around `secp256k1::SecretKey`, **not** `Copy`,
implements `Drop` via `non_secure_erase()` from secp256k1-rs.
- `Keys`: composite holding both plus `secp256k1::Keypair`. Custom `Debug`
impl that only shows the public key.
- `SecretKey` derives `Debug` directly, which the authors note is a footgun —
`Keys` compensates by overriding.
- `parse()` method on both types auto-detects hex vs. bech32 (and NIP-21
`nostr:` URIs on `PublicKey`). `FromStr` delegates to `parse()`.
- Dependencies: `secp256k1 = "0.29"`, `bech32 = "0.11"`, `faster-hex = "0.10"`,
`bitcoin_hashes`.
- Global `SECP256K1: LazyLock<Secp256k1<All>>` context, randomized via OsRng
at first access (blinding countermeasure).
- NIP-19 handled in a separate `nip19.rs` module; serde serializes
`PublicKey` as hex string.
### welshman (TypeScript)
- No wrapper types; keys are hex strings. `makeSecret()` / `getPubkey()` as
pure functions.
- `Pubkey` class wraps a pubkey hex with `.toNpub()` and `Pubkey.from(entity)`
that decodes npub/nprofile and validates with regex.
- `Nip01Signer` stores the secret in a private `#secret` field, exposing only
async signing operations.
- Depends on `nostr-tools` + `@noble/curves`.
## Common Patterns
- **secp256k1 everywhere.** All references use secp256k1 with Schnorr-style
x-only public keys (BIP-340). This is fixed by the protocol.
- **Dual encoding.** Hex for internal storage and wire format (event JSON);
bech32 (NIP-19 `npub`/`nsec`) for user-facing display and copy-paste.
- **Auto-detect on parse.** Most libraries accept either form and figure out
which is which by prefix or length.
- **Secret material isolation.** The TypeScript libraries hide secrets in
private fields of a signer class. rust-nostr uses the type system: no
`Copy`, explicit `to_secret_hex()` getter, redacted `Debug` on the
composite, memory erase on `Drop`.
- **No curve-membership validation up front.** Most validate format only and
let the underlying crypto library reject invalid points lazily. rust-nostr
is stricter because `secp256k1::SecretKey::from_slice` rejects out-of-range
values on construction.
- **NIP-19 is a separate concern.** Every library has a distinct NIP-19
module; the key types depend on it for bech32 but the bech32 logic is not
inside the key type itself.
## Considerations for Our Implementation
**Crate choice.** The `secp256k1` crate (already in `coracle-lib/Cargo.toml`
per commit history) is the natural fit — widely audited, used by Bitcoin
Core bindings, same choice as rust-nostr. Its `SecretKey::from_slice`
enforces curve constraints. For bech32 we'll pull in `bech32 = "0.11"`. Hex
can come from the existing `hex` crate (already a dependency).
**Types.**
- `PublicKey` — newtype around `secp256k1::XOnlyPublicKey`. Implements
`Copy`, `Debug`, `Display` (hex), `FromStr`, `Serialize`, `Deserialize`.
- `SecretKey` — newtype around `secp256k1::SecretKey`. Does *not* implement
`Copy` or `Display`. Custom `Debug` that prints `SecretKey(<redacted>)`.
`FromStr` that auto-detects hex vs. `nsec1…`. Explicit `to_hex()` and
`to_nsec()` methods that opt into exposing the material.
**Encoding.**
- `PublicKey::to_hex()` / `to_npub()` and their `from_` counterparts.
- `SecretKey::to_hex()` / `to_nsec()` / `from_*`.
- `FromStr` auto-detects by checking `npub1`/`nsec1` prefix first, then
falling back to 64-char hex.
**Safety posture.** Match rust-nostr's conventions where they're sensible in
a teaching context, but don't hide secret-material extraction behind unsafe
or complicated APIs — the chapter is pedagogical. Redact `Debug`, omit
`Display`, require an explicit `.to_hex()` / `.to_nsec()` call.
**Out of scope for this chapter.**
- Signing and verification (next chapter).
- Key derivation, BIP-32, mnemonics (not part of the nostr protocol).
- NIP-49 encrypted key storage (`ncryptsec`).
- Remote / browser / Android signers (later chapters).
- `nprofile`, `nevent`, `naddr` TLV pointers (chapter on entities/content).
Keep the chapter narrow: identity as a keypair, how to encode it, how to
avoid leaking it.