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.
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-toolsfor NIP-19,@noble/secp256k1underneath. - No debug redaction; no memory zeroing. Locking a signer just nulls its private field.
isHexKey()validates format (length + charset), not curve membership.
ndk (TypeScript)
HexpubkeyandNpubare branded string type aliases; secret keys areUint8Arraystored privately insideNDKPrivateKeySigner.- Auto-detects
nsec1prefix vs. 64-char hex on signer construction, throws on invalid input. - Delegates all NIP-19 work to
nostr-tools/nip19; uses@noble/hashesfor hex↔bytes andnostr-tools/nip49for 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.
pubkeyisstring(hex), secrets are passed asUint8Arrayat call sites and not stored. isHex32()validates viacharCodeAtagainst 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/NSecbranded string types for bech32 forms.- NIP-19 TLV encoding/decoding via
@scure/basefor 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 aroundsecp256k1::XOnlyPublicKey. ImplementsCopy.SecretKey: newtype aroundsecp256k1::SecretKey, notCopy, implementsDropvianon_secure_erase()from secp256k1-rs.Keys: composite holding both plussecp256k1::Keypair. CustomDebugimpl that only shows the public key.SecretKeyderivesDebugdirectly, which the authors note is a footgun —Keyscompensates by overriding.parse()method on both types auto-detects hex vs. bech32 (and NIP-21nostr:URIs onPublicKey).FromStrdelegates toparse().- 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.rsmodule; serde serializesPublicKeyas hex string.
welshman (TypeScript)
- No wrapper types; keys are hex strings.
makeSecret()/getPubkey()as pure functions. Pubkeyclass wraps a pubkey hex with.toNpub()andPubkey.from(entity)that decodes npub/nprofile and validates with regex.Nip01Signerstores the secret in a private#secretfield, 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, explicitto_secret_hex()getter, redactedDebugon the composite, memory erase onDrop. - 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_slicerejects 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 aroundsecp256k1::XOnlyPublicKey. ImplementsCopy,Debug,Display(hex),FromStr,Serialize,Deserialize.SecretKey— newtype aroundsecp256k1::SecretKey. Does not implementCopyorDisplay. CustomDebugthat printsSecretKey(<redacted>).FromStrthat auto-detects hex vs.nsec1…. Explicitto_hex()andto_nsec()methods that opt into exposing the material.
Encoding.
PublicKey::to_hex()/to_npub()and theirfrom_counterparts.SecretKey::to_hex()/to_nsec()/from_*.FromStrauto-detects by checkingnpub1/nsec1prefix 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,naddrTLV pointers (chapter on entities/content).
Keep the chapter narrow: identity as a keypair, how to encode it, how to avoid leaking it.