Add encryption chapter
This commit is contained in:
Generated
+19
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 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<MessageKeys, _> {
|
||||
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<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`.
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user