feat(fof-layer2): wire types — WrapSlot, FoFCommentGating, CommentPermission::FriendsOfFriends

Adds the on-wire shapes for FoF Mode 2 comment-gating per
docs/fof-spec/layer-2-mode2-fof-comments.md:

- WrapSlot: per-V_x slot with 2B prefilter_tag + 48B read_ciphertext
  + 48B sign_ciphertext (sealed CEK + sealed priv_x_seed). 98 bytes
  total per slot. Receiver trial-decrypts via prefilter match.

- FoFCommentGating: author-published gating block embedded in
  Post.fof_gating. Carries slot_binder_nonce (32B random; replaces
  spec's circular "post_id in HKDF info"), pub_post_set (1:1 with
  wrap_slots, includes dummy pubkeys), wrap_slots, and revocation_list
  (initially empty; revocation diffs accumulate on the BlobHeader copy).

- RevocationEntry: author-signed entry triggering retroactive comment
  delete + pub_post_set removal on every file-holder that receives it.

- CommentPermission gains FriendsOfFriends variant. Existing match arm
  in connection.rs handle-incoming-diff path is extended with a
  "drop pending CDN four-check verification" stub (full verify in a
  later slice).

- InlineComment extended with three optional fields:
    pub_x_index: index into parent post's pub_post_set
    group_sig: 64B ed25519 sig under priv_x
    encrypted_payload: ChaCha20-Poly1305 ciphertext under CEK_comments
  All #[serde(default)] for back-compat. Old comments deserialize
  cleanly with None.

- Post gains optional fof_gating field for the author-signed snapshot
  at publish time. PostId = BLAKE3(Post) covers it, so any tampering
  is detectable. Mutations (revocation, access-grant) arrive later as
  diffs against the local BlobHeader copy.

All 21 existing Post construction sites + 4 existing InlineComment
sites updated via perl -0pe sweeps to pass None for the new fields.
Full test suite: 134/134 pass (4 new slot crypto + 130 existing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 13:39:46 -04:00
parent 74fec3b1fb
commit 0f5147a31c
11 changed files with 148 additions and 0 deletions

View file

@ -143,6 +143,7 @@ pub fn build_announcement_post(
content: serde_json::to_string(&content).unwrap_or_default(), content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
} }
} }

View file

@ -6171,6 +6171,12 @@ impl ConnectionManager {
} }
} }
crate::types::CommentPermission::Public => {} crate::types::CommentPermission::Public => {}
crate::types::CommentPermission::FriendsOfFriends => {
// FoF four-check verification gate lives
// in a future slice; for now treat as
// "drop until verified" (safest default).
continue;
}
} }
if !crate::crypto::verify_comment_signature( if !crate::crypto::verify_comment_signature(
&comment.author, &comment.author,

View file

@ -23,6 +23,7 @@ mod tests {
content: "hello world".to_string(), content: "hello world".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
let id1 = compute_post_id(&post); let id1 = compute_post_id(&post);
let id2 = compute_post_id(&post); let id2 = compute_post_id(&post);
@ -36,12 +37,14 @@ mod tests {
content: "hello".to_string(), content: "hello".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
let post2 = Post { let post2 = Post {
author: [1u8; 32], author: [1u8; 32],
content: "world".to_string(), content: "world".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
assert_ne!(compute_post_id(&post1), compute_post_id(&post2)); assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
} }
@ -53,6 +56,7 @@ mod tests {
content: "test".to_string(), content: "test".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
let id = compute_post_id(&post); let id = compute_post_id(&post);
assert!(verify_post_id(&id, &post)); assert!(verify_post_id(&id, &post));

View file

@ -155,6 +155,7 @@ pub fn build_delete_control_post(
content: serde_json::to_string(&op).unwrap_or_default(), content: serde_json::to_string(&op).unwrap_or_default(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
} }
} }
@ -182,6 +183,7 @@ pub fn build_visibility_control_post(
content: serde_json::to_string(&op).unwrap_or_default(), content: serde_json::to_string(&op).unwrap_or_default(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
} }
} }
@ -212,6 +214,7 @@ mod tests {
content: "hello".to_string(), content: "hello".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
@ -240,6 +243,7 @@ mod tests {
content: "hello".to_string(), content: "hello".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();

View file

@ -61,6 +61,7 @@ pub fn build_distribution_post(
content: ciphertext_b64, content: ciphertext_b64,
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
}; };
let post_id = compute_post_id(&post); let post_id = compute_post_id(&post);
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
@ -241,6 +242,7 @@ mod tests {
content: ciphertext, content: ciphertext,
attachments: vec![], attachments: vec![],
timestamp_ms: 200, timestamp_ms: 200,
fof_gating: None,
}; };
let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

View file

@ -286,6 +286,7 @@ pub async fn import_as_personas(
content: ep.content.clone(), content: ep.content.clone(),
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None,
}; };
// Preserve the original visibility intent from the export. // Preserve the original visibility intent from the export.
@ -459,6 +460,7 @@ pub async fn import_public_posts(
content: ep.content.clone(), content: ep.content.clone(),
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None,
}; };
// Read blob data from archive // Read blob data from archive
@ -685,6 +687,7 @@ pub async fn merge_with_key(
content: plaintext, content: plaintext,
attachments: attachments.clone(), attachments: attachments.clone(),
timestamp_ms: ep.timestamp_ms, timestamp_ms: ep.timestamp_ms,
fof_gating: None,
}; };
// Read blob data from archive (may need decryption for encrypted posts) // Read blob data from archive (may need decryption for encrypted posts)

View file

@ -2253,6 +2253,7 @@ mod tests {
content: "test".to_string(), content: "test".to_string(),
attachments: vec![], attachments: vec![],
timestamp_ms: 1000, timestamp_ms: 1000,
fof_gating: None,
} }
} }

View file

@ -827,6 +827,7 @@ impl Node {
content: serde_json::to_string(&content).unwrap_or_default(), content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![], attachments: vec![],
timestamp_ms: pi.created_at, timestamp_ms: pi.created_at,
fof_gating: None,
}; };
let post_id = crate::content::compute_post_id(&post); let post_id = crate::content::compute_post_id(&post);
{ {
@ -1162,6 +1163,7 @@ impl Node {
content: final_content, content: final_content,
attachments, attachments,
timestamp_ms: now, timestamp_ms: now,
fof_gating: None,
}; };
let post_id = compute_post_id(&post); let post_id = compute_post_id(&post);
@ -3081,6 +3083,7 @@ impl Node {
content: new_content, content: new_content,
attachments: post.attachments.clone(), attachments: post.attachments.clone(),
timestamp_ms: post.timestamp_ms, timestamp_ms: post.timestamp_ms,
fof_gating: None,
}; };
let new_post_id = compute_post_id(&new_post); let new_post_id = compute_post_id(&new_post);
@ -4586,6 +4589,9 @@ impl Node {
signature, signature,
deleted_at: None, deleted_at: None,
ref_post_id, ref_post_id,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}; };
let storage = self.storage.get().await; let storage = self.storage.get().await;

View file

@ -195,6 +195,7 @@ pub fn build_profile_post(
content: serde_json::to_string(&content).unwrap_or_default(), content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
} }
} }
@ -463,6 +464,7 @@ mod tests {
content: serde_json::to_string(&content).unwrap(), content: serde_json::to_string(&content).unwrap(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
}; };
// Apply. Auto-scan should fire and store the unwrapped V_me. // Apply. Auto-scan should fire and store the unwrapped V_me.
@ -531,6 +533,7 @@ mod tests {
content: serde_json::to_string(&content).unwrap(), content: serde_json::to_string(&content).unwrap(),
attachments: vec![], attachments: vec![],
timestamp_ms, timestamp_ms,
fof_gating: None,
}; };
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();

View file

@ -893,6 +893,7 @@ impl Storage {
content: row.get(1)?, content: row.get(1)?,
attachments, attachments,
timestamp_ms: row.get::<_, i64>(3)? as u64, timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating: None,
})) }))
} else { } else {
Ok(None) Ok(None)
@ -919,6 +920,7 @@ impl Storage {
content: row.get(1)?, content: row.get(1)?,
attachments, attachments,
timestamp_ms: row.get::<_, i64>(3)? as u64, timestamp_ms: row.get::<_, i64>(3)? as u64,
fof_gating: None,
}, },
visibility, visibility,
))) )))
@ -1012,6 +1014,7 @@ impl Storage {
content, content,
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None,
}, },
visibility, visibility,
)); ));
@ -1050,6 +1053,7 @@ impl Storage {
content, content,
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None,
}, },
visibility, visibility,
)); ));
@ -1212,6 +1216,7 @@ impl Storage {
content, content,
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None,
}, },
visibility, visibility,
)); ));
@ -1247,6 +1252,7 @@ impl Storage {
content, content,
attachments, attachments,
timestamp_ms: timestamp_ms as u64, timestamp_ms: timestamp_ms as u64,
fof_gating: None,
}, },
visibility, visibility,
)); ));
@ -2926,6 +2932,7 @@ impl Storage {
content: row.get(2)?, content: row.get(2)?,
attachments, attachments,
timestamp_ms: row.get::<_, i64>(4)? as u64, timestamp_ms: row.get::<_, i64>(4)? as u64,
fof_gating: None,
}, },
visibility, visibility,
)); ));
@ -5302,6 +5309,9 @@ impl Storage {
signature: sig, signature: sig,
deleted_at: None, deleted_at: None,
ref_post_id, ref_post_id,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}); });
} }
Ok(result) Ok(result)
@ -5341,6 +5351,9 @@ impl Storage {
signature: sig, signature: sig,
deleted_at: del.map(|v| v as u64), deleted_at: del.map(|v| v as u64),
ref_post_id, ref_post_id,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}); });
} }
Ok(result) Ok(result)
@ -6100,6 +6113,7 @@ mod tests {
content: format!("post at {}", ts), content: format!("post at {}", ts),
attachments: vec![], attachments: vec![],
timestamp_ms: ts, timestamp_ms: ts,
fof_gating: None,
}; };
let id = blake3::hash(&serde_json::to_vec(&post).unwrap()); let id = blake3::hash(&serde_json::to_vec(&post).unwrap());
s.store_post(id.as_bytes(), &post).unwrap(); s.store_post(id.as_bytes(), &post).unwrap();
@ -6797,6 +6811,9 @@ mod tests {
signature: vec![0u8; 64], signature: vec![0u8; 64],
deleted_at: None, deleted_at: None,
ref_post_id: None, ref_post_id: None,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}).unwrap(); }).unwrap();
s.store_comment(&InlineComment { s.store_comment(&InlineComment {
@ -6807,6 +6824,9 @@ mod tests {
signature: vec![1u8; 64], signature: vec![1u8; 64],
deleted_at: None, deleted_at: None,
ref_post_id: None, ref_post_id: None,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}).unwrap(); }).unwrap();
let comments = s.get_comments(&post_id).unwrap(); let comments = s.get_comments(&post_id).unwrap();
@ -6832,6 +6852,9 @@ mod tests {
signature: vec![9u8; 64], signature: vec![9u8; 64],
deleted_at: None, deleted_at: None,
ref_post_id: Some(ref_post), ref_post_id: Some(ref_post),
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
}).unwrap(); }).unwrap();
let live = s.get_comments(&post_id).unwrap(); let live = s.get_comments(&post_id).unwrap();

View file

@ -39,6 +39,15 @@ pub struct Post {
pub attachments: Vec<Attachment>, pub attachments: Vec<Attachment>,
/// Unix timestamp in milliseconds /// Unix timestamp in milliseconds
pub timestamp_ms: u64, pub timestamp_ms: u64,
/// FoF Layer 2: author-signed snapshot of the comment-gating
/// state at publish time. Carries wrap_slots, pub_post_set, and the
/// slot_binder_nonce. `None` on posts without FoF comment gating.
/// Covered by `PostId = BLAKE3(Post)` so any forgery is detectable.
/// Revocations and access-grants arrive later as engagement diffs
/// against the local BlobHeader copy; this field is the snapshot at
/// t=0, not the live mutable state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fof_gating: Option<FoFCommentGating>,
} }
/// A reference to a media blob attached to a post /// A reference to a media blob attached to a post
@ -918,6 +927,9 @@ pub struct InlineComment {
pub post_id: PostId, pub post_id: PostId,
/// Either the full comment text (short comments) or a short preview of /// Either the full comment text (short comments) or a short preview of
/// the referenced post (when `ref_post_id` is set). /// the referenced post (when `ref_post_id` is set).
///
/// On FoF-policy posts this field is empty — the body lives encrypted
/// in `encrypted_payload`. Non-FoF readers see no text at all.
pub content: String, pub content: String,
/// When the comment was created (ms) /// When the comment was created (ms)
pub timestamp_ms: u64, pub timestamp_ms: u64,
@ -932,6 +944,23 @@ pub struct InlineComment {
/// for the expanded view. /// for the expanded view.
#[serde(default)] #[serde(default)]
pub ref_post_id: Option<PostId>, pub ref_post_id: Option<PostId>,
/// FoF Layer 2: index into the parent post's `pub_post_set`
/// identifying which voucher-chain signed this comment. `None` on
/// non-FoF comments. CDN propagation nodes verify `group_sig`
/// against `pub_post_set[pub_x_index]` before forwarding.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pub_x_index: Option<u32>,
/// FoF Layer 2: 64-byte ed25519 signature under priv_x over
/// `(encrypted_payload || parent_post_id || pub_x_index)`. Verified
/// at CDN-level against `pub_post_set[pub_x_index]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_sig: Option<Vec<u8>>,
/// FoF Layer 2: ChaCha20-Poly1305 ciphertext under CEK_comments
/// (derived from CEK via HKDF). Plaintext is the JSON-encoded
/// comment body + optional vouch_mac + optional parent_comment_id.
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted_payload: Option<Vec<u8>>,
} }
/// Permission level for comments on a post /// Permission level for comments on a post
@ -944,6 +973,13 @@ pub enum CommentPermission {
FollowersOnly, FollowersOnly,
/// Comments disabled /// Comments disabled
None, None,
/// FoF Layer 2: commenter must hold one of the V_x keys in the
/// author's keyring (own V_me + every V_x they received). The author
/// publishes pub_post_set + wrap_slots in the post; commenters trial-
/// decrypt to unlock priv_x for signing. CDN nodes verify the
/// comment's group_sig + pub_x_index before forwarding — kills the
/// bandwidth-DoS attack a single admitted FoF member could mount.
FriendsOfFriends,
} }
impl Default for CommentPermission { impl Default for CommentPermission {
@ -987,6 +1023,65 @@ impl Default for ModerationMode {
} }
} }
/// FoF Layer 2: per-V_x wrap slot in a post header. Dual-derived so
/// one successful AEAD-open yields both the read CEK and the per-V_x
/// signing seed. Real slots and dummy padding slots are byte-identical
/// (98 bytes each); receivers identify "their" slot by successful
/// AEAD decryption, not by position.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrapSlot {
/// 2-byte HMAC prefix. Receivers precompute one per held V_x; the
/// scan iterates only slots whose prefilter matches.
pub prefilter_tag: [u8; 2],
/// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "read"); 48B
/// (32B sealed CEK + 16B tag).
pub read_ciphertext: Vec<u8>,
/// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B
/// (32B sealed priv_x ed25519 seed + 16B tag).
pub sign_ciphertext: Vec<u8>,
}
/// FoF Layer 2: author-signed revocation entry. When a post-holder
/// receives a valid revocation diff, it deletes all locally-stored
/// comments signed by `revoked_pub_x` AND removes the entry from its
/// local pub_post_set, then forwards the diff. Retroactive cleanup.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RevocationEntry {
/// The pub_x being revoked. Must be in the post's pub_post_set
/// at the time the diff is processed.
pub revoked_pub_x: [u8; 32],
/// ms since epoch.
pub revoked_at_ms: u64,
/// Opaque to CDN; used by author UI to display the reason.
pub reason_code: u8,
/// 64-byte ed25519 signature by the post author over
/// (post_id || revoked_pub_x || revoked_at_ms || reason_code).
pub author_sig: Vec<u8>,
}
/// FoF Layer 2: the author-published gating block embedded in a
/// FoF-comment-policy post. Carries the wrap slots + the matching
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
/// initially empty; revocation diffs append over the post's lifetime.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FoFCommentGating {
/// Random 32B nonce. Plays the spec's "post_id in HKDF info" role
/// without circularity (PostId = BLAKE3(post) depends on this field).
pub slot_binder_nonce: [u8; 32],
/// All admitted pub_x's, 1:1 with `wrap_slots` (including dummies).
/// Order is randomized at publish; access-grants append at the tail
/// (Layer 3 resolved decision — pub_x_index stability matters more
/// than the small tail-positional-recency leak).
pub pub_post_set: Vec<[u8; 32]>,
/// Real wrap slots + dummy slots, shuffled at publish. 1:1 with
/// `pub_post_set`.
pub wrap_slots: Vec<WrapSlot>,
/// Initially empty. Receivers accumulate revocations as diffs
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
pub revocation_list: Vec<RevocationEntry>,
}
/// Author-controlled engagement policy for a post /// Author-controlled engagement policy for a post
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommentPolicy { pub struct CommentPolicy {