# 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, PartialOrd, Ord, 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 { 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)) } /// Parse from raw bytes (exactly 32). pub fn from_bytes(bytes: &[u8]) -> Result { 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::(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 { 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 { 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()") } } ``` 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(), 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 { 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::(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 { 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 { 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, ¶ms, &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 16–22); `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 { 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, ¶ms, &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::(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 { 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.