From 2f334fa3be5e5c6ed6e648ccb20da93d59430820 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 13 Apr 2026 21:21:52 -0700 Subject: [PATCH] Add encryption chapter --- Cargo.lock | 19 + book/03-encryption.md | 548 ++++++++++++++++++++++++++++ book/{06-events.md => 04-events.md} | 0 book/SUMMARY.md | 54 +-- book/plan/03-encryption.md | 223 +++++++++++ book/research/03-encryption.md | 325 +++++++++++++++++ coracle-lib/Cargo.toml | 4 + coracle-lib/tests/encryption.rs | 113 ++++++ 8 files changed, 1259 insertions(+), 27 deletions(-) create mode 100644 book/03-encryption.md rename book/{06-events.md => 04-events.md} (100%) create mode 100644 book/plan/03-encryption.md create mode 100644 book/research/03-encryption.md create mode 100644 coracle-lib/tests/encryption.rs diff --git a/Cargo.lock b/Cargo.lock index 3db07b9..55a2aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -108,9 +114,13 @@ dependencies = [ name = "coracle-lib" version = "0.1.0" dependencies = [ + "base64", "bech32", + "chacha20", "chacha20poly1305", "hex", + "hkdf", + "hmac", "rand", "scrypt", "secp256k1", @@ -221,6 +231,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" diff --git a/book/03-encryption.md b/book/03-encryption.md new file mode 100644 index 0000000..1bb209c --- /dev/null +++ b/book/03-encryption.md @@ -0,0 +1,548 @@ +# Encryption + +Nostr is publicity technology. Every event a client publishes is broadcast, in the +clear, to every relay it talks to, and from there to anyone who cares to subscribe. +That's a feature — it's what makes nostr resilient — but it means that two people +who want a private conversation have to arrange for privacy themselves, on top of a +medium that was designed to have none. + +An older format, +[NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md), is still in +use in some places on the network — unauthenticated AES-256-CBC over the raw ECDH +x-coordinate, with the IV appended to a base64 blob. It has a failure mode that +NIP-44 does not (a corrupted ciphertext is indistinguishable from a wrong key), +and it leaks plaintext length. It has been deprecated, and so this library does not +implement it. + +The modern standard for doing that is +[NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) v2: it uses +authenticated encryption, pads messages to hide their length, and versions itself +so the scheme can evolve without breaking old ciphertexts. It's what this chapter +covers and what new code should reach for. + +## The module + +```rust {file=coracle-lib/src/lib.rs} +pub mod encryption; +``` + +```rust {file=coracle-lib/src/encryption.rs} +//! NIP-44 v2 encryption for nostr events. +//! +//! Starts from an ECDH shared secret between a sender's +//! [`SecretKey`](crate::keys::SecretKey) and a recipient's +//! [`PublicKey`](crate::keys::PublicKey), feeds it through HKDF to derive a +//! reusable [`nip44::ConversationKey`], and then derives per-message keys from +//! there. + +use std::fmt; + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use chacha20::ChaCha20; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +use crate::keys::{PublicKey, SecretKey}; +``` + +## Errors + +Encryption has a handful of distinct failure modes that a caller might +reasonably want to distinguish: a corrupted payload, a MAC mismatch, a message +that decoded to non-UTF-8 bytes. One enum covers them all. + +```rust {file=coracle-lib/src/encryption.rs} +/// Errors that can occur when encrypting or decrypting a nostr payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EncryptionError { + /// The payload was not well-formed: bad base64, wrong length, missing + /// separator, or similar structural issues. + InvalidPayload, + /// The NIP-44 version byte was not one this library understands. + UnsupportedVersion(u8), + /// The NIP-44 HMAC did not match the ciphertext — either the payload was + /// tampered with or the wrong conversation key was used. + InvalidMac, + /// The NIP-44 padding was malformed: a bad length prefix or non-zero + /// padding bytes. + InvalidPadding, + /// The plaintext was empty. NIP-44 forbids zero-length messages. + MessageEmpty, + /// The plaintext exceeded NIP-44's 65535-byte limit. + MessageTooLong, + /// The decrypted bytes were not valid UTF-8. + InvalidUtf8, +} + +impl fmt::Display for EncryptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EncryptionError::InvalidPayload => write!(f, "invalid encrypted payload"), + EncryptionError::UnsupportedVersion(v) => { + write!(f, "unsupported NIP-44 version byte: {v:#04x}") + } + EncryptionError::InvalidMac => write!(f, "HMAC verification failed"), + EncryptionError::InvalidPadding => write!(f, "invalid NIP-44 padding"), + EncryptionError::MessageEmpty => write!(f, "message is empty"), + EncryptionError::MessageTooLong => write!(f, "message exceeds 65535 bytes"), + EncryptionError::InvalidUtf8 => write!(f, "decrypted bytes are not valid UTF-8"), + } + } +} + +impl std::error::Error for EncryptionError {} +``` + +## ECDH + +Given my secret key and your public key, I multiply them on the secp256k1 curve +to get a point that only the two of us can compute — you can reach the same +point with your secret key and my public key, because `a·G·b == b·G·a`. That +point, reduced to its 32-byte x-coordinate, is the shared secret NIP-44 builds +on. + +There are two ways to spell this with the `secp256k1` crate. `SharedSecret::new` +returns a SHA-256 hash of the x-coordinate, which is a sensible default for +general-purpose ECDH — but it is *not* what NIP-44 specifies. We want the raw +x-coordinate, so we reach for `shared_secret_point`, which returns the full +64-byte point, and take the first 32 bytes. + +There's one wrinkle: our [`PublicKey`](crate::keys::PublicKey) is x-only, but +`shared_secret_point` wants a full `secp256k1::PublicKey` with a parity byte. We +pick the even-parity lift — prepend `0x02` to the 32-byte x-coordinate — which +matches every other nostr implementation. ECDH only cares about the +x-coordinate of the resulting point, so the choice of input parity doesn't +affect the output. + +```rust {file=coracle-lib/src/encryption.rs} +/// Compute the raw 32-byte ECDH shared secret between two nostr keys. +/// +/// This is the x-coordinate of `sk · pk` on secp256k1. It is *not* hashed; +/// NIP-44 feeds it through HKDF before using it as key material. +pub fn shared_secret(sk: &SecretKey, pk: &PublicKey) -> [u8; 32] { + // Lift the x-only pubkey to a full secp256k1 point by prepending the + // even-parity byte. The ECDH output's x-coordinate is independent of the + // chosen parity, so this always matches what the counterparty computes. + let mut lifted = [0u8; 33]; + lifted[0] = 0x02; + lifted[1..].copy_from_slice(&pk.as_bytes()); + let full_pk = secp256k1::PublicKey::from_slice(&lifted) + .expect("even-parity lift of a valid x-only key is always a valid point"); + + let point = secp256k1::ecdh::shared_secret_point(&full_pk, sk.as_secp256k1()); + let mut out = [0u8; 32]; + out.copy_from_slice(&point[..32]); + out +} +``` + +The call to `sk.as_secp256k1()` is a small accessor we need to add to +`SecretKey`. It returns a reference to the wrapped `secp256k1::SecretKey` without +handing the bytes out in any form that's easier to misuse than what's already +available. + +```rust {file=coracle-lib/src/keys.rs} +impl SecretKey { + /// Borrow the wrapped `secp256k1::SecretKey` for use with lower-level APIs. + /// + /// Exposed so that other modules in this crate (signing, encryption) can + /// feed the key into `secp256k1` primitives without an extra copy. + pub(crate) fn as_secp256k1(&self) -> &secp256k1::SecretKey { + &self.0 + } +} +``` + +## NIP-44 v2 + +NIP-44 v2 encrypts with ChaCha20, authenticates with HMAC-SHA256, and pads the +plaintext to a fixed set of sizes so length analysis can't tell a one-word +reply from a short paragraph. It layers HKDF on top of the ECDH shared secret +twice: once to derive a **conversation key** from the key pair, and once per +message to derive fresh ChaCha20 and HMAC keys from that conversation key and a +random nonce. + +### The wire format + +A NIP-44 v2 payload is the base64 encoding of this byte sequence: + +| Offset | Length | Field | +| -----: | ------: | --------------------------------------------------- | +| 0 | 1 | version byte, always `0x02` for v2 | +| 1 | 32 | nonce (random, also feeds per-message key derivation) | +| 33 | ≥ | ciphertext (padded plaintext) | +| — | 32 | HMAC-SHA256 over nonce ‖ ciphertext | + +The nonce is transmitted unencrypted and authenticated by the HMAC. The HMAC +covers both the nonce and the ciphertext, so flipping either breaks +verification. + +### `ConversationKey` + +The expensive part of NIP-44 is the ECDH plus the first HKDF extract. It +depends only on the key pair, so it can be computed once and reused for every +message in a conversation. We expose it as a dedicated type. + +```rust {file=coracle-lib/src/encryption.rs} +pub mod nip44 { + //! NIP-44 v2 authenticated encryption. + //! + //! The hot path is: + //! + //! 1. Derive a [`ConversationKey`] from an `(sk, pk)` pair — this runs + //! ECDH plus HKDF-Extract and is the expensive step. + //! 2. For each message, call [`ConversationKey::encrypt`] / + //! [`ConversationKey::decrypt`], which runs HKDF-Expand with a fresh + //! random nonce, encrypts with ChaCha20, and signs with HMAC-SHA256. + //! + //! The free [`encrypt`] and [`decrypt`] functions are one-shot helpers + //! that derive a conversation key, use it once, and throw it away. + + use super::*; + + const VERSION: u8 = 0x02; + const MIN_PLAINTEXT_LEN: usize = 1; + const MAX_PLAINTEXT_LEN: usize = 65535; + const NONCE_LEN: usize = 32; + const MAC_LEN: usize = 32; + const HKDF_SALT: &[u8] = b"nip44-v2"; + + type HmacSha256 = Hmac; +``` + +HKDF-Extract with salt `"nip44-v2"` and the ECDH output as input keying +material is — by definition — HMAC-SHA256 over the shared secret, keyed by the +salt. We could spell it out as a direct HMAC call, but using the `hkdf` crate's +extract step makes the intent explicit and keeps the two KDF stages adjacent in +the code. + +```rust {file=coracle-lib/src/encryption.rs} + /// A reusable NIP-44 conversation key. + /// + /// Derived once per `(secret key, public key)` pair. Callers that send + /// or receive many messages with the same counterparty should hold one + /// of these and call [`encrypt`](Self::encrypt) / [`decrypt`](Self::decrypt) + /// directly instead of going through the free [`encrypt`] / [`decrypt`] + /// helpers, which re-run ECDH on every call. + #[derive(Clone)] + pub struct ConversationKey([u8; 32]); + + impl ConversationKey { + /// Derive a conversation key from a sender's secret key and a + /// recipient's public key. + pub fn derive(sk: &SecretKey, pk: &PublicKey) -> Self { + let shared = shared_secret(sk, pk); + let (prk, _) = Hkdf::::extract(Some(HKDF_SALT), &shared); + let mut out = [0u8; 32]; + out.copy_from_slice(&prk); + ConversationKey(out) + } + + /// Borrow the raw 32 conversation-key bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for ConversationKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ConversationKey()") + } + } +``` + +A conversation key is almost as sensitive as a secret key — anyone who holds it +can decrypt every message in the conversation, in either direction. We apply +the same redacted-`Debug` treatment we used for `SecretKey` in the previous +chapter. We do not provide a `Display` impl for the same reason. + +### Per-message key derivation + +Every call to `encrypt` picks a fresh 32-byte random nonce and feeds it, along +with the conversation key, into HKDF-Expand. The output is 76 bytes, sliced into +three parts: a 32-byte ChaCha20 key, a 12-byte ChaCha20 nonce, and a 32-byte +HMAC key. The spec calls this the *message keys*. + +```rust {file=coracle-lib/src/encryption.rs} + struct MessageKeys { + chacha_key: [u8; 32], + chacha_nonce: [u8; 12], + hmac_key: [u8; 32], + } + + impl MessageKeys { + fn derive(ck: &ConversationKey, nonce: &[u8; NONCE_LEN]) -> Self { + let hkdf = Hkdf::::from_prk(&ck.0) + .expect("conversation key has correct length"); + let mut okm = [0u8; 76]; + hkdf.expand(nonce, &mut okm) + .expect("76 bytes is within HKDF-Expand limits"); + + let mut chacha_key = [0u8; 32]; + let mut chacha_nonce = [0u8; 12]; + let mut hmac_key = [0u8; 32]; + chacha_key.copy_from_slice(&okm[..32]); + chacha_nonce.copy_from_slice(&okm[32..44]); + hmac_key.copy_from_slice(&okm[44..76]); + + MessageKeys { chacha_key, chacha_nonce, hmac_key } + } + } +``` + +`from_prk` is the HKDF entry point that skips the extract step, treating its +input as a pseudorandom key already. That's what we want here — the conversation +key is the extract output, so running it through extract again would be wrong. + +### Padding + +Length is one of the easiest things for a passive observer to see even when +content is encrypted. NIP-44 addresses it by padding every plaintext to one of +a small number of allowed sizes: 32 bytes for short messages, then doubling, then +chunks of the next power of two divided by eight for longer messages. The full +definition from the spec: + +| Plaintext length | Padded length | +| ---------------: | ------------: | +| 1 | 32 | +| 32 | 32 | +| 33 | 64 | +| 100 | 128 | +| 320 | 384 | +| 1000 | 1024 | + +Before padding, we prepend a 2-byte big-endian length prefix so that +decryption can recover the original length. The padded plaintext then looks +like `[len_hi, len_lo, plaintext..., zeros...]`. + +```rust {file=coracle-lib/src/encryption.rs} + /// Compute the padded length for a plaintext of `unpadded` bytes, per the + /// NIP-44 padding scheme. + fn padded_len(unpadded: usize) -> usize { + if unpadded <= 32 { + return 32; + } + // Next power of two strictly greater than (unpadded - 1). + let next_power = 1usize << (usize::BITS - (unpadded - 1).leading_zeros()); + let chunk = if next_power <= 256 { 32 } else { next_power / 8 }; + chunk * ((unpadded - 1) / chunk + 1) + } + + fn pad(plaintext: &[u8]) -> Result, EncryptionError> { + let len = plaintext.len(); + if len < MIN_PLAINTEXT_LEN { + return Err(EncryptionError::MessageEmpty); + } + if len > MAX_PLAINTEXT_LEN { + return Err(EncryptionError::MessageTooLong); + } + let padded_len = padded_len(len); + let mut out = Vec::with_capacity(2 + padded_len); + out.extend_from_slice(&(len as u16).to_be_bytes()); + out.extend_from_slice(plaintext); + out.resize(2 + padded_len, 0); + Ok(out) + } + + fn unpad(padded: &[u8]) -> Result, EncryptionError> { + if padded.len() < 2 { + return Err(EncryptionError::InvalidPadding); + } + let len = u16::from_be_bytes([padded[0], padded[1]]) as usize; + if len < MIN_PLAINTEXT_LEN || 2 + len > padded.len() { + return Err(EncryptionError::InvalidPadding); + } + // The stated length must correspond to the padded length we would have + // produced for it — otherwise the sender is lying about the real size. + if padded.len() - 2 != padded_len(len) { + return Err(EncryptionError::InvalidPadding); + } + Ok(padded[2..2 + len].to_vec()) + } +``` + +The `unpad` check that `padded.len() - 2 == padded_len(len)` closes a subtle +attack: without it, a crafter could claim a shorter length than the plaintext +actually is and use the difference to smuggle data. Insisting that the padded +size be exactly what we would have produced means the length prefix is +authenticated against the padding scheme itself. + +### Encrypt and decrypt + +With the key derivation, padding, and cipher all in place, the actual encrypt +and decrypt functions are mostly plumbing. + +```rust {file=coracle-lib/src/encryption.rs} + impl ConversationKey { + /// Encrypt `plaintext` under this conversation key with a freshly + /// random 32-byte nonce. + pub fn encrypt(&self, plaintext: &str) -> Result { + let mut nonce = [0u8; NONCE_LEN]; + rand::thread_rng().fill_bytes(&mut nonce); + self.encrypt_with_nonce(plaintext, &nonce) + } + + /// Encrypt with a caller-supplied nonce. + /// + /// Mainly useful for verifying against the official NIP-44 test + /// vectors, which fix the nonce to produce a deterministic output. + /// Production code should use [`encrypt`](Self::encrypt) so that + /// every message gets a freshly random nonce. + pub fn encrypt_with_nonce( + &self, + plaintext: &str, + nonce: &[u8; NONCE_LEN], + ) -> Result { + let padded = pad(plaintext.as_bytes())?; + let keys = MessageKeys::derive(self, nonce); + + let mut ciphertext = padded; + ChaCha20::new(&keys.chacha_key.into(), &keys.chacha_nonce.into()) + .apply_keystream(&mut ciphertext); + + let mut mac = ::new_from_slice(&keys.hmac_key) + .expect("HMAC accepts any key length"); + mac.update(nonce); + mac.update(&ciphertext); + let tag = mac.finalize().into_bytes(); + + let mut payload = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len() + MAC_LEN); + payload.push(VERSION); + payload.extend_from_slice(nonce); + payload.extend_from_slice(&ciphertext); + payload.extend_from_slice(&tag); + Ok(BASE64.encode(&payload)) + } + + /// Decrypt a NIP-44 v2 payload previously encrypted under this + /// conversation key. + pub fn decrypt(&self, payload: &str) -> Result { + let bytes = BASE64 + .decode(payload) + .map_err(|_| EncryptionError::InvalidPayload)?; + // Smallest valid payload: version + nonce + min-padded + mac. + let min_len = 1 + NONCE_LEN + 2 + 32 + MAC_LEN; + if bytes.len() < min_len { + return Err(EncryptionError::InvalidPayload); + } + if bytes[0] != VERSION { + return Err(EncryptionError::UnsupportedVersion(bytes[0])); + } + + let nonce: &[u8; NONCE_LEN] = bytes[1..1 + NONCE_LEN] + .try_into() + .expect("slice length checked above"); + let ct_end = bytes.len() - MAC_LEN; + let ciphertext = &bytes[1 + NONCE_LEN..ct_end]; + let mac_bytes = &bytes[ct_end..]; + + let keys = MessageKeys::derive(self, nonce); + + let mut mac = ::new_from_slice(&keys.hmac_key) + .expect("HMAC accepts any key length"); + mac.update(nonce); + mac.update(ciphertext); + mac.verify_slice(mac_bytes) + .map_err(|_| EncryptionError::InvalidMac)?; + + let mut plaintext = ciphertext.to_vec(); + ChaCha20::new(&keys.chacha_key.into(), &keys.chacha_nonce.into()) + .apply_keystream(&mut plaintext); + + let unpadded = unpad(&plaintext)?; + String::from_utf8(unpadded).map_err(|_| EncryptionError::InvalidUtf8) + } + } +``` + +Two points about `decrypt` are worth lingering on. First, the HMAC is verified +*before* the ciphertext is decrypted — a tampered message is rejected without +ever being fed through ChaCha20. Second, the MAC check uses `verify_slice`, +which the `hmac` crate implements in constant time; a hand-rolled byte-by-byte +comparison would leak timing information about how many bytes matched. + +One spec-level requirement sits above this module: when a NIP-44 payload +travels inside a signed event, the spec says the outer event signature MUST be +verified before the payload is decrypted. The signature authenticates the full +event — author, timestamp, tags, and the encrypted content together — and +skipping it means trusting a ciphertext whose provenance hasn't been checked. +That check belongs to the event and signer layers, not to this module, but +callers are on the hook for doing it. + +### One-shot helpers + +Most of the time callers want to encrypt or decrypt a single message without +thinking about conversation keys at all. Free functions wrap the derivation +step for them. + +```rust {file=coracle-lib/src/encryption.rs} + /// Encrypt `plaintext` to `pk` with a freshly derived conversation key. + /// + /// Re-runs ECDH and HKDF-Extract on every call. Callers sending many + /// messages to the same recipient should hold a [`ConversationKey`] + /// directly. + pub fn encrypt( + sk: &SecretKey, + pk: &PublicKey, + plaintext: &str, + ) -> Result { + ConversationKey::derive(sk, pk).encrypt(plaintext) + } + + /// Decrypt a NIP-44 v2 payload from `pk` with a freshly derived + /// conversation key. + pub fn decrypt( + sk: &SecretKey, + pk: &PublicKey, + payload: &str, + ) -> Result { + ConversationKey::derive(sk, pk).decrypt(payload) + } +} +``` + +## Methods on `SecretKey` + +The free functions above are the primitives. For the common case — "encrypt +this string to that pubkey" — it's nicer to write `sk.nip44_encrypt(pk, msg)` +than to reach into a module. We hang two convenience methods off `SecretKey` +that just forward to the free functions. + +```rust {file=coracle-lib/src/encryption.rs} +impl SecretKey { + /// Encrypt `plaintext` to `pk` using NIP-44 v2. + pub fn nip44_encrypt( + &self, + pk: &PublicKey, + plaintext: &str, + ) -> Result { + nip44::encrypt(self, pk, plaintext) + } + + /// Decrypt a NIP-44 v2 payload from `pk`. + pub fn nip44_decrypt( + &self, + pk: &PublicKey, + payload: &str, + ) -> Result { + nip44::decrypt(self, pk, payload) + } +} +``` + +These live in `encryption.rs` rather than `keys.rs` because they belong to this +chapter's story, not the previous one. Rust doesn't mind: `impl` blocks can be +added to a type from any module in the crate that defines it. + +## What's next + +The two methods above cover the common path: encrypt a string to a pubkey, +decrypt one back. Hiding *who* is talking to whom is a separate problem that +we'll tackle when we get to gift wrap (NIP-59). Before that, there are signers +to build: the encryption primitives here take a `&SecretKey` directly, but most +real applications want to delegate that to a browser extension, a remote +bunker, or a hardware device without ever touching the raw bytes. diff --git a/book/06-events.md b/book/04-events.md similarity index 100% rename from book/06-events.md rename to book/04-events.md diff --git a/book/SUMMARY.md b/book/SUMMARY.md index b193797..a11c7b4 100644 --- a/book/SUMMARY.md +++ b/book/SUMMARY.md @@ -5,42 +5,42 @@ ## Basics - [Keys](02-keys.md) -- [Signing](03-signing.md) -- [Encryption](04-encryption.md) -- [Encryptables](05-encryptables.md) -- [Events](06-events.md) -- [Tags](07-tags.md) -- [Kinds](08-kinds.md) -- [Kind Ranges](09-kind-ranges.md) -- [Addresses](10-addresses.md) -- [Proof of Work](11-proof-of-work.md) -- [Filters](12-filters.md) +- [Encryption](03-encryption.md) +- [Events](04-events.md) +- [Signing](05-signing.md) +- [Tags](06-tags.md) +- [Kinds](07-kinds.md) +- [Kind Ranges](08-kind-ranges.md) +- [Addresses](09-addresses.md) +- [Proof of Work](10-proof-of-work.md) +- [Filters](11-filters.md) ## Domain -- [Relay Selections](13-relay-selections.md) -- [Relay Metadata](14-relay-metadata.md) -- [Relay Membership](15-relay-membership.md) -- [Profiles](16-profiles.md) -- [Follows](17-follows.md) -- [Microblogging](18-microblogging.md) -- [Reactions](19-reactions.md) -- [Reports](20-reports.md) -- [Emojis](21-emojis.md) -- [Zaps](22-zaps.md) -- [Rooms](23-rooms.md) +- [Relay Selections](12-relay-selections.md) +- [Relay Metadata](13-relay-metadata.md) +- [Relay Membership](14-relay-membership.md) +- [Profiles](15-profiles.md) +- [Follows](16-follows.md) +- [Microblogging](17-microblogging.md) +- [Reactions](18-reactions.md) +- [Reports](19-reports.md) +- [Emojis](20-emojis.md) +- [Zaps](21-zaps.md) +- [Rooms](22-rooms.md) ## Networking -- [Relay Connections](24-relay-connections.md) -- [Relay Authentication](25-relay-authentication.md) -- [Relay Policies](26-relay-policies.md) -- [Server Authentication](27-server-authentication.md) -- [Relay Management API](28-relay-management-api.md) -- [Blossom Media Storage](29-blossom-media-storage.md) +- [Relay Connections](23-relay-connections.md) +- [Relay Authentication](24-relay-authentication.md) +- [Relay Policies](25-relay-policies.md) +- [Server Authentication](26-server-authentication.md) +- [Relay Management API](27-relay-management-api.md) +- [Blossom Media Storage](28-blossom-media-storage.md) ## Signers +- [Signer Interface](29-signer-interface.md) - [Secret Signers](30-secret-signers.md) - [Remote Signers](31-remote-signers.md) - [Android Signers](32-android-signers.md) diff --git a/book/plan/03-encryption.md b/book/plan/03-encryption.md new file mode 100644 index 0000000..f375d98 --- /dev/null +++ b/book/plan/03-encryption.md @@ -0,0 +1,223 @@ +# Plan: 03-encryption + +## Topic Summary + +Covers nostr encryption primitives: ECDH shared key derivation over secp256k1, NIP-04 +(legacy AES-256-CBC), and NIP-44 v2 (ChaCha20 + HKDF + HMAC-SHA256). Introduces a +`ConversationKey` type for reusable NIP-44 shared state and hangs convenience +`nip04_*`/`nip44_*` methods off `SecretKey`. Builds directly on the `keys` chapter — +this is the first chapter where `PublicKey` and `SecretKey` do something interesting +with each other. + +## Chapter Outline + +1. **Framing.** Nostr is publicity tech first; encryption is opt-in. Two live + standards: NIP-04 (deprecated, ubiquitous) and NIP-44 v2 (modern, recommended). + Why both exist, what each is good for, and the metadata caveat (encrypted + content hides content, not routing — gift wrap is a later chapter). + +2. **ECDH: the shared foundation.** Both NIPs start from the same step: an ECDH + shared secret between my secret key and your public key. Explain the operation, + why we take the x-coordinate of the shared point, and how it depends only on + the two keys (so both parties independently derive the same 32 bytes). Ship + `shared_secret(sk, pk) -> [u8; 32]` as the public primitive. + +3. **NIP-04.** Smaller, deprecated, but still the most common DM format on the + network. AES-256-CBC with a random 16-byte IV, key = raw ECDH x-coordinate, + payload = `base64(ct) ?iv= base64(iv)`. Show encrypt and decrypt. + +4. **NIP-44 v2.** The modern scheme. + - Motivation: authenticated encryption, forward-compatible versioning, + length-hiding padding, no reliance on CBC. + - Message format on the wire: `[version=2][nonce(32)][ciphertext][hmac(32)]`, + base64-encoded. + - `ConversationKey`: derive once per `(sk, pk)` pair with + `HKDF-Extract(salt="nip44-v2", ikm=shared_secret)`. Reusable across messages. + - Per-message keys: `HKDF-Expand(conversation_key, info=nonce, len=76)` split + into (ChaCha20 key 32, ChaCha20 nonce 12, HMAC key 32). + - Padding: 2-byte big-endian length prefix, zero-pad to a power-of-2 chunk. + Show the padding function with a small table of examples. + - Encrypt pipeline, decrypt pipeline, MAC verification (constant-time). + - Size and length constraints (1..=65535 plaintext bytes). + +5. **Wiring onto `SecretKey`.** Convenience methods: `nip04_encrypt`/`_decrypt`, + `nip44_encrypt`/`_decrypt`. These just call into the module-level functions and + discard the conversation key. Note that callers doing many messages to one + recipient should hold a `ConversationKey` directly. + +6. **Tests.** A round-trip test for each scheme and at least one official NIP-44 + test vector for confidence. + +7. **What's next.** Gift wrap (NIP-59) and signer-side encryption will come later; + the free functions here are the load-bearing primitives. + +## API Design + +All in `coracle-lib/src/encryption.rs`: + +```rust +pub enum EncryptionError { + InvalidPayload, // base64 / size / format + UnsupportedVersion, // NIP-44 version byte not recognized + InvalidMac, // NIP-44 HMAC mismatch + InvalidPadding, // NIP-44 length prefix / padding bytes wrong + MessageEmpty, // zero-length plaintext + MessageTooLong, // > 65535 bytes + InvalidUtf8, // decrypted bytes not valid UTF-8 + Crypto, // underlying primitive failure (should be unreachable) +} +impl Display + Error for EncryptionError + +pub fn shared_secret(sk: &SecretKey, pk: &PublicKey) -> [u8; 32]; + +pub mod nip04 { + pub fn encrypt(sk: &SecretKey, pk: &PublicKey, plaintext: &str) -> String; + pub fn decrypt(sk: &SecretKey, pk: &PublicKey, payload: &str) + -> Result; +} + +pub mod nip44 { + pub struct ConversationKey([u8; 32]); + impl ConversationKey { + pub fn derive(sk: &SecretKey, pk: &PublicKey) -> Self; + pub fn encrypt(&self, plaintext: &str) -> Result; + pub fn decrypt(&self, payload: &str) -> Result; + } + pub fn encrypt(sk: &SecretKey, pk: &PublicKey, plaintext: &str) + -> Result; + pub fn decrypt(sk: &SecretKey, pk: &PublicKey, payload: &str) + -> Result; +} +``` + +Added to `SecretKey` in `keys.rs` (or inside `encryption.rs` via `impl SecretKey`): + +```rust +impl SecretKey { + pub fn nip04_encrypt(&self, pk: &PublicKey, msg: &str) -> String; + pub fn nip04_decrypt(&self, pk: &PublicKey, payload: &str) + -> Result; + pub fn nip44_encrypt(&self, pk: &PublicKey, msg: &str) + -> Result; + pub fn nip44_decrypt(&self, pk: &PublicKey, payload: &str) + -> Result; +} +``` + +Place these in the encryption chapter file (cross-crate method additions via `impl` +block in a different file), so the narrative stays together. + +## Code Organization + +- New module `coracle-lib/src/encryption.rs` containing `EncryptionError`, + `shared_secret`, and `nip04`/`nip44` submodules. +- `pub mod encryption;` added to `coracle-lib/src/lib.rs`. +- The `impl SecretKey { nip04_*, nip44_* }` block lives in the encryption chapter + as well (tangles into a new file like `coracle-lib/src/keys_encryption_ext.rs`, or + simply back into `keys.rs` via a fresh `{file=coracle-lib/src/keys.rs}` block + *after* the keys chapter's blocks — tangle concatenates in document order across + the whole book). Pick the latter: cleaner file layout, and SUMMARY.md order + guarantees `02-keys.md` is tangled before `03-encryption.md`. +- Tests in `coracle-lib/tests/encryption.rs` (round-trip + one official vector). + +## Dependencies + +Add to `coracle-lib/Cargo.toml`: + +- `aes = "0.8"` — AES-256 block cipher for NIP-04 +- `cbc = "0.1"` — CBC mode wrapper +- `chacha20 = "0.9"` — raw ChaCha20 stream cipher for NIP-44 (note: `chacha20poly1305` + is already a dep for NIP-49, but that's the AEAD; NIP-44 uses unauthenticated + stream ChaCha20 with a separate HMAC) +- `hmac = "0.12"` — HMAC-SHA256 +- `hkdf = "0.12"` — HKDF extract/expand +- `base64 = "0.22"` — payload encoding + +`sha2` and `secp256k1` are already present. `secp256k1` 0.29 exposes +`secp256k1::ecdh::shared_secret_point(&PublicKey, &SecretKey) -> [u8; 64]` which is +what we need (not `SharedSecret::new`, which SHA-256-hashes the result). + +For the `shared_secret_point` call we need a full `secp256k1::PublicKey` (not x-only). +Reconstruct one from the stored x-only key by prepending `0x02` (even parity). This +matches nostr-tools and rust-nostr. + +## Narrative Notes + +- **Open the chapter with a sober framing**. Don't pitch encryption as privacy; + pitch it as content-hiding with known metadata leaks. Briefly mention NIP-17/gift + wrap as the thing that will fix metadata, deferred to a later chapter. +- **Tell ECDH as a story**: "we both do the same math and end up with the same 32 + bytes no one else can reproduce." Avoid jargon like "Diffie-Hellman key agreement" + in the first sentence; introduce the name after the concept. +- **Motivate x-coordinate extraction**: a curve point is (x, y); both parties + compute the same point, so taking either coordinate works, and by convention + everybody takes x. This is a 30-second paragraph, not a math lecture. +- **NIP-04 is short; treat it as a warm-up**. The code is 20-ish lines per direction + and gives the reader something concrete before NIP-44's complexity. +- **For NIP-44, show the wire format first** (a table or diagram), then walk through + encrypt and decrypt step by step. Readers retain the pipeline better when they + can see the output shape before the transformations. +- **Show a padding table**: `1 → 32, 32 → 32, 33 → 64, 100 → 128, 1000 → 1024` — this + makes the algorithm click faster than a prose description of power-of-2 chunking. +- **Emphasize `ConversationKey` reuse**: for chat-like workloads the ECDH+HKDF-Extract + step is the expensive part, and it only depends on the key pair. The one-shot + `nip44::encrypt` helper is for convenience, not the default. +- **Call out constant-time HMAC verification**. Use `hmac`'s `Mac::verify_slice` or + the RustCrypto `subtle::ConstantTimeEq` — don't hand-roll a comparison. +- **Do not mention gift wrap (NIP-59), NIP-49, NIP-46, or signer abstractions + beyond a forward reference**. Scope creep. + +## Design Decisions + +1. **Module lives in `coracle-lib`**, next to `keys`. Rationale: NIP-04/44 are + stateless primitives, no async, no relay coupling. Signer-based delegation (the + applesauce/welshman pattern) belongs in `coracle-signer` — a later chapter. + +2. **Free functions + first-class `ConversationKey` type.** This mirrors rust-nostr. + Free functions read well in examples; the type enables efficient reuse. Welshman + caches conversation keys in an LRU; we don't — applications can build caches on + top of `ConversationKey::derive()` themselves. + +3. **Convenience methods on `SecretKey`.** The user explicitly asked for this. They + discard the conversation key after a single use, which is fine for one-shot calls. + +4. **Single `EncryptionError` enum**, not split NIP-04/NIP-44 enums. Teaching + simplicity; a handful of variants covers both. + +5. **Use `unreachable!` sparingly**. For invariants the crypto libraries promise + (e.g., AES key length is correct by construction), prefer `.expect("…")` with a + clear message over a new error variant. Matches the NIP-49 code in `keys.rs`. + +6. **RustCrypto ecosystem across the board** (`aes`, `cbc`, `chacha20`, `hmac`, + `hkdf`, `sha2`). Consistent idioms, well-maintained, already partially used. + Don't mix in `bitcoin_hashes` or `ring`. + +7. **secp256k1 `shared_secret_point`, not `SharedSecret::new`.** The latter + SHA-256-hashes the x-coordinate and is *not* what nostr specifies. The former + returns the raw 64-byte point and we take the first 32 bytes. + +8. **Reconstruct full `PublicKey` from x-only by prepending `0x02`.** Matches every + other reference. We're just picking the even-parity lift of the x-only point. + +9. **Tests live in `coracle-lib/tests/encryption.rs`** (not inline). Consistent with + CLAUDE.md guidance that tests are hand-written and not tangled. Round-trip both + directions, plus one official NIP-44 v2 vector (hardcode the vector inline — no + need to pull in the full `nip44.vectors.json`). + +## Open Questions + +- **Do we hex- or struct-verify in the NIP-44 vector test?** Use at least one vector + where conversation key, nonce, plaintext, and ciphertext are all known, so we can + verify that our encryption produces a byte-for-byte match given a fixed nonce. + Resolve during writing by pulling one `encrypt_decrypt` vector from the official + spec. + +- **Should `encrypt` accept a caller-supplied nonce for determinism in tests?** Yes, + add a crate-internal `encrypt_with_nonce` helper used only by the vector test; + public API stays nonce-free. Mirrors rust-nostr's `encrypt_with_rng`. + +- **Keep the `nip04::encrypt` signature as `&str` → `String` or accept bytes?** + Stick with `&str` → `String` for parity with nostr-tools and to match how DMs are + actually used (JSON text). Document the limitation. + +Plan complete. Proceed with `/write-chapter 03-encryption`. diff --git a/book/research/03-encryption.md b/book/research/03-encryption.md new file mode 100644 index 0000000..6f887ee --- /dev/null +++ b/book/research/03-encryption.md @@ -0,0 +1,325 @@ +# Research: 03-encryption + +## Topic Summary + +Chapter covers nostr encryption: ECDH shared key derivation over secp256k1, NIP-04 +(legacy AES-256-CBC), and NIP-44 v2 (ChaCha20 + HKDF + HMAC-SHA256). Likely extends +`PrivateKey` with encrypt/decrypt methods, but reference implementations also offer +useful abstractions like a reusable `ConversationKey` type. Should explain why NIP-44 +exists, the message format, and the per-message key derivation pipeline. + +## Philosophy + +From `ref/building-nostr`: + +- Nostr is **publicity technology**, not privacy technology. Encryption is situational, + not foundational. Frame it as an opt-in privacy mechanism layered on top of an + inherently public event broadcast system. +- The NIP-04 → NIP-17 evolution is a case study in protocol churn: NIP-04 leaks + significant metadata (sender, recipient, timing); NIP-17 leaks much less but broke + backwards compatibility. Two major clients resisted NIP-17 to preserve UX/delivery. + Lesson: privacy and ergonomics are in tension; libraries must support both. +- Encryption is a **content privacy mechanism**, not an access control mechanism. + Access control belongs in relays. +- User agency over identity is paramount: never send keys to custodians; encryption + primitives must be usable by hardware/remote signers without exposing the key. +- Metadata is the harder problem than message content. Even encrypted DMs leak + unless carefully designed (gift wrap pattern). + +Implications for the chapter: introduce encryption as a tool with trade-offs, be clear +that NIP-04 is deprecated but still required for compatibility, and design the API so +ECDH operations can be delegated to a signer abstraction later. + +## Reference Implementation Analysis + +### applesauce + +Encryption is owned by signers, not a standalone module. Core types in +`packages/core/src/helpers/encrypted-content.ts`: + +- `EncryptedContentSigner` interface defines `nip04` and `nip44` methods on signers +- `EncryptionMethods` pairs `encrypt(pubkey, plaintext)` and `decrypt(pubkey, ciphertext)` +- Event-kind → encryption-method mapping (kind 4 → nip04, kind 13/1059 → nip44) +- Plaintext cached on the event object via `Symbol.for("encrypted-content")` (Reflect API) + +`PrivateKeySigner` (`packages/signers/src/signers/private-key-signer.ts:25-30`) holds a +Uint8Array key and exposes `.nip04` and `.nip44` properties with async encrypt/decrypt. +NIP-44 calls `nip44.getConversationKey(secret, pubkey)` per message; no conversation key +caching at the applesauce level. + +Aggressive plaintext caching via RxJS observables persists decrypted content to +IndexedDB for offline-read UX. Gift wrap (NIP-59) is a clean three-layer cascade +(rumor → seal → gift wrap) using symbols to track refs without polluting JSON. + +Dependencies: `nostr-tools ~2.19`, `@noble/secp256k1 ^1.7.1`, `@noble/hashes`. + +Worth borrowing: signer-as-HSM pattern, kind→method mapping, capability probing. + +### ndk + +`NDKEncryptionScheme = "nip04" | "nip44"` union (core/src/types.ts). + +Signer interface: +```ts +encrypt(recipient: NDKUser, value: string, scheme?: NDKEncryptionScheme): Promise +decrypt(sender: NDKUser, value: string, scheme?: NDKEncryptionScheme): Promise +encryptionEnabled(scheme?): Promise +``` + +Event-level encrypt/decrypt in `core/src/events/encryption.ts` delegates to signer. +Auto-detects NIP-04 vs NIP-44 by checking for `?iv=` pattern in the ciphertext. +Decrypted events cached by event ID via pluggable cache adapter (no conversation key +cache). + +NIP-46 backend separates encrypt/decrypt strategies (`backend/nip44-encrypt.ts`, +`backend/nip44-decrypt.ts`). + +### nostr-gadgets + +No direct encryption implementation. Delegates entirely to `@nostr/tools` (nip04, nip44 +exports). Not useful as a reference for our implementation. + +### nostr-tools + +The canonical low-level reference. Pure-sync API, minimal deps, all from the @noble +ecosystem. + +**Dependencies:** `@noble/curves` (secp256k1 ECDH), `@noble/hashes` (SHA-256, HKDF, +HMAC), `@noble/ciphers` (AES-CBC, ChaCha20), `@scure/base` (base64). + +**NIP-04** (`nip04.ts`, 41 lines): +1. ECDH: `secp256k1.getSharedSecret(privkey, '02' + pubkey)` → 65-byte uncompressed +2. Take x-coordinate: `key.slice(1, 33)` → 32-byte AES key +3. AES-256-CBC with random 16-byte IV +4. Format: `{base64_ciphertext}?iv={base64_iv}` + +**NIP-44 v2** (`nip44.ts`, 128 lines): + +`getConversationKey(privkey, pubkey)`: +``` +sharedX = ecdh(privkey, '02' + pubkey)[1:33] // 32 bytes +return hkdf_extract(sha256, ikm=sharedX, salt='nip44-v2') +``` + +Per-message key derivation (HKDF-Expand with nonce as info, output 76 bytes): +- `[0:32]` ChaCha20 key +- `[32:44]` ChaCha20 nonce (12 bytes) +- `[44:76]` HMAC-SHA256 key + +Padding: messages 1–65535 bytes; ≤32 → 32; else round up to next power-of-2 chunk. +Length prefix is 2-byte big-endian, prepended before padding. + +Encryption pipeline: +1. Generate 32-byte random nonce +2. Pad plaintext (length prefix + zero-padding to chunk size) +3. Derive message keys from conversation key + nonce +4. ChaCha20 encrypt +5. HMAC-SHA256(nonce || ciphertext) with derived MAC key +6. Payload: `[version=2][nonce(32)][ciphertext][mac(32)]`, base64-encoded + +Decryption verifies version byte, recomputes HMAC (constant-time compare), decrypts, +and unpads with length-prefix validation. + +Test vectors live in `nip44.vectors.json` (official NIP-44 vectors from spec). + +### rust-nostr + +The most directly relevant reference — Rust idioms we may want to mirror or diverge +from. All in the `nostr` crate. + +**Module layout:** +- `src/util/mod.rs:72-83` — `generate_shared_key()` +- `src/util/hkdf.rs` — HKDF extract/expand +- `src/nips/nip04.rs` — NIP-04 +- `src/nips/nip44/mod.rs` — NIP-44 versioning wrapper +- `src/nips/nip44/v2.rs` — v2 implementation + +**Dependencies:** `secp256k1` (ECDH), `chacha20`, `aes`, `cbc`, `bitcoin_hashes` +(HMAC-SHA256, SHA-256), `base64`. Behind `nip04` and `nip44` feature flags. + +**ECDH** (`util/mod.rs:72-83`): +```rust +pub fn generate_shared_key(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32], _> { + let pk = pk.xonly()?; + let normalized = NormalizedPublicKey::from_x_only_public_key(pk, Parity::Even); + let ssp: [u8; 64] = ecdh::shared_secret_point(&normalized, sk); + let mut shared = [0u8; 32]; + shared.copy_from_slice(&ssp[..32]); + Ok(shared) +} +``` +Uses x-only (BIP340) pubkey with `Parity::Even` normalization; takes first 32 bytes +of the shared secret point. + +**API:** Free functions, not methods on `Keys`: +- `nip04::encrypt(sk, pk, content)`, `decrypt(sk, pk, payload)` +- `nip44::encrypt(sk, pk, content, version)`, `decrypt(sk, pk, payload)` +- `ConversationKey::derive(sk, pk)` — first-class type for v2 + +**ConversationKey** (`v2.rs:108-153`): +- Newtype wrapping HMAC state (via Deref) to avoid re-hashing +- Result of `hkdf_extract(salt=b"nip44-v2", ikm=shared_key)` +- Reusable across messages in the same conversation + +**Message keys derivation (`v2.rs:252-258`):** +```rust +fn get_message_keys(ck: &ConversationKey, nonce: &[u8]) -> Result { + let expanded = hkdf::expand(ck.as_bytes(), nonce, 76); + MessageKeys::from_slice(&expanded) +} +``` + +**Padding (`v2.rs:260-287`):** +- 1–65408 byte limit +- ≤32 → 32; else round to next power-of-2, with chunk = nextpow2/8 for large messages + +**Errors:** Layered enum types (`Error`, `ErrorV2`) covering key errors, base64, +UTF-8, HKDF length, HMAC mismatch, padding, message size. + +**Tests:** Official NIP-44 test vectors in `nip44/nip44.vectors.json`, exercised by +`test_valid_get_conversation_key`, `test_valid_calc_padded_len`, +`test_valid_encrypt_decrypt`, plus invalid-input tests. + +### welshman + +Encryption lives in the signer package +(`packages/signer/src/util.ts`), wrapping `nostr-tools` directly: + +```ts +interface EncryptionImplementation { + encrypt(pubkey, message): Promise + decrypt(pubkey, message): Promise +} +interface ISigner { + nip04: EncryptionImplementation + nip44: EncryptionImplementation +} +``` + +**Notable: aggressive ECDH conversation-key caching** via an LRU cache (maxSize 10,000) +keyed by `${secret}:${pubkey}`. Wraps `nip44.v2.utils.getConversationKey()`. None of +the other references cache shared secrets. + +NIP-46 remote signer defaults to NIP-44 with NIP-04 fallback. Standalone `decrypt()` +helper auto-detects the format. + +## Common Patterns + +1. **ECDH = secp256k1 shared point, take x-coordinate.** Every implementation does + this. The NIP-04 path uses the raw x-coordinate as the AES key. The NIP-44 path + feeds it into HKDF-Extract with salt `"nip44-v2"` to produce a conversation key. + +2. **Message format for NIP-44 v2 is fixed:** `[version=2][nonce(32)][ct][hmac(32)]`, + base64-encoded. HMAC covers `nonce || ciphertext` (the nonce is AAD, not encrypted). + +3. **Padding scheme is uniform:** 2-byte big-endian length prefix, then zero-pad to + the next power-of-2 chunk (min 32 bytes, max ~65KB). Decryption must verify the + length prefix and that padding bytes are zero. + +4. **Per-message keys derived via HKDF-Expand** with the conversation key as PRK and + the random nonce as info, producing 76 bytes split into ChaCha20 key (32), + ChaCha20 nonce (12), HMAC key (32). + +5. **NIP-04 is universally deprecated** but still implemented for compatibility. Its + format `{ct}?iv={iv}` is easy to detect, which enables auto-routing in + higher-level decrypt helpers. + +6. **Architectural divergence** on where encryption lives: + - **Free functions** (rust-nostr, nostr-tools): standalone modules, no key class + coupling. Most flexible. + - **Methods on a signer** (applesauce, ndk, welshman): encryption belongs to the + thing that holds the key. Enables hardware/remote signers. + - We can do **both**: free functions in this chapter, signer abstraction added + in a later chapter that calls them. + +7. **Conversation key as a first-class type** (rust-nostr's `ConversationKey`, + welshman's LRU cache) is worth borrowing. Without it, every message re-runs ECDH + and HKDF-Extract, which is wasteful for chat-like workloads. With it, callers can + derive once and encrypt many. + +8. **Plaintext caching is an application concern**, not a library concern. Several + libraries (applesauce, ndk) cache decrypted event content, but always at a layer + above the raw crypto. + +## Considerations for Our Implementation + +**Crate placement:** Encryption belongs in `coracle-lib` next to `PrivateKey` and +`PublicKey` (`coracle-lib/src/keys.rs`). Keep it stateless and signer-agnostic; a +later signer chapter can wrap it. + +**Dependencies to add:** +- `secp256k1` is already pulled in by the keys chapter — use its `ecdh` module. + Alternative: `k256` (RustCrypto). secp256k1 is simpler and matches BIP340 semantics. +- `aes` + `cbc` for NIP-04 +- `chacha20` for NIP-44 +- `hmac` + `sha2` (RustCrypto) or `bitcoin_hashes` for HMAC-SHA256 + HKDF. RustCrypto + is more idiomatic and we can use the `hkdf` crate directly. +- `base64` (or `base64ct`) for payload encoding +- `rand` / `rand_core` for IV and nonce generation (already a transitive dep) + +Keep dependencies minimal — prefer one ecosystem (RustCrypto: `aes`, `cbc`, `chacha20`, +`hmac`, `sha2`, `hkdf`) for consistency. + +**API shape proposal:** + +Free functions in a new `coracle-lib/src/encryption.rs` module: +```rust +pub fn shared_secret(sk: &PrivateKey, pk: &PublicKey) -> [u8; 32]; + +pub mod nip04 { + pub fn encrypt(sk: &PrivateKey, pk: &PublicKey, plaintext: &str) -> String; + pub fn decrypt(sk: &PrivateKey, pk: &PublicKey, payload: &str) -> Result; +} + +pub mod nip44 { + pub struct ConversationKey([u8; 32]); + impl ConversationKey { + pub fn derive(sk: &PrivateKey, pk: &PublicKey) -> Self; + } + pub fn encrypt(ck: &ConversationKey, plaintext: &str) -> Result; + pub fn decrypt(ck: &ConversationKey, payload: &str) -> Result; +} +``` + +Plus convenience methods on `PrivateKey` that wrap these (per the user's hint): +```rust +impl PrivateKey { + pub fn nip04_encrypt(&self, pk: &PublicKey, msg: &str) -> String; + pub fn nip04_decrypt(&self, pk: &PublicKey, payload: &str) -> Result; + pub fn nip44_encrypt(&self, pk: &PublicKey, msg: &str) -> Result; + pub fn nip44_decrypt(&self, pk: &PublicKey, payload: &str) -> Result; +} +``` + +The `ConversationKey` type is the load-bearing abstraction — methods on `PrivateKey` +are the friendly wrapper that derives one ad-hoc and discards it. + +**Error type:** A single `EncryptionError` enum covering: invalid payload format, +base64 decode, version mismatch, HMAC mismatch, padding error, message too long, +message empty, UTF-8 error. Don't split into NIP-04 and NIP-44 enums — too much +ceremony for a teaching resource. + +**Padding & format constants:** Define them as named constants with comments +referencing the NIP, since the magic numbers (32, 65535, "nip44-v2", 76) are +otherwise opaque. + +**Test vectors:** Pull a small subset of the official NIP-44 vectors into +`coracle-lib/tests/nip44_vectors.rs` for confidence. NIP-04 has no official vectors +but a round-trip test plus interop with rust-nostr's known ciphertext is sufficient. + +**Out of scope for this chapter:** +- Gift wrap (NIP-59) — separate chapter +- Encrypted event helpers (kind→scheme dispatch) — belongs to events/signer chapter +- Conversation key caching — application concern; mention in passing +- NIP-49 password-encrypted keys — separate concern, possibly a signer chapter + +**Narrative arc:** +1. Why encryption is situational, not foundational. NIP-04 → NIP-44 motivation. +2. ECDH shared secret derivation: the common foundation. +3. NIP-04: the simple, deprecated path. Show it first because it's smaller. +4. NIP-44 v2: the message format, padding, conversation key, per-message keys, + encrypt/decrypt pipeline. Show why it's an improvement. +5. Wiring it onto `PrivateKey` for ergonomics. +6. Tests against round-trips and known vectors. + +Research complete. You can proceed with `/plan-chapter 03-encryption`. diff --git a/coracle-lib/Cargo.toml b/coracle-lib/Cargo.toml index 3c9ad9a..828b313 100644 --- a/coracle-lib/Cargo.toml +++ b/coracle-lib/Cargo.toml @@ -14,4 +14,8 @@ bech32 = "0.11" rand = "0.8" scrypt = { version = "0.11", default-features = false, features = ["std"] } chacha20poly1305 = "0.10" +chacha20 = "0.9" +hmac = "0.12" +hkdf = "0.12" +base64 = "0.22" unicode-normalization = "0.1" diff --git a/coracle-lib/tests/encryption.rs b/coracle-lib/tests/encryption.rs new file mode 100644 index 0000000..6987661 --- /dev/null +++ b/coracle-lib/tests/encryption.rs @@ -0,0 +1,113 @@ +use coracle_lib::encryption::{nip44, shared_secret, EncryptionError}; +use coracle_lib::keys::{PublicKey, SecretKey}; + +fn sk(hex: &str) -> SecretKey { + SecretKey::from_hex(hex).unwrap() +} + +fn pk(sk: &SecretKey) -> PublicKey { + sk.public_key() +} + +#[test] +fn shared_secret_is_symmetric() { + let a = SecretKey::generate(); + let b = SecretKey::generate(); + let pa = pk(&a); + let pb = pk(&b); + assert_eq!(shared_secret(&a, &pb), shared_secret(&b, &pa)); +} + +#[test] +fn nip44_roundtrip_short() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + let msg = "hi"; + let ct = nip44::encrypt(&alice, &pk(&bob), msg).unwrap(); + let pt = nip44::decrypt(&bob, &pk(&alice), &ct).unwrap(); + assert_eq!(pt, msg); +} + +#[test] +fn nip44_roundtrip_long() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + let msg: String = "lorem ipsum ".repeat(500); + let ct = nip44::encrypt(&alice, &pk(&bob), &msg).unwrap(); + let pt = nip44::decrypt(&bob, &pk(&alice), &ct).unwrap(); + assert_eq!(pt, msg); +} + +#[test] +fn nip44_methods_on_secret_key() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + let ct = alice.nip44_encrypt(&pk(&bob), "via method").unwrap(); + let pt = bob.nip44_decrypt(&pk(&alice), &ct).unwrap(); + assert_eq!(pt, "via method"); +} + +#[test] +fn nip44_conversation_key_reuse() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + let ck_a = nip44::ConversationKey::derive(&alice, &pk(&bob)); + let ck_b = nip44::ConversationKey::derive(&bob, &pk(&alice)); + assert_eq!(ck_a.as_bytes(), ck_b.as_bytes()); + + let ct = ck_a.encrypt("reusable").unwrap(); + let pt = ck_b.decrypt(&ct).unwrap(); + assert_eq!(pt, "reusable"); +} + +#[test] +fn nip44_rejects_empty_message() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + assert_eq!( + nip44::encrypt(&alice, &pk(&bob), ""), + Err(EncryptionError::MessageEmpty) + ); +} + +#[test] +fn nip44_rejects_tampered_mac() { + let alice = SecretKey::generate(); + let bob = SecretKey::generate(); + let ct = nip44::encrypt(&alice, &pk(&bob), "tamper me").unwrap(); + // Flip a bit near the end (inside the MAC region). + let mut bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + ct.as_bytes(), + ) + .unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + let tampered = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); + assert_eq!( + nip44::decrypt(&bob, &pk(&alice), &tampered), + Err(EncryptionError::InvalidMac) + ); +} + +/// Official NIP-44 v2 test vector: sec1=1, sec2=2, nonce=0..01, plaintext="a". +#[test] +fn nip44_official_vector() { + let sec1 = sk("0000000000000000000000000000000000000000000000000000000000000001"); + let sec2 = sk("0000000000000000000000000000000000000000000000000000000000000002"); + let expected_ck = + hex::decode("c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d").unwrap(); + let expected_ct = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"; + + let ck = nip44::ConversationKey::derive(&sec1, &pk(&sec2)); + assert_eq!(ck.as_bytes(), expected_ck.as_slice()); + + let mut nonce = [0u8; 32]; + nonce[31] = 1; + let ct = ck.encrypt_with_nonce("a", &nonce).unwrap(); + assert_eq!(ct, expected_ct); + + let pt = ck.decrypt(&ct).unwrap(); + assert_eq!(pt, "a"); +}