From 96118d7ce88966087fc1d902227c32b6376f7231 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 15:07:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(fof-layer2):=20access-grant=20=E2=80=94=20?= =?UTF-8?q?retroactive=20read+comment=20widening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/core/src/connection.rs | 17 ++++ crates/core/src/fof.rs | 175 ++++++++++++++++++++++++++++++++++ crates/core/src/node.rs | 81 ++++++++++++++++ crates/core/src/storage.rs | 24 +++++ crates/core/src/types.rs | 13 +++ 5 files changed, 310 insertions(+) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 11f262e..f70c9d0 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -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, diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 5cf167c..6bc607d 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -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 { + 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 { + 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 { + 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]; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index c8f77eb..0ab4d1a 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -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> { let storage = self.storage.get().await; diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 95d0eea..d9a3bd6 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -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 { + 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 diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 0771a6e..641ac0d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1133,6 +1133,19 @@ pub enum BlobHeaderDiffOp { /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code). author_sig: Vec, }, + /// 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, + }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown,