feat(fof-layer3): PostVisibility::FoFClosed + body crypto + bucket padding
Adds the Mode 1 (encrypted body) primitives: PostVisibility::FoFClosed - New tag variant. The actual gating data (slot_binder_nonce, pub_post_set, wrap_slots) lives in Post.fof_gating — single source of truth shared between Mode 2 (Public + fof_gating) and Mode 1 (FoFClosed + fof_gating). Invariant: FoFClosed implies Some(gating). fof::encrypt_fof_body / decrypt_fof_body - ChaCha20-Poly1305 under the gating CEK with slot_binder_nonce as AAD (binds body decrypt to the post's gating; an attacker who steals CEK can't reuse it against a different post). - Plaintext format: real_len_u32_le || body_bytes || random_padding. Length prefix lets the reader strip padding after decrypt. - Bucketed body padding: power-of-2 from 1KB up to 256KB, then +256KB linear above. Different bodies in the same bucket produce identically-sized ciphertexts (test asserts this). fof::next_body_size_bucket(real) -> usize - Min 1KB, power-of-2 to 256KB, then +256KB steps. Aligns with the future storage chunk size at 256KB+. Three new tests (145 total): - body_bucket_rule_boundaries: spec-conformance for the bucket sizes. - fof_body_roundtrip: encrypt → decrypt; wrong CEK rejects; wrong AAD (slot_binder_nonce) rejects. - fof_body_padding_hides_real_length: 5B body and 500B body produce same-sized on-wire ciphertexts (1KB bucket). 8 match arms updated to handle FoFClosed across import, network, node, storage. Most paths skip FoFClosed-specific handling (it goes through the FoF wrap_slot path); revoke_post_access bails with a pointer to the FoF revoke helpers; index_post_recipients no-ops (FoF has no per-recipient identifiers on the wire). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
10de3f6108
commit
856f386231
6 changed files with 232 additions and 3 deletions
|
|
@ -341,6 +341,122 @@ pub fn decrypt_fof_comment_payload(
|
|||
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
|
||||
}
|
||||
|
||||
// --- Mode 1 (FoFClosed) body encryption ---
|
||||
//
|
||||
// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is
|
||||
// that the post body is itself encrypted under that CEK before
|
||||
// publish; readers who can unlock a slot (recovering CEK) decrypt the
|
||||
// body too. Non-FoF readers see only the ciphertext; they can still
|
||||
// store + propagate the post as a CDN host without decrypting.
|
||||
|
||||
/// FoF Layer 3: encrypt a post body under the gating CEK, padded to a
|
||||
/// bucket per the spec. Output is `body_bucket_padding_nonce(12) ||
|
||||
/// real_len_u32_le || ciphertext_with_tag(body+pad+16)` so the reader
|
||||
/// can recover the original byte length after decryption.
|
||||
pub fn encrypt_fof_body(
|
||||
body: &str,
|
||||
cek: &[u8; 32],
|
||||
slot_binder_nonce: &[u8; 32],
|
||||
) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};
|
||||
use rand::RngCore;
|
||||
|
||||
let real = body.as_bytes();
|
||||
// Pre-tag size we want to encrypt = 4-byte length prefix + body + padding.
|
||||
let prefixed_len = 4 + real.len();
|
||||
let bucket = next_body_size_bucket(prefixed_len);
|
||||
let mut plaintext = Vec::with_capacity(bucket);
|
||||
plaintext.extend_from_slice(&(real.len() as u32).to_le_bytes());
|
||||
plaintext.extend_from_slice(real);
|
||||
// Pad with random bytes (NOT zeros — zeros would be a small
|
||||
// distinguisher under known-plaintext but doesn't matter under
|
||||
// AEAD; random is defense-in-depth.)
|
||||
let mut pad = vec![0u8; bucket.saturating_sub(plaintext.len())];
|
||||
rand::rng().fill_bytes(&mut pad);
|
||||
plaintext.extend_from_slice(&pad);
|
||||
|
||||
// Derive a per-body nonce-context key so we don't reuse the slot
|
||||
// AEAD nonces. blake3-derive over (cek, slot_binder_nonce) gives
|
||||
// us a stable seed; we then prepend a fresh random 12B nonce.
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(cek)
|
||||
.map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
// AAD = slot_binder_nonce. Binds body decrypt to the post's gating
|
||||
// (an attacker who steals the body ciphertext + CEK can't decrypt
|
||||
// against a different post's wrap slots).
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
chacha20poly1305::aead::Payload {
|
||||
msg: &plaintext,
|
||||
aad: slot_binder_nonce,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("body encrypt: {}", e))?;
|
||||
|
||||
let mut out = Vec::with_capacity(12 + ciphertext.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// FoF Layer 3: decrypt a body sealed by [`encrypt_fof_body`]. Returns
|
||||
/// the original `String` body (padding stripped via the 4-byte length
|
||||
/// prefix).
|
||||
pub fn decrypt_fof_body(
|
||||
encrypted: &[u8],
|
||||
cek: &[u8; 32],
|
||||
slot_binder_nonce: &[u8; 32],
|
||||
) -> Result<String> {
|
||||
use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};
|
||||
|
||||
if encrypted.len() < 12 + 16 {
|
||||
anyhow::bail!("encrypted body too short");
|
||||
}
|
||||
let nonce = Nonce::from_slice(&encrypted[..12]);
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(cek)
|
||||
.map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
nonce,
|
||||
chacha20poly1305::aead::Payload {
|
||||
msg: &encrypted[12..],
|
||||
aad: slot_binder_nonce,
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("body decrypt: {}", e))?;
|
||||
if plaintext.len() < 4 {
|
||||
anyhow::bail!("body plaintext missing length prefix");
|
||||
}
|
||||
let real_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize;
|
||||
if 4 + real_len > plaintext.len() {
|
||||
anyhow::bail!("body length prefix overruns plaintext");
|
||||
}
|
||||
let body_bytes = &plaintext[4..4 + real_len];
|
||||
String::from_utf8(body_bytes.to_vec())
|
||||
.map_err(|e| anyhow::anyhow!("body not valid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
/// FoF Layer 3 body-size bucket rule.
|
||||
/// Power-of-2 from a minimum of 1 KiB up to 256 KiB, then linear
|
||||
/// +256 KiB steps above. Hides body length within meaningful brackets.
|
||||
pub(crate) fn next_body_size_bucket(real: usize) -> usize {
|
||||
const MIN_BUCKET: usize = 1024;
|
||||
const POW2_CEIL: usize = 256 * 1024;
|
||||
const LINEAR_STEP: usize = 256 * 1024;
|
||||
if real <= MIN_BUCKET { return MIN_BUCKET; }
|
||||
if real <= POW2_CEIL {
|
||||
let mut b = MIN_BUCKET;
|
||||
while b < real { b *= 2; }
|
||||
return b;
|
||||
}
|
||||
// 256K, 512K, 768K, ...
|
||||
let above = real - POW2_CEIL;
|
||||
let steps = (above + LINEAR_STEP - 1) / LINEAR_STEP;
|
||||
POW2_CEIL + steps * LINEAR_STEP
|
||||
}
|
||||
|
||||
// --- Revocation: sign + verify + apply ---
|
||||
|
||||
/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`.
|
||||
|
|
@ -939,6 +1055,56 @@ mod tests {
|
|||
assert!(!blocked, "revoked pub_x must not be re-granted access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_bucket_rule_boundaries() {
|
||||
// Sub-1KB.
|
||||
assert_eq!(next_body_size_bucket(0), 1024);
|
||||
assert_eq!(next_body_size_bucket(1024), 1024);
|
||||
// Power-of-2 progression sub-256KB.
|
||||
assert_eq!(next_body_size_bucket(1025), 2048);
|
||||
assert_eq!(next_body_size_bucket(2048), 2048);
|
||||
assert_eq!(next_body_size_bucket(100_000), 131_072); // 128KB
|
||||
assert_eq!(next_body_size_bucket(200_000), 262_144); // 256KB
|
||||
// Linear +256KB above.
|
||||
assert_eq!(next_body_size_bucket(262_145), 524_288); // 512KB
|
||||
assert_eq!(next_body_size_bucket(500_000), 524_288);
|
||||
assert_eq!(next_body_size_bucket(524_289), 786_432); // 768KB
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fof_body_roundtrip() {
|
||||
let cek = [0x55; 32];
|
||||
let slot_binder_nonce = [0xCC; 32];
|
||||
let body = "Hello, this is a Mode 1 FoFClosed body that nobody outside the FoF set should be able to read on the wire.";
|
||||
|
||||
let encrypted = encrypt_fof_body(body, &cek, &slot_binder_nonce).unwrap();
|
||||
// Encryption pads to bucket: small body → 1KB ciphertext-ish.
|
||||
assert!(encrypted.len() >= 1024, "body padded to >= 1KB bucket");
|
||||
|
||||
let decrypted = decrypt_fof_body(&encrypted, &cek, &slot_binder_nonce).unwrap();
|
||||
assert_eq!(decrypted, body);
|
||||
|
||||
// Wrong CEK fails AEAD.
|
||||
let wrong_cek = [0x11; 32];
|
||||
assert!(decrypt_fof_body(&encrypted, &wrong_cek, &slot_binder_nonce).is_err());
|
||||
|
||||
// Wrong AAD (slot_binder_nonce) fails too.
|
||||
let wrong_nonce = [0xFF; 32];
|
||||
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fof_body_padding_hides_real_length() {
|
||||
let cek = [0x55; 32];
|
||||
let nonce = [0xCC; 32];
|
||||
// A 5-byte body and a 500-byte body should both produce the
|
||||
// same on-wire size (1KB bucket).
|
||||
let small = encrypt_fof_body("short", &cek, &nonce).unwrap();
|
||||
let medium = encrypt_fof_body(&"x".repeat(500), &cek, &nonce).unwrap();
|
||||
assert_eq!(small.len(), medium.len(),
|
||||
"different bodies in the same bucket produce same-sized ciphertexts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fof_revocation_wrong_author_rejected() {
|
||||
let post_id = [0x01; 32];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue