# 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`.