diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 8d0e90d..c66ea6d 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -37,19 +37,22 @@ pub fn build_fof_comment_gating( storage: &Storage, author_persona_id: &NodeId, ) -> Result> { - // Gather the author's keyring: own current V_me + all unique - // received V_x's (deduped at the byte level per Layer 3). - let Some((_own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else { + // Gather the author's keyring with provenance: (V_x, owner, epoch). + // The author's own V_me appears with owner=author_persona_id. + let Some((own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else { return Ok(None); }; let received = storage.list_received_vouch_keys(author_persona_id)?; - // Dedup at the V_x byte level. Keep the highest epoch per (owner, key). - let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len()); - unique_keys.push(own_v_me); - for (_owner, _epoch, key) in &received { - if !unique_keys.iter().any(|existing| existing == key) { - unique_keys.push(*key); + // Dedup at the V_x byte level — keep the first sighting (which is + // own_v_me for the author's slot, then received keys in storage + // order). Per Layer 3 spec: one slot per unique V_x. + let mut tagged_keys: Vec<([u8; 32], NodeId, u32)> = + Vec::with_capacity(1 + received.len()); + tagged_keys.push((own_v_me, *author_persona_id, own_epoch)); + for (owner, epoch, key) in &received { + if !tagged_keys.iter().any(|(existing, _, _)| existing == key) { + tagged_keys.push((*key, *owner, *epoch)); } } @@ -61,10 +64,16 @@ pub fn build_fof_comment_gating( // Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2 // resolved decision — per-post keypair generation). Then seal the - // slot. Build pub_post_set in lockstep with wrap_slots so the - // .len() invariant holds and indices map cleanly. - let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len()); - for v_x in &unique_keys { + // slot. + // + // We carry a `kind` tag through the shuffle so we can recover + // (slot_index, owner, epoch, pub_x) afterward for provenance. + enum EntryKind { + Real { v_x_owner: NodeId, v_x_epoch: u32 }, + Dummy, + } + let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); + for (v_x, owner, epoch) in &tagged_keys { let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); @@ -76,12 +85,10 @@ pub fn build_fof_comment_gating( read_ciphertext: sealed.read_ciphertext, sign_ciphertext: sealed.sign_ciphertext, }; - entries.push((pub_x, slot)); + entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); } - // Pad to bucket with dummies. Dummy pub_x = 32B random bytes (no - // priv_x exists; group_sig verification against it will always - // fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x. + // Pad to bucket with dummies. let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); let mut rng = rand::rng(); while entries.len() < bucket { @@ -94,6 +101,7 @@ pub fn build_fof_comment_gating( let mut dummy_sign = vec![0u8; 48]; rng.fill_bytes(&mut dummy_sign); entries.push(( + EntryKind::Dummy, dummy_pub_x, WrapSlot { prefilter_tag: dummy_prefilter, @@ -106,7 +114,21 @@ pub fn build_fof_comment_gating( // Shuffle so real and dummy positions are indistinguishable. entries.shuffle(&mut rng); - let (pub_post_set, wrap_slots): (Vec<_>, Vec<_>) = entries.into_iter().unzip(); + let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); + let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); + let mut real_slot_provenance: Vec = Vec::new(); + for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { + if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { + real_slot_provenance.push(RealSlotProvenance { + slot_index: idx as u32, + v_x_owner, + v_x_epoch, + pub_x, + }); + } + pub_post_set.push(pub_x); + wrap_slots.push(slot); + } let gating = FoFCommentGating { slot_binder_nonce, @@ -117,19 +139,16 @@ pub fn build_fof_comment_gating( Ok(Some(FoFCommentGatingBuilt { gating, - // Returned to the caller because the author needs them locally: - // - cek: to decrypt their own comments later - // - own pub_x: to find their own slot in pub_post_set for - // authoring author-side comments + future access-grants cek, slot_binder_nonce, + real_slot_provenance, })) } /// Output of [`build_fof_comment_gating`]. The gating block goes into /// `Post.fof_gating`; the side outputs are author-local state the -/// caller should cache (e.g., in `own_post_slot_provenance` introduced -/// in the cascade-revocation slice later). +/// caller persists (CEK cache, `own_post_slot_provenance` for cascade +/// revocations). #[derive(Debug, Clone)] pub struct FoFCommentGatingBuilt { pub gating: FoFCommentGating, @@ -140,6 +159,24 @@ pub struct FoFCommentGatingBuilt { /// Same nonce as `gating.slot_binder_nonce`; mirrored here for /// callers who want it without reaching into the gating struct. pub slot_binder_nonce: [u8; 32], + /// FoF Layer 4 provenance: which V_x sealed which slot. Real + /// slots only (dummies are excluded). Caller persists into + /// `own_post_slot_provenance` for later cascade revocations. + /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). + pub real_slot_provenance: Vec, +} + +/// One entry per real (non-dummy) slot in a published FoF post. +#[derive(Debug, Clone)] +pub struct RealSlotProvenance { + pub slot_index: u32, + /// Persona who issued the V_x this slot was sealed under. For the + /// author's own slot this is the author themselves. + pub v_x_owner: NodeId, + /// V_x epoch — important for cascade revocation when an old + /// epoch is retired. + pub v_x_epoch: u32, + pub pub_x: [u8; 32], } // --- Reader / commenter side --- @@ -1169,6 +1206,77 @@ mod tests { "different bodies in the same bucket produce same-sized ciphertexts"); } + /// Provenance roundtrip: build_fof_comment_gating populates + /// real_slot_provenance; entries match the actual real slots in + /// the gating block. + #[test] + fn fof_gating_real_slot_provenance() { + use crate::types::PostingIdentity; + use ed25519_dalek::SigningKey; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(80); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 7, &v_me_alice, 1000).unwrap(); + + // Two received V_x's at different epochs. + let (bob_id, _) = make_persona(81); + let (carol_id, _) = make_persona(82); + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + let mut v_x_carol = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_carol); + s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); + s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + // 3 unique V_x's → 3 real slots, padded to bucket 8. + assert_eq!(built.real_slot_provenance.len(), 3); + assert_eq!(built.gating.pub_post_set.len(), 8); + + // Provenance entries must reference real positions whose pub_x + // matches gating.pub_post_set[slot_index]. + for prov in &built.real_slot_provenance { + assert_eq!( + built.gating.pub_post_set[prov.slot_index as usize], + prov.pub_x, + "provenance pub_x matches gating at indexed slot" + ); + } + + // Provenance covers exactly Alice's own V_me (epoch 7) + Bob (3) + Carol (5). + let owners: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_owner).collect(); + assert!(owners.contains(&alice_id)); + assert!(owners.contains(&bob_id)); + assert!(owners.contains(&carol_id)); + let epochs: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_epoch).collect(); + assert!(epochs.contains(&7)); + assert!(epochs.contains(&3)); + assert!(epochs.contains(&5)); + + // The pub_x derived from each real slot's signing seed must + // match the published pub_post_set entry. + for prov in &built.real_slot_provenance { + for v_x in [v_me_alice, v_x_bob, v_x_carol].iter() { + if let Some(opened) = crate::crypto::open_wrap_slot( + v_x, &built.slot_binder_nonce, + &built.gating.wrap_slots[prov.slot_index as usize].read_ciphertext, + &built.gating.wrap_slots[prov.slot_index as usize].sign_ciphertext, + ) { + let derived = SigningKey::from_bytes(&opened.priv_x_seed) + .verifying_key().to_bytes(); + assert_eq!(derived, prov.pub_x); + break; + } + } + } + } + #[test] fn fof_revocation_wrong_author_rejected() { let post_id = [0x01; 32]; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 8e78fef..710dfa2 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1065,6 +1065,7 @@ impl Node { ))? }; let cek = built.cek; + let provenance = built.real_slot_provenance.clone(); let (post_id, post, visibility) = self.create_post_inner( &self.default_posting_id, &self.default_posting_secret, @@ -1073,6 +1074,20 @@ impl Node { attachment_data, Some(built.gating), ).await?; + + // FoF Layer 4: persist provenance so cascade-revocation can + // resolve "which pub_x's on which of my posts were sealed + // under V_me epoch N" later. + { + let storage = self.storage.get().await; + for entry in &provenance { + let _ = storage.record_post_slot_provenance( + &self.default_posting_id, &post_id, entry.slot_index, + &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, + ); + } + } + Ok((post_id, post, visibility, cek)) } @@ -1132,6 +1147,7 @@ impl Node { }; let cek = built.cek; let slot_binder_nonce = built.slot_binder_nonce; + let provenance = built.real_slot_provenance.clone(); // Encrypt + pad body under the gating CEK. Output is base64'd // so it can live in Post.content (which is a String). @@ -1162,6 +1178,13 @@ impl Node { &PostVisibility::FoFClosed, &VisibilityIntent::Public, )?; + // FoF Layer 4: persist provenance for cascade-revoke. + for entry in &provenance { + let _ = storage.record_post_slot_provenance( + &self.default_posting_id, &post_id, entry.slot_index, + &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, + ); + } } self.update_neighbor_manifests_as( @@ -1886,6 +1909,84 @@ impl Node { Ok(()) } + /// FoF Layer 4: pure V_me rotation. Generates a new V_me epoch for + /// the default persona without revoking any vouchee. Republishes + /// the persona's bio post under the new key for every current + /// target. Used for periodic refresh or leak response (combined + /// with `cascade_revoke_v_me_epoch` for old-content cleanup + + /// `key_burn_post` for leaked-key scenarios). + /// + /// Returns the new epoch number. + pub async fn rotate_v_me(&self) -> anyhow::Result { + use rand::RngCore; + let (default_id, display_name, bio, avatar_cid, posting_secret, new_epoch) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + anyhow::bail!("no default posting identity"); + }; + let pi = storage.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity missing"))?; + let profile = storage.get_profile(&default_id)?; + let (name, bio, avatar) = match profile { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (pi.display_name.clone(), String::new(), None), + }; + + let next_epoch = storage.current_own_vouch_key(&default_id)? + .map(|(e, _)| e + 1) + .unwrap_or(1); + let mut new_key = [0u8; 32]; + rand::rng().fill_bytes(&mut new_key); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; + (default_id, name, bio, avatar, pi.secret_seed, next_epoch) + }; + + // Republish bio so existing vouch targets receive the new key. + self.publish_profile_post_as( + &default_id, &posting_secret, &display_name, &bio, avatar_cid, + ).await?; + Ok(new_epoch) + } + + /// FoF Layer 4: cascade revocation. For every FoF post authored by + /// the default persona where slots were sealed under V_me at + /// `retired_epoch`, publish a per-pub_x revocation diff. Existing + /// stored comments by those pub_x's are cascade-deleted via the + /// standard apply_fof_revocation path. + /// + /// Returns the number of post-level revocations published. + /// Typically called after `rotate_v_me` when the user wants to + /// retire access for vouchees they no longer want commenting on + /// old posts. Optional — by default rotation grandfathers old + /// posts. + pub async fn cascade_revoke_v_me_epoch( + &self, + retired_epoch: u32, + reason_code: u8, + ) -> anyhow::Result { + // Look up all (post_id, pub_x) pairs sealed under (self, retired_epoch). + let pairs = { + let storage = self.storage.get().await; + storage.list_provenance_for_v_x_epoch( + &self.default_posting_id, + &self.default_posting_id, + retired_epoch, + )? + }; + let mut published = 0usize; + for (post_id, _pub_x, slot_index) in pairs { + // Use the existing per-post revocation helper. It signs + + // applies locally + propagates. + if self.revoke_fof_commenter(post_id, slot_index, reason_code).await.is_ok() { + published += 1; + } + } + Ok(published) + } + /// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS /// the rotation primitive. The new V_me_epoch is generated and the /// bio post is republished with wrappers for every remaining target diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 271ad30..63ae566 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -490,7 +490,22 @@ impl Storage { PRIMARY KEY (post_id, revoked_pub_x) ); CREATE INDEX IF NOT EXISTS idx_fof_revocations_post - ON fof_revocations(post_id);", + ON fof_revocations(post_id); + -- FoF Layer 4: author-local map of which V_x sealed which + -- slot on which of MY posts. Never on the wire. Used at + -- cascade-revocation time to find pub_x's that need to be + -- revoked when a V_me epoch is retired. + CREATE TABLE IF NOT EXISTS own_post_slot_provenance ( + author_persona_id BLOB NOT NULL, + post_id BLOB NOT NULL, + slot_index INTEGER NOT NULL, + sealed_under_v_x_owner BLOB NOT NULL, + sealed_under_v_x_epoch INTEGER NOT NULL, + pub_x BLOB NOT NULL, + PRIMARY KEY (author_persona_id, post_id, slot_index) + ); + CREATE INDEX IF NOT EXISTS idx_own_post_slot_provenance_v_x + ON own_post_slot_provenance(author_persona_id, sealed_under_v_x_owner, sealed_under_v_x_epoch);", )?; Ok(()) } @@ -5028,6 +5043,73 @@ impl Storage { Ok(()) } + /// FoF Layer 4: record which V_x sealed which slot on one of the + /// author's posts. Called at post-publish time. Used at + /// cascade-revocation time to find the pub_x's that need revoking + /// when a V_me epoch is retired. + pub fn record_post_slot_provenance( + &self, + author_persona_id: &NodeId, + post_id: &PostId, + slot_index: u32, + sealed_under_v_x_owner: &NodeId, + sealed_under_v_x_epoch: u32, + pub_x: &[u8; 32], + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO own_post_slot_provenance + (author_persona_id, post_id, slot_index, + sealed_under_v_x_owner, sealed_under_v_x_epoch, pub_x) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + author_persona_id.as_slice(), + post_id.as_slice(), + slot_index as i64, + sealed_under_v_x_owner.as_slice(), + sealed_under_v_x_epoch as i64, + pub_x.as_slice(), + ], + )?; + Ok(()) + } + + /// FoF Layer 4: list (post_id, pub_x) pairs where the slot was + /// sealed under the given V_x owner+epoch. Used by cascade + /// revocation to identify pub_x's to revoke when retiring an epoch. + pub fn list_provenance_for_v_x_epoch( + &self, + author_persona_id: &NodeId, + owner: &NodeId, + epoch: u32, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT post_id, pub_x, slot_index FROM own_post_slot_provenance + WHERE author_persona_id = ?1 + AND sealed_under_v_x_owner = ?2 + AND sealed_under_v_x_epoch = ?3", + )?; + let rows = stmt.query_map(params![ + author_persona_id.as_slice(), + owner.as_slice(), + epoch as i64, + ], |row| { + let pid: Vec = row.get(0)?; + let pub_x: Vec = row.get(1)?; + let idx: i64 = row.get(2)?; + Ok((pid, pub_x, idx)) + })?; + let mut out = Vec::new(); + for r in rows { + let (pid, pub_x, idx) = r?; + let pid: PostId = pid.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid post_id in provenance"))?; + let pub_x: [u8; 32] = pub_x.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid pub_x in provenance"))?; + out.push((pid, pub_x, idx as u32)); + } + Ok(out) + } + /// FoF Layer 2: append a new (pub_x, wrap_slot) entry to a stored /// post's fof_gating. Local-only mutation; PostId (in the `id` /// column) is unaffected. Idempotent on `(post_id, new_pub_x)`.