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:
parent
34c5b60686
commit
74fec3b1fb
1 changed files with 242 additions and 0 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue