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
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue