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];

View file

@ -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<u32> {
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<usize> {
// 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

View file

@ -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<Vec<(PostId, [u8; 32], u32)>> {
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<u8> = row.get(0)?;
let pub_x: Vec<u8> = 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)`.