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

7.8 KiB

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.