feat(fof-layer4): provenance table + pure V_me rotation + cascade revoke

Lays the foundation for Layer 4 lifecycle operations:

Storage (own_post_slot_provenance):
- New author-local table mapping (post_id, slot_index) to
  (v_x_owner, v_x_epoch, pub_x). Populated at FoF post-publish.
  Used by cascade revocation to find the pub_x's that need revoking
  when a V_me epoch is retired. Never on the wire.
- record_post_slot_provenance + list_provenance_for_v_x_epoch APIs.

fof::build_fof_comment_gating now returns RealSlotProvenance entries
for each real (non-dummy) slot it sealed. Owner = persona who issued
the V_x (author's own persona_id for self-slot). Both Mode 1 and
Mode 2 publish paths persist provenance after compute_post_id.

Node API:
- rotate_v_me() — pure rotation. Generates next V_me epoch in
  vouch_keys_own (old epoch retained, is_current=0), republishes bio
  for existing vouch targets. Returns new epoch. Used for periodic
  refresh / leak response; doesn't revoke anyone.
- cascade_revoke_v_me_epoch(epoch, reason) — for every post the
  author authored where slots were sealed under (self, epoch),
  publish a per-pub_x revocation diff via revoke_fof_commenter. The
  existing Layer 2 cascade-delete then sweeps locally-stored comments.
  Returns the count of revocations published.

These combine to give the spec's "rotation + optional cascade" UX:
rotate first (cheap, grandfathers old posts), then cascade if the
user wants to actively cut off old-content access.

13 fof tests pass (new: fof_gating_real_slot_provenance asserting
provenance entries match real slots' pub_x values).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:17:47 -06:00
parent 66b78041fc
commit c0de21d37b
3 changed files with 316 additions and 25 deletions

View file

@ -37,19 +37,22 @@ pub fn build_fof_comment_gating(
storage: &Storage,
author_persona_id: &NodeId,
) -> Result<Option<FoFCommentGatingBuilt>> {
// 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<WrapSlot> = Vec::with_capacity(entries.len());
let mut real_slot_provenance: Vec<RealSlotProvenance> = 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<RealSlotProvenance>,
}
/// 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<NodeId> = 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<u32> = 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];