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