feat(fof-layer2): access-grant — retroactive read+comment widening
Wires the access-grant primitive end-to-end:
Wire format:
- BlobHeaderDiffOp::FoFAccessGrant { post_id, new_pub_x,
new_wrap_slot, granted_at_ms, author_sig }. 64-byte Ed25519 sig
by post author over canonicalized tuple.
fof.rs:
- sign_fof_access_grant / verify_fof_access_grant: identical shape
to revocation but covers (pub_x, wrap_slot, granted_at).
- apply_fof_access_grant_locally: appends to local pub_post_set +
wrap_slots. Refuses to apply if new_pub_x is already revoked
(prevents accidental re-admission of a previously-blocked signer
per Layer 4 resolved decision). Idempotent on (post_id, new_pub_x).
storage.rs:
- append_fof_access_grant(post_id, new_pub_x, new_wrap_slot): mutates
the stored post's fof_gating_json column to append the new entry.
PostId (in id column) is unaffected — local-evolution semantics:
the stored gating diverges from the original t=0 snapshot as
access-grants and revocations land.
connection.rs: receive arm verifies author_sig + applies locally.
Author API (node.rs):
- Node::grant_fof_access(post_id, new_v_x): recovers the post's CEK
by trial-unwrapping the author's own slot (find_unlock_for_post),
generates a fresh per-V_x keypair, seals a new wrap slot under
new_v_x with the same CEK + slot_binder_nonce, signs the grant,
applies locally for immediate UI, then propagates via
propagate_engagement_diff.
New test brings the suite to 142 passing:
- fof_access_grant_appends_and_unlocks: pre-grant Carol cannot
unlock; Alice grants; post-grant Carol unlocks and recovers the
CEK; duplicate grant skipped; revoked pub_x cannot be re-admitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6a76adef8f
commit
96118d7ce8
5 changed files with 310 additions and 0 deletions
|
|
@ -4817,6 +4817,87 @@ impl Node {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// FoF Layer 2: retroactively widen read+comment access on a
|
||||
/// FoF-gated post the caller authored by sealing a fresh wrap slot
|
||||
/// under the given V_x and appending it to the post's gating.
|
||||
/// Propagates as a `FoFAccessGrant` engagement-diff.
|
||||
pub async fn grant_fof_access(
|
||||
&self,
|
||||
post_id: PostId,
|
||||
new_v_x: &[u8; 32],
|
||||
) -> anyhow::Result<()> {
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::RngCore;
|
||||
|
||||
// Resolve post + author + cached CEK + slot_binder_nonce. The
|
||||
// author must be on this device.
|
||||
let (post_author, posting_secret, cek, slot_binder_nonce) = {
|
||||
let storage = self.storage.get().await;
|
||||
let post = storage.get_post(&post_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
|
||||
let gating = post.fof_gating.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?;
|
||||
let identity = storage.get_posting_identity(&post.author)?
|
||||
.ok_or_else(|| anyhow::anyhow!("post author not on this device"))?;
|
||||
|
||||
// Recover the CEK: try every V_x in the author persona's
|
||||
// keyring against the post's slots. The author's own slot
|
||||
// will unwrap and yield CEK.
|
||||
let unlock = crate::fof::find_unlock_for_post(&*storage, &post)?
|
||||
.ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?;
|
||||
(post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce)
|
||||
};
|
||||
|
||||
// Generate a fresh (priv_x, pub_x) keypair, seal a wrap slot
|
||||
// under the new V_x with the same CEK + slot_binder_nonce.
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let new_pub_x = *signing_key.verifying_key().as_bytes();
|
||||
|
||||
let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?;
|
||||
let new_wrap_slot = crate::types::WrapSlot {
|
||||
prefilter_tag: sealed.prefilter_tag,
|
||||
read_ciphertext: sealed.read_ciphertext,
|
||||
sign_ciphertext: sealed.sign_ciphertext,
|
||||
};
|
||||
|
||||
let granted_at_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
let author_sig = crate::fof::sign_fof_access_grant(
|
||||
&posting_secret, &post_id, &new_pub_x, &new_wrap_slot, granted_at_ms,
|
||||
);
|
||||
|
||||
// Apply locally first.
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
let _ = crate::fof::apply_fof_access_grant_locally(
|
||||
&*storage, &post_id, &new_pub_x, &new_wrap_slot,
|
||||
);
|
||||
}
|
||||
|
||||
// Propagate.
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
let diff = crate::protocol::BlobHeaderDiffPayload {
|
||||
post_id,
|
||||
author: post_author,
|
||||
ops: vec![crate::types::BlobHeaderDiffOp::FoFAccessGrant {
|
||||
post_id,
|
||||
new_pub_x,
|
||||
new_wrap_slot,
|
||||
granted_at_ms,
|
||||
author_sig,
|
||||
}],
|
||||
timestamp_ms: now,
|
||||
};
|
||||
self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the comment policy for a post.
|
||||
pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result<Option<crate::types::CommentPolicy>> {
|
||||
let storage = self.storage.get().await;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue