feat(fof-layer2): wrap-slot dual-derivation seal/open primitives

Foundational crypto for FoF Mode 2 (public body + FoF-gated comments)
and Mode 1 (FoFClosed; later). Implements the dual-derivation wrap
slot from docs/fof-spec/layer-2-mode2-fof-comments.md:

- Each slot is sealed under one V_x and dual-derived:
    read part  → 32B CEK    (read capability for the post)
    sign part  → 32B priv_x (per-V_x signing capability)
- Both halves use ChaCha20-Poly1305 with deterministic key+nonce
  derived from (V_x, slot_binder_nonce) via blake3::derive_key with
  distinct sub-contexts. Receiver trial-decrypts: success on both
  halves yields OpenedWrapSlot{cek, priv_x_seed}.
- 2-byte prefilter tag = blake3-derive("...prefilter", nonce||V_x)[..2].
  Receivers precompute one per held V_x per post; skip non-matching
  slots entirely. Cuts trial-decrypt cost by ~2^16.

slot_binder_nonce (32B random per-post) replaces the spec's literal
"post_id in HKDF info" — PostId = BLAKE3(post) would be circular here.
Same anti-replay property: unique per publish, recipient-free, in the
post header in plaintext.

Also adds derive_cek_comments(cek, slot_binder_nonce) for the
comment-body encryption key (distinct from the post body CEK; lets
Mode 2 keep body public but comments private).

4 unit tests: slot roundtrip, wrong-binder-fails, prefilter tag
stability + keying, cek_comments distinct-per-post.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 07:16:42 -04:00
parent 34c5b60686
commit 74fec3b1fb

View file

@ -18,6 +18,17 @@ const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1";
const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key";
const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce";
/// FoF Layer 2: per-V_x wrap-slot derivation contexts. Each slot is
/// dual-derived under a different sub-context: `read` yields the CEK
/// (read capability), `sign` yields the per-V_x signing seed. Bound to
/// the post via `slot_binder_nonce` (a random 32B nonce in the post
/// header — not the PostId, which would be circular).
const WRAP_SLOT_READ_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/key";
const WRAP_SLOT_READ_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/nonce";
const WRAP_SLOT_SIGN_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/key";
const WRAP_SLOT_SIGN_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/nonce";
const WRAP_SLOT_PREFILTER_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/prefilter";
/// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes.
pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] {
let signing_key = SigningKey::from_bytes(seed); let signing_key = SigningKey::from_bytes(seed);
@ -301,6 +312,158 @@ pub fn open_vouch_grant(
Some(v_me) Some(v_me)
} }
// --- FoF Layer 2: wrap-slot seal/open (dual-derived read + sign) ---
//
// Each post under FoF comment-gating carries one wrap slot per
// admitted V_x. The slot is dual-derived: one half yields the post's
// shared CEK (read capability), the other yields the per-V_x signing
// seed priv_x (comment-authorship capability for that voucher-chain).
//
// Receivers trial-decrypt slots whose prefilter tag matches one of
// their held V_x's. Successful AEAD-open on the `read` part gives
// them CEK; the `sign` part gives them priv_x. They derive the
// matching pub_x = ed25519_pub(priv_x_seed); the CDN verifies their
// comment signatures against pub_x via the post's pub_post_set.
//
// All AEAD derivation is bound to a per-post `slot_binder_nonce`
// (random 32B in the post header). This plays the same role as the
// spec's "post_id in HKDF info" but isn't circular (PostId =
// BLAKE3(post) depends on wrap_slots → circular).
//
// Wire shape:
// prefilter_tag: 2 bytes (HMAC(V_x, slot_binder_nonce)[:2])
// read_part: 48 bytes (32B sealed CEK + 16B tag)
// sign_part: 48 bytes (32B sealed priv_x seed + 16B tag)
// Total: 98 bytes per slot.
/// Output of [`seal_wrap_slot`]. All fields are wire-stable. See module
/// doc above for derivation details.
#[derive(Debug, Clone)]
pub struct SealedWrapSlot {
pub prefilter_tag: [u8; 2],
pub read_ciphertext: Vec<u8>, // 48 bytes
pub sign_ciphertext: Vec<u8>, // 48 bytes
}
/// Compute the 2-byte prefilter tag for a (V_x, slot_binder_nonce)
/// pair. Cheap; receivers precompute one per held V_x per post and
/// skip non-matching slots entirely.
pub fn wrap_slot_prefilter_tag(v_x: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 2] {
let mut input = [0u8; 64];
input[..32].copy_from_slice(slot_binder_nonce);
input[32..].copy_from_slice(v_x);
let tag = blake3::derive_key(WRAP_SLOT_PREFILTER_CONTEXT, &input);
[tag[0], tag[1]]
}
fn derive_wrap_slot_part(
v_x: &[u8; 32],
slot_binder_nonce: &[u8; 32],
key_ctx: &str,
nonce_ctx: &str,
) -> ([u8; 32], [u8; 12]) {
let mut input = [0u8; 64];
input[..32].copy_from_slice(slot_binder_nonce);
input[32..].copy_from_slice(v_x);
let key = blake3::derive_key(key_ctx, &input);
let nonce_full = blake3::derive_key(nonce_ctx, &input);
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&nonce_full[..12]);
(key, nonce)
}
/// Seal one wrap slot for a specific V_x. Pair (CEK, priv_x_seed) is
/// the slot plaintext: the read part carries CEK, the sign part carries
/// priv_x_seed. Both halves are bound to `slot_binder_nonce` via HKDF.
pub fn seal_wrap_slot(
v_x: &[u8; 32],
slot_binder_nonce: &[u8; 32],
cek: &[u8; 32],
priv_x_seed: &[u8; 32],
) -> Result<SealedWrapSlot> {
let (read_key, read_nonce) = derive_wrap_slot_part(
v_x, slot_binder_nonce,
WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT,
);
let (sign_key, sign_nonce) = derive_wrap_slot_part(
v_x, slot_binder_nonce,
WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT,
);
let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key)
.map_err(|e| anyhow::anyhow!("read cipher init: {}", e))?;
let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key)
.map_err(|e| anyhow::anyhow!("sign cipher init: {}", e))?;
let read_ct = read_cipher
.encrypt(Nonce::from_slice(&read_nonce), cek.as_slice())
.map_err(|e| anyhow::anyhow!("read seal: {}", e))?;
let sign_ct = sign_cipher
.encrypt(Nonce::from_slice(&sign_nonce), priv_x_seed.as_slice())
.map_err(|e| anyhow::anyhow!("sign seal: {}", e))?;
if read_ct.len() != 48 || sign_ct.len() != 48 {
bail!("unexpected wrap-slot ciphertext length");
}
Ok(SealedWrapSlot {
prefilter_tag: wrap_slot_prefilter_tag(v_x, slot_binder_nonce),
read_ciphertext: read_ct,
sign_ciphertext: sign_ct,
})
}
/// Output of a successful [`open_wrap_slot`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenedWrapSlot {
pub cek: [u8; 32],
pub priv_x_seed: [u8; 32],
}
/// Try to open a wrap slot using one of the receiver's V_x's. Returns
/// `None` if either AEAD fails (this slot isn't sealed under this V_x).
pub fn open_wrap_slot(
v_x: &[u8; 32],
slot_binder_nonce: &[u8; 32],
read_ciphertext: &[u8],
sign_ciphertext: &[u8],
) -> Option<OpenedWrapSlot> {
if read_ciphertext.len() != 48 || sign_ciphertext.len() != 48 {
return None;
}
let (read_key, read_nonce) = derive_wrap_slot_part(
v_x, slot_binder_nonce,
WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT,
);
let (sign_key, sign_nonce) = derive_wrap_slot_part(
v_x, slot_binder_nonce,
WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT,
);
let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key).ok()?;
let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key).ok()?;
let cek_bytes = read_cipher
.decrypt(Nonce::from_slice(&read_nonce), read_ciphertext)
.ok()?;
let seed_bytes = sign_cipher
.decrypt(Nonce::from_slice(&sign_nonce), sign_ciphertext)
.ok()?;
if cek_bytes.len() != 32 || seed_bytes.len() != 32 {
return None;
}
let mut cek = [0u8; 32];
cek.copy_from_slice(&cek_bytes);
let mut priv_x_seed = [0u8; 32];
priv_x_seed.copy_from_slice(&seed_bytes);
Some(OpenedWrapSlot { cek, priv_x_seed })
}
/// Derive the per-post comments CEK from the wrap-slot CEK. The
/// comments-CEK is used to encrypt comment bodies separately from the
/// post body — preserves the option of body-public + comments-private
/// (Mode 2) without leaking the body CEK relationship.
pub fn derive_cek_comments(cek: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 32] {
let mut input = [0u8; 64];
input[..32].copy_from_slice(cek);
input[32..].copy_from_slice(slot_binder_nonce);
blake3::derive_key("itsgoin/fof-cek-comments/v1", &input)
}
/// Encrypt a post with a provided CEK, wrapping for recipients. /// Encrypt a post with a provided CEK, wrapping for recipients.
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`. /// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
pub fn encrypt_post_with_cek( pub fn encrypt_post_with_cek(
@ -1522,4 +1685,83 @@ mod tests {
let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk);
assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)");
} }
// --- FoF Layer 2: wrap-slot seal/open ---
#[test]
fn wrap_slot_roundtrip() {
let v_x: [u8; 32] = [0x42; 32];
let slot_binder_nonce: [u8; 32] = [0xAB; 32];
let cek: [u8; 32] = [0x01; 32];
let priv_x_seed: [u8; 32] = [0x02; 32];
let sealed = seal_wrap_slot(&v_x, &slot_binder_nonce, &cek, &priv_x_seed).unwrap();
assert_eq!(sealed.read_ciphertext.len(), 48);
assert_eq!(sealed.sign_ciphertext.len(), 48);
// Same V_x opens it.
let opened = open_wrap_slot(
&v_x, &slot_binder_nonce,
&sealed.read_ciphertext, &sealed.sign_ciphertext,
).unwrap();
assert_eq!(opened.cek, cek);
assert_eq!(opened.priv_x_seed, priv_x_seed);
// Different V_x must not.
let wrong_v_x: [u8; 32] = [0x99; 32];
let attempt = open_wrap_slot(
&wrong_v_x, &slot_binder_nonce,
&sealed.read_ciphertext, &sealed.sign_ciphertext,
);
assert_eq!(attempt, None);
}
#[test]
fn wrap_slot_wrong_binder_fails() {
let v_x: [u8; 32] = [0x42; 32];
let real_nonce: [u8; 32] = [0xAB; 32];
let wrong_nonce: [u8; 32] = [0xCD; 32];
let cek: [u8; 32] = [0x01; 32];
let priv_x_seed: [u8; 32] = [0x02; 32];
let sealed = seal_wrap_slot(&v_x, &real_nonce, &cek, &priv_x_seed).unwrap();
// Same V_x but wrong slot_binder_nonce → AEAD-fail.
let attempt = open_wrap_slot(
&v_x, &wrong_nonce,
&sealed.read_ciphertext, &sealed.sign_ciphertext,
);
assert_eq!(attempt, None);
}
#[test]
fn wrap_slot_prefilter_tag_is_stable_and_keyed() {
let v_a: [u8; 32] = [0x11; 32];
let v_b: [u8; 32] = [0x22; 32];
let nonce_x: [u8; 32] = [0xAA; 32];
let nonce_y: [u8; 32] = [0xBB; 32];
let t1 = wrap_slot_prefilter_tag(&v_a, &nonce_x);
let t2 = wrap_slot_prefilter_tag(&v_a, &nonce_x);
assert_eq!(t1, t2, "deterministic for same inputs");
// Different V_x or different nonce → different tag (overwhelmingly).
let t3 = wrap_slot_prefilter_tag(&v_b, &nonce_x);
assert_ne!(t1, t3);
let t4 = wrap_slot_prefilter_tag(&v_a, &nonce_y);
assert_ne!(t1, t4);
}
#[test]
fn cek_comments_is_distinct_per_post() {
let cek: [u8; 32] = [0x01; 32];
let nonce_a: [u8; 32] = [0xAA; 32];
let nonce_b: [u8; 32] = [0xBB; 32];
let a = derive_cek_comments(&cek, &nonce_a);
let b = derive_cek_comments(&cek, &nonce_b);
assert_ne!(a, b);
// Stable.
assert_eq!(derive_cek_comments(&cek, &nonce_a), a);
// Different from the base CEK.
assert_ne!(a, cek);
}
} }