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,
|
storage: &Storage,
|
||||||
author_persona_id: &NodeId,
|
author_persona_id: &NodeId,
|
||||||
) -> Result<Option<FoFCommentGatingBuilt>> {
|
) -> Result<Option<FoFCommentGatingBuilt>> {
|
||||||
// Gather the author's keyring: own current V_me + all unique
|
// Gather the author's keyring with provenance: (V_x, owner, epoch).
|
||||||
// received V_x's (deduped at the byte level per Layer 3).
|
// 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 {
|
let Some((own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let received = storage.list_received_vouch_keys(author_persona_id)?;
|
let received = storage.list_received_vouch_keys(author_persona_id)?;
|
||||||
|
|
||||||
// Dedup at the V_x byte level. Keep the highest epoch per (owner, key).
|
// Dedup at the V_x byte level — keep the first sighting (which is
|
||||||
let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len());
|
// own_v_me for the author's slot, then received keys in storage
|
||||||
unique_keys.push(own_v_me);
|
// order). Per Layer 3 spec: one slot per unique V_x.
|
||||||
for (_owner, _epoch, key) in &received {
|
let mut tagged_keys: Vec<([u8; 32], NodeId, u32)> =
|
||||||
if !unique_keys.iter().any(|existing| existing == key) {
|
Vec::with_capacity(1 + received.len());
|
||||||
unique_keys.push(*key);
|
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
|
// Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2
|
||||||
// resolved decision — per-post keypair generation). Then seal the
|
// resolved decision — per-post keypair generation). Then seal the
|
||||||
// slot. Build pub_post_set in lockstep with wrap_slots so the
|
// slot.
|
||||||
// .len() invariant holds and indices map cleanly.
|
//
|
||||||
let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len());
|
// We carry a `kind` tag through the shuffle so we can recover
|
||||||
for v_x in &unique_keys {
|
// (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];
|
let mut seed = [0u8; 32];
|
||||||
rand::rng().fill_bytes(&mut seed);
|
rand::rng().fill_bytes(&mut seed);
|
||||||
let signing_key = SigningKey::from_bytes(&seed);
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
|
@ -76,12 +85,10 @@ pub fn build_fof_comment_gating(
|
||||||
read_ciphertext: sealed.read_ciphertext,
|
read_ciphertext: sealed.read_ciphertext,
|
||||||
sign_ciphertext: sealed.sign_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
|
// Pad to bucket with dummies.
|
||||||
// priv_x exists; group_sig verification against it will always
|
|
||||||
// fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x.
|
|
||||||
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
|
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
while entries.len() < bucket {
|
while entries.len() < bucket {
|
||||||
|
|
@ -94,6 +101,7 @@ pub fn build_fof_comment_gating(
|
||||||
let mut dummy_sign = vec![0u8; 48];
|
let mut dummy_sign = vec![0u8; 48];
|
||||||
rng.fill_bytes(&mut dummy_sign);
|
rng.fill_bytes(&mut dummy_sign);
|
||||||
entries.push((
|
entries.push((
|
||||||
|
EntryKind::Dummy,
|
||||||
dummy_pub_x,
|
dummy_pub_x,
|
||||||
WrapSlot {
|
WrapSlot {
|
||||||
prefilter_tag: dummy_prefilter,
|
prefilter_tag: dummy_prefilter,
|
||||||
|
|
@ -106,7 +114,21 @@ pub fn build_fof_comment_gating(
|
||||||
// Shuffle so real and dummy positions are indistinguishable.
|
// Shuffle so real and dummy positions are indistinguishable.
|
||||||
entries.shuffle(&mut rng);
|
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 {
|
let gating = FoFCommentGating {
|
||||||
slot_binder_nonce,
|
slot_binder_nonce,
|
||||||
|
|
@ -117,19 +139,16 @@ pub fn build_fof_comment_gating(
|
||||||
|
|
||||||
Ok(Some(FoFCommentGatingBuilt {
|
Ok(Some(FoFCommentGatingBuilt {
|
||||||
gating,
|
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,
|
cek,
|
||||||
slot_binder_nonce,
|
slot_binder_nonce,
|
||||||
|
real_slot_provenance,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of [`build_fof_comment_gating`]. The gating block goes into
|
/// Output of [`build_fof_comment_gating`]. The gating block goes into
|
||||||
/// `Post.fof_gating`; the side outputs are author-local state the
|
/// `Post.fof_gating`; the side outputs are author-local state the
|
||||||
/// caller should cache (e.g., in `own_post_slot_provenance` introduced
|
/// caller persists (CEK cache, `own_post_slot_provenance` for cascade
|
||||||
/// in the cascade-revocation slice later).
|
/// revocations).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FoFCommentGatingBuilt {
|
pub struct FoFCommentGatingBuilt {
|
||||||
pub gating: FoFCommentGating,
|
pub gating: FoFCommentGating,
|
||||||
|
|
@ -140,6 +159,24 @@ pub struct FoFCommentGatingBuilt {
|
||||||
/// Same nonce as `gating.slot_binder_nonce`; mirrored here for
|
/// Same nonce as `gating.slot_binder_nonce`; mirrored here for
|
||||||
/// callers who want it without reaching into the gating struct.
|
/// callers who want it without reaching into the gating struct.
|
||||||
pub slot_binder_nonce: [u8; 32],
|
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 ---
|
// --- Reader / commenter side ---
|
||||||
|
|
@ -1169,6 +1206,77 @@ mod tests {
|
||||||
"different bodies in the same bucket produce same-sized ciphertexts");
|
"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]
|
#[test]
|
||||||
fn fof_revocation_wrong_author_rejected() {
|
fn fof_revocation_wrong_author_rejected() {
|
||||||
let post_id = [0x01; 32];
|
let post_id = [0x01; 32];
|
||||||
|
|
|
||||||
|
|
@ -1065,6 +1065,7 @@ impl Node {
|
||||||
))?
|
))?
|
||||||
};
|
};
|
||||||
let cek = built.cek;
|
let cek = built.cek;
|
||||||
|
let provenance = built.real_slot_provenance.clone();
|
||||||
let (post_id, post, visibility) = self.create_post_inner(
|
let (post_id, post, visibility) = self.create_post_inner(
|
||||||
&self.default_posting_id,
|
&self.default_posting_id,
|
||||||
&self.default_posting_secret,
|
&self.default_posting_secret,
|
||||||
|
|
@ -1073,6 +1074,20 @@ impl Node {
|
||||||
attachment_data,
|
attachment_data,
|
||||||
Some(built.gating),
|
Some(built.gating),
|
||||||
).await?;
|
).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))
|
Ok((post_id, post, visibility, cek))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1132,6 +1147,7 @@ impl Node {
|
||||||
};
|
};
|
||||||
let cek = built.cek;
|
let cek = built.cek;
|
||||||
let slot_binder_nonce = built.slot_binder_nonce;
|
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
|
// Encrypt + pad body under the gating CEK. Output is base64'd
|
||||||
// so it can live in Post.content (which is a String).
|
// so it can live in Post.content (which is a String).
|
||||||
|
|
@ -1162,6 +1178,13 @@ impl Node {
|
||||||
&PostVisibility::FoFClosed,
|
&PostVisibility::FoFClosed,
|
||||||
&VisibilityIntent::Public,
|
&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(
|
self.update_neighbor_manifests_as(
|
||||||
|
|
@ -1886,6 +1909,84 @@ impl Node {
|
||||||
Ok(())
|
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
|
/// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS
|
||||||
/// the rotation primitive. The new V_me_epoch is generated and the
|
/// the rotation primitive. The new V_me_epoch is generated and the
|
||||||
/// bio post is republished with wrappers for every remaining target
|
/// bio post is republished with wrappers for every remaining target
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,22 @@ impl Storage {
|
||||||
PRIMARY KEY (post_id, revoked_pub_x)
|
PRIMARY KEY (post_id, revoked_pub_x)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_fof_revocations_post
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -5028,6 +5043,73 @@ impl Storage {
|
||||||
Ok(())
|
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
|
/// 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`
|
/// post's fof_gating. Local-only mutation; PostId (in the `id`
|
||||||
/// column) is unaffected. Idempotent on `(post_id, new_pub_x)`.
|
/// column) is unaffected. Idempotent on `(post_id, new_pub_x)`.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue