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

21 KiB
Raw Permalink Blame History

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, 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 — 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:

pub mod keys;
//! 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.

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

/// 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, 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<Self, KeyError> {
        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))
    }
}

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.

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::<Bech32>(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<Self, KeyError> {
        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.

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<Self, Self::Err> {
        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.
/// 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(<redacted>)")
    }
}

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(<redacted>), 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.

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.

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<Self, KeyError> {
        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::<Bech32>(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<Self, KeyError> {
        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 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).

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<Self, KeyError> {
        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, &params, &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.

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 1622); `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<String, KeyError> {
        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, &params, &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::<Bech32>(NCRYPTSEC_HRP, &payload)
            .expect("ncryptsec payload is always 91 bytes"))
    }
}

FromStr

impl FromStr for SecretKey {
    type Err = KeyError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        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.