Files
2026-04-13 21:21:52 -07:00

224 lines
11 KiB
Markdown

# 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<String, EncryptionError>;
}
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<String, EncryptionError>;
pub fn decrypt(&self, payload: &str) -> Result<String, EncryptionError>;
}
pub fn encrypt(sk: &SecretKey, pk: &PublicKey, plaintext: &str)
-> Result<String, EncryptionError>;
pub fn decrypt(sk: &SecretKey, pk: &PublicKey, payload: &str)
-> Result<String, EncryptionError>;
}
```
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<String, EncryptionError>;
pub fn nip44_encrypt(&self, pk: &PublicKey, msg: &str)
-> Result<String, EncryptionError>;
pub fn nip44_decrypt(&self, pk: &PublicKey, payload: &str)
-> Result<String, EncryptionError>;
}
```
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`.