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.
164 lines
7.8 KiB
Markdown
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.
|