diff --git a/crates/core/src/announcement.rs b/crates/core/src/announcement.rs index a3a6c89..098677e 100644 --- a/crates/core/src/announcement.rs +++ b/crates/core/src/announcement.rs @@ -143,6 +143,7 @@ pub fn build_announcement_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 1d4917b..e7b1fb5 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6171,6 +6171,12 @@ impl ConnectionManager { } } 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( &comment.author, diff --git a/crates/core/src/content.rs b/crates/core/src/content.rs index ecb3664..622a016 100644 --- a/crates/core/src/content.rs +++ b/crates/core/src/content.rs @@ -23,6 +23,7 @@ mod tests { content: "hello world".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let id1 = compute_post_id(&post); let id2 = compute_post_id(&post); @@ -36,12 +37,14 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post2 = Post { author: [1u8; 32], content: "world".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; assert_ne!(compute_post_id(&post1), compute_post_id(&post2)); } @@ -53,6 +56,7 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let id = compute_post_id(&post); assert!(verify_post_id(&id, &post)); diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs index 9d75d98..57dd53d 100644 --- a/crates/core/src/control.rs +++ b/crates/core/src/control.rs @@ -155,6 +155,7 @@ pub fn build_delete_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -182,6 +183,7 @@ pub fn build_visibility_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -212,6 +214,7 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); @@ -240,6 +243,7 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index f23335e..d229dab 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -61,6 +61,7 @@ pub fn build_distribution_post( content: ciphertext_b64, attachments: vec![], timestamp_ms, + fof_gating: None, }; let post_id = compute_post_id(&post); let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; @@ -241,6 +242,7 @@ mod tests { content: ciphertext, attachments: vec![], timestamp_ms: 200, + fof_gating: None, }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index ed12d2a..1992868 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -286,6 +286,7 @@ pub async fn import_as_personas( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Preserve the original visibility intent from the export. @@ -459,6 +460,7 @@ pub async fn import_public_posts( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Read blob data from archive @@ -685,6 +687,7 @@ pub async fn merge_with_key( content: plaintext, attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, + fof_gating: None, }; // Read blob data from archive (may need decryption for encrypted posts) diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 114ab41..bc7fd21 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2253,6 +2253,7 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, + fof_gating: None, } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 1e5efb7..44fb070 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -827,6 +827,7 @@ impl Node { content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms: pi.created_at, + fof_gating: None, }; let post_id = crate::content::compute_post_id(&post); { @@ -1162,6 +1163,7 @@ impl Node { content: final_content, attachments, timestamp_ms: now, + fof_gating: None, }; let post_id = compute_post_id(&post); @@ -3081,6 +3083,7 @@ impl Node { content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, + fof_gating: None, }; let new_post_id = compute_post_id(&new_post); @@ -4586,6 +4589,9 @@ impl Node { signature, deleted_at: None, ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }; let storage = self.storage.get().await; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 0ae0183..55970a6 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -195,6 +195,7 @@ pub fn build_profile_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, + fof_gating: None, } } @@ -463,6 +464,7 @@ mod tests { content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, + fof_gating: None, }; // Apply. Auto-scan should fire and store the unwrapped V_me. @@ -531,6 +533,7 @@ mod tests { content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, + fof_gating: None, }; apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 7912b9f..b9452ea 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -893,6 +893,7 @@ impl Storage { content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, + fof_gating: None, })) } else { Ok(None) @@ -919,6 +920,7 @@ impl Storage { content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, + fof_gating: None, }, visibility, ))) @@ -1012,6 +1014,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1050,6 +1053,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1212,6 +1216,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -1247,6 +1252,7 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, + fof_gating: None, }, visibility, )); @@ -2926,6 +2932,7 @@ impl Storage { content: row.get(2)?, attachments, timestamp_ms: row.get::<_, i64>(4)? as u64, + fof_gating: None, }, visibility, )); @@ -5302,6 +5309,9 @@ impl Storage { signature: sig, deleted_at: None, ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }); } Ok(result) @@ -5341,6 +5351,9 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }); } Ok(result) @@ -6100,6 +6113,7 @@ mod tests { content: format!("post at {}", ts), attachments: vec![], timestamp_ms: ts, + fof_gating: None, }; let id = blake3::hash(&serde_json::to_vec(&post).unwrap()); s.store_post(id.as_bytes(), &post).unwrap(); @@ -6797,6 +6811,9 @@ mod tests { signature: vec![0u8; 64], deleted_at: None, ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); s.store_comment(&InlineComment { @@ -6807,6 +6824,9 @@ mod tests { signature: vec![1u8; 64], deleted_at: None, ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -6832,6 +6852,9 @@ mod tests { signature: vec![9u8; 64], deleted_at: None, ref_post_id: Some(ref_post), + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 7e3a54a..ec73145 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -39,6 +39,15 @@ pub struct Post { pub attachments: Vec, /// Unix timestamp in milliseconds 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, } /// A reference to a media blob attached to a post @@ -918,6 +927,9 @@ pub struct InlineComment { pub post_id: PostId, /// Either the full comment text (short comments) or a short preview of /// 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, /// When the comment was created (ms) pub timestamp_ms: u64, @@ -932,6 +944,23 @@ pub struct InlineComment { /// for the expanded view. #[serde(default)] pub ref_post_id: Option, + /// 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, + /// 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>, + /// 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>, } /// Permission level for comments on a post @@ -944,6 +973,13 @@ pub enum CommentPermission { FollowersOnly, /// Comments disabled 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 { @@ -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, + /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B + /// (32B sealed priv_x ed25519 seed + 16B tag). + pub sign_ciphertext: Vec, +} + +/// 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, +} + +/// 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, + /// Initially empty. Receivers accumulate revocations as diffs + /// arrive; the on-wire t=0 snapshot is empty. + #[serde(default)] + pub revocation_list: Vec, +} + /// Author-controlled engagement policy for a post #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommentPolicy {