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))
|
.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 ---
|
// --- Revocation: sign + verify + apply ---
|
||||||
|
|
||||||
/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`.
|
/// 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");
|
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]
|
#[test]
|
||||||
fn fof_revocation_wrong_author_rejected() {
|
fn fof_revocation_wrong_author_rejected() {
|
||||||
let post_id = [0x01; 32];
|
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.
|
// No intent recorded — infer from the visibility shape.
|
||||||
match vis {
|
match vis {
|
||||||
PostVisibility::Public => VisibilityIntent::Public,
|
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 } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
||||||
// Friends posts wrap to every public follow (usually many).
|
// Friends posts wrap to every public follow (usually many).
|
||||||
|
|
@ -679,6 +682,16 @@ pub async fn merge_with_key(
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
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
|
// 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)))
|
.map(|members| members.iter().any(|m| query_list.contains(m)))
|
||||||
.unwrap_or(false)
|
.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);
|
let _ = storage.pin_blob(&att.cid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize encrypted receipt + comment slots for non-public posts
|
// Initialize encrypted receipt + comment slots for non-public posts.
|
||||||
if !matches!(visibility, PostVisibility::Public) {
|
// 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 {
|
let participant_count = match &visibility {
|
||||||
PostVisibility::Encrypted { recipients } => recipients.len(),
|
PostVisibility::Encrypted { recipients } => recipients.len(),
|
||||||
PostVisibility::GroupEncrypted { .. } => {
|
PostVisibility::GroupEncrypted { .. } => {
|
||||||
|
|
@ -1229,7 +1234,7 @@ impl Node {
|
||||||
_ => 2,
|
_ => 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PostVisibility::Public => unreachable!(),
|
PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
|
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
|
||||||
|
|
@ -1483,6 +1488,15 @@ impl Node {
|
||||||
).ok()
|
).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)
|
(id, post, vis, decrypted)
|
||||||
})
|
})
|
||||||
|
|
@ -1917,6 +1931,15 @@ impl Node {
|
||||||
Ok(None)
|
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 { .. } => {
|
PostVisibility::GroupEncrypted { .. } => {
|
||||||
anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead")
|
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
|
let new_recipient_ids: Vec<NodeId> = existing_recipients
|
||||||
|
|
@ -5058,6 +5084,11 @@ impl Node {
|
||||||
Ok(None)
|
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(())
|
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)
|
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
|
||||||
wrapped_cek: Vec<u8>,
|
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 {
|
impl Default for PostVisibility {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue