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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue