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:
parent
66b78041fc
commit
c0de21d37b
3 changed files with 316 additions and 25 deletions
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue