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

164 lines
7.8 KiB
Markdown

# 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.