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:
Scott Reimers 2026-05-14 15:07:54 -04:00
parent 6a76adef8f
commit 96118d7ce8
5 changed files with 310 additions and 0 deletions

View file

@ -6270,6 +6270,23 @@ impl ConnectionManager {
*revoked_at_ms, *reason_code, author_sig,
);
}
BlobHeaderDiffOp::FoFAccessGrant {
post_id, new_pub_x, new_wrap_slot, granted_at_ms, author_sig,
} => {
let post_author = match storage.get_post(post_id) {
Ok(Some(p)) => p.author,
_ => continue,
};
if !crate::fof::verify_fof_access_grant(
&post_author, post_id, new_pub_x, new_wrap_slot,
*granted_at_ms, author_sig,
) {
continue;
}
let _ = crate::fof::apply_fof_access_grant_locally(
&storage, post_id, new_pub_x, new_wrap_slot,
);
}
BlobHeaderDiffOp::ThreadSplit { new_post_id } => {
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
post_id: *new_post_id,

View file

@ -396,6 +396,82 @@ pub fn verify_fof_revocation(
verifying_key.verify(&bytes, &sig).is_ok()
}
// --- Access-grant: sign + verify + apply ---
/// Bytes covered by a `BlobHeaderDiffOp::FoFAccessGrant.author_sig`.
/// Wrap-slot is canonicalized by serializing its three fields in order:
/// prefilter_tag || read_ciphertext || sign_ciphertext.
fn fof_access_grant_signing_bytes(
post_id: &[u8; 32],
new_pub_x: &[u8; 32],
new_wrap_slot: &crate::types::WrapSlot,
granted_at_ms: u64,
) -> Vec<u8> {
let mut out = Vec::with_capacity(32 + 32 + 2 + 48 + 48 + 8);
out.extend_from_slice(post_id);
out.extend_from_slice(new_pub_x);
out.extend_from_slice(&new_wrap_slot.prefilter_tag);
out.extend_from_slice(&new_wrap_slot.read_ciphertext);
out.extend_from_slice(&new_wrap_slot.sign_ciphertext);
out.extend_from_slice(&granted_at_ms.to_le_bytes());
out
}
/// Author-side: sign an access-grant entry.
pub fn sign_fof_access_grant(
author_secret: &[u8; 32],
post_id: &[u8; 32],
new_pub_x: &[u8; 32],
new_wrap_slot: &crate::types::WrapSlot,
granted_at_ms: u64,
) -> Vec<u8> {
use ed25519_dalek::{Signer, SigningKey};
let signing_key = SigningKey::from_bytes(author_secret);
let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms);
signing_key.sign(&bytes).to_bytes().to_vec()
}
/// CDN-side: verify an access-grant entry's author_sig.
pub fn verify_fof_access_grant(
post_author: &NodeId,
post_id: &[u8; 32],
new_pub_x: &[u8; 32],
new_wrap_slot: &crate::types::WrapSlot,
granted_at_ms: u64,
author_sig: &[u8],
) -> bool {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
if author_sig.len() != 64 { return false; }
let sig_bytes: [u8; 64] = match author_sig.try_into() {
Ok(b) => b, Err(_) => return false,
};
let sig = Signature::from_bytes(&sig_bytes);
let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; };
let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms);
verifying_key.verify(&bytes, &sig).is_ok()
}
/// Apply a verified access-grant to local storage. Appends the new
/// (pub_x, wrap_slot) at the tail of the stored post's fof_gating.
/// Idempotent on `(post_id, new_pub_x)`. Must only be called after
/// `verify_fof_access_grant` returns true.
///
/// Refuses to apply if `new_pub_x` is already in the post's
/// revocation_list (prevents accidental re-admission of a previously-
/// revoked signer; spec-resolved).
pub fn apply_fof_access_grant_locally(
storage: &Storage,
post_id: &[u8; 32],
new_pub_x: &[u8; 32],
new_wrap_slot: &crate::types::WrapSlot,
) -> Result<bool> {
if storage.is_fof_pub_x_revoked(post_id, new_pub_x)? {
return Ok(false);
}
let appended = storage.append_fof_access_grant(post_id, new_pub_x, new_wrap_slot)?;
Ok(appended)
}
/// Apply a verified revocation to local storage + cascade delete
/// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`.
/// Returns the count of comments deleted.
@ -764,6 +840,105 @@ mod tests {
assert_eq!(re_deleted, 0);
}
/// Access-grant roundtrip: Alice publishes a Mode 2 FoF post, then
/// later vouches for Carol. Alice signs an access-grant adding
/// Carol's V_x to the post. apply_fof_access_grant_locally appends
/// the new slot; Carol's device can now unlock the post.
#[test]
fn fof_access_grant_appends_and_unlocks() {
use crate::types::PostingIdentity;
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(60);
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, 1, &v_me_alice, 1000).unwrap();
// Initial gating: Alice only.
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let post_id = [0xBC; 32];
let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
};
s.store_post_with_intent(
&post_id, &post,
&crate::types::PostVisibility::Public,
&crate::types::VisibilityIntent::Public,
).unwrap();
// Carol's V_x (Carol vouches for herself or is granted access).
let mut v_x_carol = [0u8; 32];
rand::rng().fill_bytes(&mut v_x_carol);
// Pre-grant: Carol can NOT unlock the post via her V_x.
let pre_post = s.get_post(&post_id).unwrap().unwrap();
let pre_unlock = pre_post.fof_gating.as_ref()
.map(|g| g.wrap_slots.iter().any(|slot| {
crate::crypto::open_wrap_slot(
&v_x_carol, &g.slot_binder_nonce,
&slot.read_ciphertext, &slot.sign_ciphertext,
).is_some()
}))
.unwrap_or(false);
assert!(!pre_unlock, "Carol cannot unlock pre-grant");
// Alice authors an access-grant: seal a new slot under Carol's V_x.
let mut new_priv_x_seed = [0u8; 32];
rand::rng().fill_bytes(&mut new_priv_x_seed);
let new_pub_x = SigningKey::from_bytes(&new_priv_x_seed)
.verifying_key().to_bytes();
let sealed = crate::crypto::seal_wrap_slot(
&v_x_carol, &built.slot_binder_nonce, &built.cek, &new_priv_x_seed,
).unwrap();
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 = 5000;
let sig = sign_fof_access_grant(
&alice_seed, &post_id, &new_pub_x, &new_wrap_slot, granted_at,
);
assert!(verify_fof_access_grant(
&alice_id, &post_id, &new_pub_x, &new_wrap_slot, granted_at, &sig,
));
let applied = apply_fof_access_grant_locally(
&s, &post_id, &new_pub_x, &new_wrap_slot,
).unwrap();
assert!(applied, "access-grant appended");
// Post-grant: stored post's gating now includes Carol's slot.
let post = s.get_post(&post_id).unwrap().unwrap();
let g = post.fof_gating.as_ref().unwrap();
let unlocked = g.wrap_slots.iter().any(|slot| {
crate::crypto::open_wrap_slot(
&v_x_carol, &g.slot_binder_nonce,
&slot.read_ciphertext, &slot.sign_ciphertext,
).map(|o| o.cek == built.cek).unwrap_or(false)
});
assert!(unlocked, "Carol can now unlock the post and recover Alice's CEK");
// Idempotent: re-applying the same grant is a no-op.
let again = apply_fof_access_grant_locally(
&s, &post_id, &new_pub_x, &new_wrap_slot,
).unwrap();
assert!(!again, "duplicate grant skipped");
// Revocation blocks re-admission.
s.add_fof_revocation(&post_id, &new_pub_x, 6000, 0, &[0u8; 64]).unwrap();
let blocked = apply_fof_access_grant_locally(
&s, &post_id, &new_pub_x, &new_wrap_slot,
).unwrap();
assert!(!blocked, "revoked pub_x must not be re-granted access");
}
#[test]
fn fof_revocation_wrong_author_rejected() {
let post_id = [0x01; 32];

View file

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

View file

@ -5024,6 +5024,30 @@ impl Storage {
Ok(())
}
/// 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)`.
pub fn append_fof_access_grant(
&self,
post_id: &PostId,
new_pub_x: &[u8; 32],
new_wrap_slot: &crate::types::WrapSlot,
) -> anyhow::Result<bool> {
let Some(mut post) = self.get_post(post_id)? else { return Ok(false); };
let Some(mut gating) = post.fof_gating.take() else { return Ok(false); };
if gating.pub_post_set.iter().any(|p| p == new_pub_x) {
return Ok(false); // already present
}
gating.pub_post_set.push(*new_pub_x);
gating.wrap_slots.push(new_wrap_slot.clone());
let gating_json = serde_json::to_string(&gating)?;
self.conn.execute(
"UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2",
params![gating_json, post_id.as_slice()],
)?;
Ok(true)
}
/// FoF Layer 2: record a post-level revocation locally. Idempotent
/// on `(post_id, revoked_pub_x)`. Subsequent incoming comments
/// where `pub_post_set[pub_x_index] == revoked_pub_x` are rejected

View file

@ -1133,6 +1133,19 @@ pub enum BlobHeaderDiffOp {
/// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code).
author_sig: Vec<u8>,
},
/// FoF Layer 2: author retroactively widens read access on a
/// FoF-gated post by appending a new (pub_x, wrap_slot) pair. The
/// newly-vouched persona can now decrypt and comment on the post
/// without a full re-issue. Append-only at the tail per Layer 3.
FoFAccessGrant {
post_id: PostId,
new_pub_x: [u8; 32],
new_wrap_slot: WrapSlot,
granted_at_ms: u64,
/// 64-byte ed25519 sig by post author over
/// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le).
author_sig: Vec<u8>,
},
/// Unknown ops from newer protocol versions — silently ignored
#[serde(other)]
Unknown,