Add encryption chapter

This commit is contained in:
Jon Staab
2026-04-13 21:21:52 -07:00
parent 5364854881
commit 2f334fa3be
8 changed files with 1259 additions and 27 deletions
Generated
+19
View File
@@ -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"
+548
View File
@@ -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<Sha256>;
```
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::<Sha256>::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(<redacted>)")
}
}
```
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::<Sha256>::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<Vec<u8>, 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<Vec<u8>, 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<String, EncryptionError> {
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<String, EncryptionError> {
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 = <HmacSha256 as 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<String, EncryptionError> {
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 = <HmacSha256 as 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<String, EncryptionError> {
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<String, EncryptionError> {
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<String, EncryptionError> {
nip44::encrypt(self, pk, plaintext)
}
/// Decrypt a NIP-44 v2 payload from `pk`.
pub fn nip44_decrypt(
&self,
pk: &PublicKey,
payload: &str,
) -> Result<String, EncryptionError> {
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.
+27 -27
View File
@@ -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)
+223
View File
@@ -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<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`.
+325
View File
@@ -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<string>
decrypt(sender: NDKUser, value: string, scheme?: NDKEncryptionScheme): Promise<string>
encryptionEnabled(scheme?): Promise<NDKEncryptionScheme[]>
```
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 165535 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<MessageKeys, _> {
let expanded = hkdf::expand(ck.as_bytes(), nonce, 76);
MessageKeys::from_slice(&expanded)
}
```
**Padding (`v2.rs:260-287`):**
- 165408 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<string>
decrypt(pubkey, message): Promise<string>
}
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<String, Error>;
}
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<String, Error>;
pub fn decrypt(ck: &ConversationKey, payload: &str) -> Result<String, Error>;
}
```
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<String, Error>;
pub fn nip44_encrypt(&self, pk: &PublicKey, msg: &str) -> Result<String, Error>;
pub fn nip44_decrypt(&self, pk: &PublicKey, payload: &str) -> Result<String, Error>;
}
```
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`.
+4
View File
@@ -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"
+113
View File
@@ -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");
}