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];
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI
|
|||
// No intent recorded — infer from the visibility shape.
|
||||
match vis {
|
||||
PostVisibility::Public => VisibilityIntent::Public,
|
||||
// FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public.
|
||||
// The FoF gating handles audience; intent is the structural tag.
|
||||
PostVisibility::FoFClosed => VisibilityIntent::Public,
|
||||
PostVisibility::Encrypted { recipients } => {
|
||||
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
||||
// Friends posts wrap to every public follow (usually many).
|
||||
|
|
@ -679,6 +682,16 @@ pub async fn merge_with_key(
|
|||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
PostVisibility::FoFClosed => {
|
||||
// FoF Layer 3 import: skip for now. The recovered
|
||||
// post would need its fof_gating + CEK to decrypt,
|
||||
// and the receiving persona's keyring may not
|
||||
// include the right V_x. Re-issue via the author's
|
||||
// device is the supported path.
|
||||
debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new post under our identity
|
||||
|
|
|
|||
|
|
@ -2234,6 +2234,11 @@ pub fn should_send_post(
|
|||
.map(|members| members.iter().any(|m| query_list.contains(m)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
// FoF Layer 3: FoFClosed posts have no per-recipient identifiers
|
||||
// on the wire. Match like Public (by author): the post propagates
|
||||
// through the same CDN diversity path as public content; only
|
||||
// FoF readers can decrypt.
|
||||
PostVisibility::FoFClosed => query_list.contains(&post.author),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1213,8 +1213,13 @@ impl Node {
|
|||
let _ = storage.pin_blob(&att.cid);
|
||||
}
|
||||
|
||||
// Initialize encrypted receipt + comment slots for non-public posts
|
||||
if !matches!(visibility, PostVisibility::Public) {
|
||||
// Initialize encrypted receipt + comment slots for non-public posts.
|
||||
// FoFClosed posts use the FoF wrap_slots mechanism for both
|
||||
// reads and comments — they don't use the legacy receipt/
|
||||
// comment slot path. Skip init for FoFClosed.
|
||||
if !matches!(visibility, PostVisibility::Public)
|
||||
&& !matches!(visibility, PostVisibility::FoFClosed)
|
||||
{
|
||||
let participant_count = match &visibility {
|
||||
PostVisibility::Encrypted { recipients } => recipients.len(),
|
||||
PostVisibility::GroupEncrypted { .. } => {
|
||||
|
|
@ -1229,7 +1234,7 @@ impl Node {
|
|||
_ => 2,
|
||||
}
|
||||
}
|
||||
PostVisibility::Public => unreachable!(),
|
||||
PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(),
|
||||
};
|
||||
|
||||
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
|
||||
|
|
@ -1483,6 +1488,15 @@ impl Node {
|
|||
).ok()
|
||||
})
|
||||
}
|
||||
// FoF Layer 3: FoFClosed body decrypt requires
|
||||
// trial-unlocking via the post's wrap_slots against
|
||||
// every persona's received-vouch keyring — which is
|
||||
// an async storage lookup, not available in this
|
||||
// sync helper. Feed rendering for FoFClosed posts
|
||||
// goes through a dedicated async path that resolves
|
||||
// the unlock + decrypts; this helper returns None
|
||||
// and lets the caller fall back.
|
||||
PostVisibility::FoFClosed => None,
|
||||
};
|
||||
(id, post, vis, decrypted)
|
||||
})
|
||||
|
|
@ -1917,6 +1931,15 @@ impl Node {
|
|||
Ok(None)
|
||||
}
|
||||
}
|
||||
// FoF Layer 3: blob decryption for FoFClosed posts requires
|
||||
// the CEK recovered via wrap_slots. This sync helper doesn't
|
||||
// have storage access for the keyring trial-unlock; the
|
||||
// async caller path goes through get_blob_for_post which
|
||||
// can perform the unlock. For now return None — blob
|
||||
// decryption for FoF posts is wired in the receive/render
|
||||
// slice. (v0 ships with FoF body decryption only; binary
|
||||
// attachments arrive in a follow-up.)
|
||||
PostVisibility::FoFClosed => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3040,6 +3063,9 @@ impl Node {
|
|||
PostVisibility::GroupEncrypted { .. } => {
|
||||
anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead")
|
||||
}
|
||||
PostVisibility::FoFClosed => {
|
||||
anyhow::bail!("cannot revoke individual access on a FoF-gated post via this path; use revoke_fof_commenter (Layer 2) or grant_fof_access (Layer 3)")
|
||||
}
|
||||
};
|
||||
|
||||
let new_recipient_ids: Vec<NodeId> = existing_recipients
|
||||
|
|
@ -5058,6 +5084,11 @@ impl Node {
|
|||
Ok(None)
|
||||
}
|
||||
}
|
||||
// FoF Layer 3: FoFClosed posts don't use the legacy
|
||||
// receipt/comment slot mechanism — they use the FoF gating's
|
||||
// CEK_comments. This helper isn't used for FoF posts;
|
||||
// return None so callers fall back to the FoF-specific path.
|
||||
PostVisibility::FoFClosed => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4540,6 +4540,10 @@ impl Storage {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
// FoF Layer 3: FoFClosed posts have no per-recipient
|
||||
// identifiers on the wire (audience is implicit in the
|
||||
// wrap_slots). No index to populate.
|
||||
PostVisibility::FoFClosed => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,16 @@ pub enum PostVisibility {
|
|||
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
|
||||
wrapped_cek: Vec<u8>,
|
||||
},
|
||||
/// FoF Layer 3 (Mode 1): post body is encrypted under the CEK
|
||||
/// carried in the post's `fof_gating.wrap_slots`. Tag variant only —
|
||||
/// the actual gating data (slot_binder_nonce / pub_post_set /
|
||||
/// wrap_slots) lives in `Post.fof_gating` so Mode 1 and Mode 2
|
||||
/// share a single home for the FoF state.
|
||||
///
|
||||
/// Invariant: when visibility is FoFClosed, `Post.fof_gating` must
|
||||
/// be Some. Posts with FoFClosed + None gating are rejected at
|
||||
/// receive time.
|
||||
FoFClosed,
|
||||
}
|
||||
|
||||
impl Default for PostVisibility {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue