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>
956 lines
39 KiB
Rust
956 lines
39 KiB
Rust
//! FoF Layer 2: post-side construction + verification for FoF-gated
|
|
//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the
|
|
//! wire shape and threat model.
|
|
//!
|
|
//! This module owns:
|
|
//! - **Author publish path** ([`build_fof_comment_gating`]): seal wrap
|
|
//! slots, generate per-V_x signing keypairs, bucket-pad, shuffle.
|
|
//! - **Reader/commenter unlock path** ([`find_unlock_for_post`],
|
|
//! [`build_fof_comment`]): trial-decrypt slots with any held V_x;
|
|
//! if a slot opens, derive priv_x + comments-CEK, encrypt the
|
|
//! comment body, sign with priv_x, attach pub_x_index.
|
|
//!
|
|
//! The CDN four-check verification path and revocation handling live
|
|
//! in sibling modules (added in subsequent slices).
|
|
|
|
use anyhow::Result;
|
|
use ed25519_dalek::SigningKey;
|
|
use rand::seq::SliceRandom;
|
|
use rand::RngCore;
|
|
|
|
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
|
|
use crate::storage::Storage;
|
|
use crate::types::{FoFCommentGating, NodeId, WrapSlot};
|
|
|
|
/// Build the `FoFCommentGating` block for a post about to be published
|
|
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
|
|
/// (own current `V_me` + every distinct received `V_x`) drives the
|
|
/// real-slot set; dummies pad the count to the next bucket.
|
|
///
|
|
/// Returns `None` if the author has no current `V_me` set — every
|
|
/// persona is supposed to have one (auto-generated on creation in
|
|
/// Layer 1), but this gracefully no-ops if not.
|
|
///
|
|
/// Side effect: this function is pure; no storage writes. The caller
|
|
/// owns persisting the resulting Post.
|
|
pub fn build_fof_comment_gating(
|
|
storage: &Storage,
|
|
author_persona_id: &NodeId,
|
|
) -> Result<Option<FoFCommentGatingBuilt>> {
|
|
// Gather the author's keyring: own current V_me + all unique
|
|
// received V_x's (deduped at the byte level per Layer 3).
|
|
let Some((_own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else {
|
|
return Ok(None);
|
|
};
|
|
let received = storage.list_received_vouch_keys(author_persona_id)?;
|
|
|
|
// Dedup at the V_x byte level. Keep the highest epoch per (owner, key).
|
|
let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len());
|
|
unique_keys.push(own_v_me);
|
|
for (_owner, _epoch, key) in &received {
|
|
if !unique_keys.iter().any(|existing| existing == key) {
|
|
unique_keys.push(*key);
|
|
}
|
|
}
|
|
|
|
// Generate the per-post CEK + slot_binder_nonce.
|
|
let mut cek = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut cek);
|
|
let mut slot_binder_nonce = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut slot_binder_nonce);
|
|
|
|
// Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2
|
|
// resolved decision — per-post keypair generation). Then seal the
|
|
// slot. Build pub_post_set in lockstep with wrap_slots so the
|
|
// .len() invariant holds and indices map cleanly.
|
|
let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len());
|
|
for v_x in &unique_keys {
|
|
let mut seed = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut seed);
|
|
let signing_key = SigningKey::from_bytes(&seed);
|
|
let pub_x = *signing_key.verifying_key().as_bytes();
|
|
|
|
let sealed: SealedWrapSlot = seal_wrap_slot(v_x, &slot_binder_nonce, &cek, &seed)?;
|
|
let slot = WrapSlot {
|
|
prefilter_tag: sealed.prefilter_tag,
|
|
read_ciphertext: sealed.read_ciphertext,
|
|
sign_ciphertext: sealed.sign_ciphertext,
|
|
};
|
|
entries.push((pub_x, slot));
|
|
}
|
|
|
|
// Pad to bucket with dummies. Dummy pub_x = 32B random bytes (no
|
|
// priv_x exists; group_sig verification against it will always
|
|
// fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x.
|
|
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
|
|
let mut rng = rand::rng();
|
|
while entries.len() < bucket {
|
|
let mut dummy_pub_x = [0u8; 32];
|
|
rng.fill_bytes(&mut dummy_pub_x);
|
|
let mut dummy_prefilter = [0u8; 2];
|
|
rng.fill_bytes(&mut dummy_prefilter);
|
|
let mut dummy_read = vec![0u8; 48];
|
|
rng.fill_bytes(&mut dummy_read);
|
|
let mut dummy_sign = vec![0u8; 48];
|
|
rng.fill_bytes(&mut dummy_sign);
|
|
entries.push((
|
|
dummy_pub_x,
|
|
WrapSlot {
|
|
prefilter_tag: dummy_prefilter,
|
|
read_ciphertext: dummy_read,
|
|
sign_ciphertext: dummy_sign,
|
|
},
|
|
));
|
|
}
|
|
|
|
// Shuffle so real and dummy positions are indistinguishable.
|
|
entries.shuffle(&mut rng);
|
|
|
|
let (pub_post_set, wrap_slots): (Vec<_>, Vec<_>) = entries.into_iter().unzip();
|
|
|
|
let gating = FoFCommentGating {
|
|
slot_binder_nonce,
|
|
pub_post_set,
|
|
wrap_slots,
|
|
revocation_list: Vec::new(),
|
|
};
|
|
|
|
Ok(Some(FoFCommentGatingBuilt {
|
|
gating,
|
|
// Returned to the caller because the author needs them locally:
|
|
// - cek: to decrypt their own comments later
|
|
// - own pub_x: to find their own slot in pub_post_set for
|
|
// authoring author-side comments + future access-grants
|
|
cek,
|
|
slot_binder_nonce,
|
|
}))
|
|
}
|
|
|
|
/// Output of [`build_fof_comment_gating`]. The gating block goes into
|
|
/// `Post.fof_gating`; the side outputs are author-local state the
|
|
/// caller should cache (e.g., in `own_post_slot_provenance` introduced
|
|
/// in the cascade-revocation slice later).
|
|
#[derive(Debug, Clone)]
|
|
pub struct FoFCommentGatingBuilt {
|
|
pub gating: FoFCommentGating,
|
|
/// The post's body/comments encryption key. Authors keep this
|
|
/// locally keyed by `post_id` so they can read/decrypt without
|
|
/// needing to unwrap a slot themselves.
|
|
pub cek: [u8; 32],
|
|
/// Same nonce as `gating.slot_binder_nonce`; mirrored here for
|
|
/// callers who want it without reaching into the gating struct.
|
|
pub slot_binder_nonce: [u8; 32],
|
|
}
|
|
|
|
// --- Reader / commenter side ---
|
|
|
|
/// FoF Layer 2: a persona's successful unlock of a gated post.
|
|
/// Carries everything the persona needs to read encrypted comments AND
|
|
/// author new ones.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostUnlock {
|
|
/// Which persona unlocked the post (the V_x that matched belongs to
|
|
/// this persona's keyring — owned `V_me` or received).
|
|
pub persona_id: NodeId,
|
|
/// Index into `pub_post_set` / `wrap_slots` of the slot that
|
|
/// unlocked. Comments author by this persona will set
|
|
/// `InlineComment.pub_x_index` to this.
|
|
pub slot_index: u32,
|
|
/// Per-post shared CEK (unwrapped from the slot's read part).
|
|
pub cek: [u8; 32],
|
|
/// Ed25519 signing seed for the per-V_x keypair admitted to this
|
|
/// post (unwrapped from the slot's sign part). Used to sign
|
|
/// `group_sig` on FoF comments.
|
|
pub priv_x_seed: [u8; 32],
|
|
}
|
|
|
|
/// Trial-decrypt the post's `fof_gating.wrap_slots` against every
|
|
/// persona on this device. Returns the first successful unlock found,
|
|
/// or `None` if no held V_x matches.
|
|
///
|
|
/// Iteration order: personas as listed by storage; within each persona,
|
|
/// own current `V_me` first, then received V_x's. Slots are scanned in
|
|
/// order; the 2B prefilter lets us skip non-matching slots in O(1) per.
|
|
pub fn find_unlock_for_post(
|
|
storage: &Storage,
|
|
post: &crate::types::Post,
|
|
) -> Result<Option<PostUnlock>> {
|
|
let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); };
|
|
let personas = storage.list_posting_identities()?;
|
|
for persona in &personas {
|
|
// Build this persona's V_x ring: own current + every received.
|
|
let mut keys: Vec<[u8; 32]> = Vec::new();
|
|
if let Some((_, own_key)) = storage.current_own_vouch_key(&persona.node_id)? {
|
|
keys.push(own_key);
|
|
}
|
|
for (_owner, _epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? {
|
|
keys.push(key);
|
|
}
|
|
for v_x in &keys {
|
|
let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce);
|
|
for (idx, slot) in gating.wrap_slots.iter().enumerate() {
|
|
if slot.prefilter_tag != prefilter {
|
|
continue;
|
|
}
|
|
if let Some(opened) = crate::crypto::open_wrap_slot(
|
|
v_x,
|
|
&gating.slot_binder_nonce,
|
|
&slot.read_ciphertext,
|
|
&slot.sign_ciphertext,
|
|
) {
|
|
return Ok(Some(PostUnlock {
|
|
persona_id: persona.node_id,
|
|
slot_index: idx as u32,
|
|
cek: opened.cek,
|
|
priv_x_seed: opened.priv_x_seed,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped
|
|
/// inside [`crate::types::InlineComment::encrypted_payload`].
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct FoFCommentPayload {
|
|
/// User-visible comment body.
|
|
pub body: String,
|
|
/// Optional reply parent.
|
|
#[serde(default)]
|
|
pub parent_comment_id: Option<[u8; 32]>,
|
|
/// HMAC(V_x, post_id || comment_hash)[:16B] — author-side
|
|
/// attribution to a specific voucher-chain. Computed by the
|
|
/// commenter; not yet enforced in this commit.
|
|
#[serde(default)]
|
|
pub vouch_mac: Option<[u8; 16]>,
|
|
}
|
|
|
|
/// Build a Mode 2 / Mode 1 FoF comment on a parent post. The post must
|
|
/// have `fof_gating = Some`; the caller must already hold a successful
|
|
/// `PostUnlock` for it (typically from [`find_unlock_for_post`]).
|
|
///
|
|
/// Produces an `InlineComment` with empty `content` (the body lives
|
|
/// encrypted in `encrypted_payload`) + `pub_x_index` + `group_sig` +
|
|
/// `encrypted_payload`. The conventional `signature` field carries the
|
|
/// commenter's identity-key signature over the existing payload (so
|
|
/// non-FoF nodes can still verify identity without unwrapping the slot).
|
|
pub fn build_fof_comment(
|
|
parent_post_id: &[u8; 32],
|
|
unlock: &PostUnlock,
|
|
slot_binder_nonce: &[u8; 32],
|
|
commenter_id: &NodeId,
|
|
commenter_secret: &[u8; 32],
|
|
body: &str,
|
|
parent_comment_id: Option<[u8; 32]>,
|
|
now_ms: u64,
|
|
) -> Result<crate::types::InlineComment> {
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
|
|
// Encrypt the comment body under CEK_comments derived from the
|
|
// post's CEK + slot_binder_nonce.
|
|
let cek_comments = crate::crypto::derive_cek_comments(&unlock.cek, slot_binder_nonce);
|
|
let payload = FoFCommentPayload {
|
|
body: body.to_string(),
|
|
parent_comment_id,
|
|
vouch_mac: None,
|
|
};
|
|
let plaintext = serde_json::to_vec(&payload)
|
|
.map_err(|e| anyhow::anyhow!("serialize fof comment payload: {}", e))?;
|
|
let encrypted = crate::crypto::encrypt_bytes_with_cek(&plaintext, &cek_comments)?;
|
|
|
|
// Sign (encrypted || post_id || pub_x_index_le) with priv_x.
|
|
let priv_x_signer = SigningKey::from_bytes(&unlock.priv_x_seed);
|
|
let mut to_sign = Vec::with_capacity(encrypted.len() + 32 + 4);
|
|
to_sign.extend_from_slice(&encrypted);
|
|
to_sign.extend_from_slice(parent_post_id);
|
|
to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes());
|
|
let group_sig = priv_x_signer.sign(&to_sign).to_bytes().to_vec();
|
|
|
|
// Conventional InlineComment.signature: commenter's identity-key
|
|
// signature over the existing comment_signature tuple. We use empty
|
|
// content here (the body lives in encrypted_payload). Existing
|
|
// non-FoF receivers can still verify the identity sig and tombstone
|
|
// logic continues to work.
|
|
let identity_signature = crate::crypto::sign_comment(
|
|
commenter_secret,
|
|
commenter_id,
|
|
parent_post_id,
|
|
"",
|
|
now_ms,
|
|
None,
|
|
);
|
|
|
|
Ok(crate::types::InlineComment {
|
|
author: *commenter_id,
|
|
post_id: *parent_post_id,
|
|
content: String::new(),
|
|
timestamp_ms: now_ms,
|
|
signature: identity_signature,
|
|
deleted_at: None,
|
|
ref_post_id: None,
|
|
pub_x_index: Some(unlock.slot_index),
|
|
group_sig: Some(group_sig),
|
|
encrypted_payload: Some(encrypted),
|
|
})
|
|
}
|
|
|
|
/// Verify the `group_sig` on an incoming FoF comment against the post's
|
|
/// `pub_post_set`. Used by the CDN four-check accept rule (next slice).
|
|
/// Returns `true` iff the comment carries a valid Ed25519 signature
|
|
/// under `pub_post_set[pub_x_index]` over
|
|
/// (encrypted_payload || post_id || pub_x_index_le).
|
|
pub fn verify_fof_group_sig(
|
|
comment: &crate::types::InlineComment,
|
|
gating: &FoFCommentGating,
|
|
) -> bool {
|
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
|
let Some(pub_x_index) = comment.pub_x_index else { return false; };
|
|
let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
|
|
let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
|
|
let idx = pub_x_index as usize;
|
|
if idx >= gating.pub_post_set.len() { return false; }
|
|
if group_sig.len() != 64 { return false; }
|
|
let pub_x = &gating.pub_post_set[idx];
|
|
let Ok(verifying_key) = VerifyingKey::from_bytes(pub_x) else { return false; };
|
|
let sig_bytes: [u8; 64] = match group_sig.as_slice().try_into() {
|
|
Ok(b) => b, Err(_) => return false,
|
|
};
|
|
let sig = Signature::from_bytes(&sig_bytes);
|
|
let mut to_verify = Vec::with_capacity(encrypted_payload.len() + 32 + 4);
|
|
to_verify.extend_from_slice(encrypted_payload);
|
|
to_verify.extend_from_slice(&comment.post_id);
|
|
to_verify.extend_from_slice(&pub_x_index.to_le_bytes());
|
|
verifying_key.verify(&to_verify, &sig).is_ok()
|
|
}
|
|
|
|
/// Decrypt the `encrypted_payload` of an FoF comment back to its
|
|
/// plaintext body / vouch_mac / parent_comment_id, using the CEK
|
|
/// recovered via [`find_unlock_for_post`].
|
|
pub fn decrypt_fof_comment_payload(
|
|
comment: &crate::types::InlineComment,
|
|
cek: &[u8; 32],
|
|
slot_binder_nonce: &[u8; 32],
|
|
) -> Result<FoFCommentPayload> {
|
|
let encrypted = comment.encrypted_payload.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("comment has no encrypted_payload"))?;
|
|
let cek_comments = crate::crypto::derive_cek_comments(cek, slot_binder_nonce);
|
|
let plaintext = crate::crypto::decrypt_bytes_with_cek(encrypted, &cek_comments)?;
|
|
serde_json::from_slice(&plaintext)
|
|
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
|
|
}
|
|
|
|
// --- Revocation: sign + verify + apply ---
|
|
|
|
/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`.
|
|
/// Constructed identically on both ends so the verify is deterministic.
|
|
fn fof_revocation_signing_bytes(
|
|
post_id: &[u8; 32],
|
|
revoked_pub_x: &[u8; 32],
|
|
revoked_at_ms: u64,
|
|
reason_code: u8,
|
|
) -> [u8; 32 + 32 + 8 + 1] {
|
|
let mut out = [0u8; 32 + 32 + 8 + 1];
|
|
out[..32].copy_from_slice(post_id);
|
|
out[32..64].copy_from_slice(revoked_pub_x);
|
|
out[64..72].copy_from_slice(&revoked_at_ms.to_le_bytes());
|
|
out[72] = reason_code;
|
|
out
|
|
}
|
|
|
|
/// Author-side: sign a revocation entry with the post author's
|
|
/// identity secret. Returns the 64-byte Ed25519 signature.
|
|
pub fn sign_fof_revocation(
|
|
author_secret: &[u8; 32],
|
|
post_id: &[u8; 32],
|
|
revoked_pub_x: &[u8; 32],
|
|
revoked_at_ms: u64,
|
|
reason_code: u8,
|
|
) -> Vec<u8> {
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
let signing_key = SigningKey::from_bytes(author_secret);
|
|
let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code);
|
|
signing_key.sign(&bytes).to_bytes().to_vec()
|
|
}
|
|
|
|
/// CDN-side: verify a revocation entry's author_sig against the post
|
|
/// author's public key. Returns `false` on any shape/key/signature
|
|
/// failure.
|
|
pub fn verify_fof_revocation(
|
|
post_author: &NodeId,
|
|
post_id: &[u8; 32],
|
|
revoked_pub_x: &[u8; 32],
|
|
revoked_at_ms: u64,
|
|
reason_code: u8,
|
|
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_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code);
|
|
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.
|
|
///
|
|
/// Must only be called after `verify_fof_revocation` returns true.
|
|
/// The caller (CDN receive path) is responsible for that gate.
|
|
pub fn apply_fof_revocation_locally(
|
|
storage: &Storage,
|
|
post_id: &[u8; 32],
|
|
revoked_pub_x: &[u8; 32],
|
|
revoked_at_ms: u64,
|
|
reason_code: u8,
|
|
author_sig: &[u8],
|
|
) -> Result<usize> {
|
|
storage.add_fof_revocation(post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig)?;
|
|
|
|
// Resolve pub_x -> pub_x_index via the post's pub_post_set, then
|
|
// cascade-delete locally-stored comments matching that index.
|
|
let Some(post) = storage.get_post(post_id)? else { return Ok(0); };
|
|
let Some(gating) = post.fof_gating.as_ref() else { return Ok(0); };
|
|
let mut deleted = 0;
|
|
for (idx, pub_x) in gating.pub_post_set.iter().enumerate() {
|
|
if pub_x == revoked_pub_x {
|
|
deleted += storage.delete_fof_comments_by_pub_x_index(post_id, idx as u32)?;
|
|
}
|
|
}
|
|
Ok(deleted)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::Storage;
|
|
use crate::crypto::{ed25519_seed_to_x25519_private, open_wrap_slot};
|
|
use ed25519_dalek::SigningKey;
|
|
use rand::RngCore;
|
|
|
|
fn temp_storage() -> Storage {
|
|
Storage::open(":memory:").unwrap()
|
|
}
|
|
|
|
fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) {
|
|
let mut seed = [0u8; 32];
|
|
seed[0] = seed_byte;
|
|
let signing_key = SigningKey::from_bytes(&seed);
|
|
(*signing_key.verifying_key().as_bytes(), seed)
|
|
}
|
|
|
|
/// Author has a V_me + one received V_x → build_fof_comment_gating
|
|
/// produces a real-slot-count of 2 (own + received), padded to
|
|
/// bucket 8.
|
|
#[test]
|
|
fn build_gating_realcount_and_padding() {
|
|
let s = temp_storage();
|
|
let (alice_id, _alice_seed) = make_persona(1);
|
|
let (bob_id, _bob_seed) = make_persona(2);
|
|
|
|
// Alice has her own V_me
|
|
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();
|
|
|
|
// Alice received a V_x from Bob
|
|
let mut v_x_bob = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut v_x_bob);
|
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
|
|
|
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built");
|
|
// Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
|
|
assert_eq!(built.gating.pub_post_set.len(), 8);
|
|
assert_eq!(built.gating.wrap_slots.len(), 8);
|
|
assert_eq!(built.gating.revocation_list.len(), 0);
|
|
|
|
// Every slot is exactly 48+48 bytes; prefilter is 2 bytes.
|
|
for slot in &built.gating.wrap_slots {
|
|
assert_eq!(slot.read_ciphertext.len(), 48);
|
|
assert_eq!(slot.sign_ciphertext.len(), 48);
|
|
}
|
|
|
|
// Alice can find HER OWN slot by trial-unwrap with her V_me.
|
|
let mut own_hit = None;
|
|
for (idx, slot) in built.gating.wrap_slots.iter().enumerate() {
|
|
if let Some(opened) = open_wrap_slot(
|
|
&v_me_alice, &built.slot_binder_nonce,
|
|
&slot.read_ciphertext, &slot.sign_ciphertext,
|
|
) {
|
|
assert_eq!(opened.cek, built.cek);
|
|
own_hit = Some((idx, opened));
|
|
break;
|
|
}
|
|
}
|
|
let (own_idx, opened) = own_hit.expect("author's own V_me must unlock one slot");
|
|
|
|
// The pub_x in pub_post_set at the same index must match the
|
|
// ed25519 pubkey derived from the unwrapped priv_x seed.
|
|
let signing_key = SigningKey::from_bytes(&opened.priv_x_seed);
|
|
let derived_pub_x = signing_key.verifying_key().to_bytes();
|
|
assert_eq!(built.gating.pub_post_set[own_idx], derived_pub_x);
|
|
|
|
// A holder of V_x_bob can also unlock exactly one slot (Bob's
|
|
// chain to Alice's post).
|
|
let mut bob_hit = None;
|
|
for (idx, slot) in built.gating.wrap_slots.iter().enumerate() {
|
|
if let Some(opened) = open_wrap_slot(
|
|
&v_x_bob, &built.slot_binder_nonce,
|
|
&slot.read_ciphertext, &slot.sign_ciphertext,
|
|
) {
|
|
bob_hit = Some((idx, opened));
|
|
break;
|
|
}
|
|
}
|
|
let (bob_idx, bob_opened) = bob_hit.expect("Bob's V_x must unlock one slot");
|
|
assert_ne!(bob_idx, own_idx, "different V_x's must hit different slots");
|
|
assert_eq!(bob_opened.cek, built.cek, "same CEK across all real slots");
|
|
}
|
|
|
|
#[test]
|
|
fn build_gating_returns_none_without_vme() {
|
|
let s = temp_storage();
|
|
let (alice_id, _) = make_persona(5);
|
|
// No V_me inserted.
|
|
let built = build_fof_comment_gating(&s, &alice_id).unwrap();
|
|
assert!(built.is_none(), "no V_me → no gating block");
|
|
}
|
|
|
|
#[test]
|
|
fn build_gating_deduplicates_repeated_v_x() {
|
|
let s = temp_storage();
|
|
let (alice_id, _) = make_persona(7);
|
|
let (bob_id, _) = make_persona(8);
|
|
let (carol_id, _) = make_persona(9);
|
|
|
|
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();
|
|
|
|
// Two different vouchers happened to issue the SAME key bytes
|
|
// (contrived but tests dedup). Real probability is 2^-256;
|
|
// here we force it for the test.
|
|
let same_key = [0xCC; 32];
|
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
|
|
s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();
|
|
|
|
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
|
// Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
|
|
// We can't assert real count directly without exposing internals,
|
|
// but we can confirm exactly two distinct successful unwraps:
|
|
let mut alice_hits = 0;
|
|
let mut same_key_hits = 0;
|
|
for slot in &built.gating.wrap_slots {
|
|
if open_wrap_slot(&v_me_alice, &built.slot_binder_nonce,
|
|
&slot.read_ciphertext, &slot.sign_ciphertext).is_some() {
|
|
alice_hits += 1;
|
|
}
|
|
if open_wrap_slot(&same_key, &built.slot_binder_nonce,
|
|
&slot.read_ciphertext, &slot.sign_ciphertext).is_some() {
|
|
same_key_hits += 1;
|
|
}
|
|
}
|
|
assert_eq!(alice_hits, 1, "exactly one slot for V_me_alice");
|
|
assert_eq!(same_key_hits, 1, "exactly one slot for the duplicated key (dedup'd)");
|
|
}
|
|
|
|
// Silences the unused-import warning when the X25519 derivation is
|
|
// only exercised in future slices.
|
|
#[test]
|
|
fn x25519_derivation_helper_compiles() {
|
|
let mut seed = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut seed);
|
|
let _ = ed25519_seed_to_x25519_private(&seed);
|
|
}
|
|
|
|
/// End-to-end FoF comment roundtrip:
|
|
/// 1. Alice authors a Mode 2 FoF post (her keyring: own V_me + Bob's V_x).
|
|
/// 2. A "receiver device" (Bob's) holds Bob's V_x. It finds the unlock.
|
|
/// 3. Bob authors a comment on Alice's post.
|
|
/// 4. The comment's group_sig verifies against the post's pub_post_set.
|
|
/// 5. Alice (using her cached cek) decrypts Bob's comment plaintext.
|
|
#[test]
|
|
fn fof_comment_authoring_roundtrip() {
|
|
use crate::types::PostingIdentity;
|
|
use ed25519_dalek::SigningKey;
|
|
|
|
// Alice's device: has her V_me and Bob's V_x (received from Bob).
|
|
let alice_storage = temp_storage();
|
|
let (alice_id, alice_seed) = make_persona(1);
|
|
alice_storage.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);
|
|
alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
|
|
|
let (bob_id, _) = make_persona(2);
|
|
let mut v_x_bob = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut v_x_bob);
|
|
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
|
|
|
// Alice publishes the gating block.
|
|
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
|
|
let parent_post_id = [0xCC; 32];
|
|
|
|
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
|
|
let bob_storage = temp_storage();
|
|
let (bob_id_, bob_seed) = make_persona(2);
|
|
assert_eq!(bob_id, bob_id_);
|
|
bob_storage.upsert_posting_identity(&PostingIdentity {
|
|
node_id: bob_id, secret_seed: bob_seed,
|
|
display_name: "Bob".into(), created_at: 1500,
|
|
}).unwrap();
|
|
bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap();
|
|
|
|
// Wrap Alice's published gating into a Post struct as it would
|
|
// appear on the wire.
|
|
let post = crate::types::Post {
|
|
author: alice_id,
|
|
content: "alice's public post".into(),
|
|
attachments: vec![],
|
|
timestamp_ms: 3000,
|
|
fof_gating: Some(built.gating.clone()),
|
|
};
|
|
|
|
// Bob's device unlocks the post via his V_me (= v_x_bob).
|
|
let unlock = find_unlock_for_post(&bob_storage, &post).unwrap()
|
|
.expect("Bob's persona must unlock the post");
|
|
assert_eq!(unlock.persona_id, bob_id);
|
|
assert_eq!(unlock.cek, built.cek, "Bob recovers Alice's CEK");
|
|
|
|
// Bob authors a comment.
|
|
let comment = build_fof_comment(
|
|
&parent_post_id,
|
|
&unlock,
|
|
&built.slot_binder_nonce,
|
|
&bob_id,
|
|
&bob_seed,
|
|
"great post alice",
|
|
None,
|
|
4000,
|
|
).unwrap();
|
|
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
|
|
assert!(comment.pub_x_index.is_some());
|
|
assert!(comment.group_sig.is_some());
|
|
assert!(comment.encrypted_payload.is_some());
|
|
|
|
// CDN-level verification: group_sig validates against pub_post_set.
|
|
assert!(verify_fof_group_sig(&comment, &built.gating),
|
|
"valid FoF comment must pass CDN four-check (group_sig leg)");
|
|
|
|
// Tamper detection: flip a byte in encrypted_payload and re-verify.
|
|
let mut tampered = comment.clone();
|
|
let mut payload = tampered.encrypted_payload.clone().unwrap();
|
|
payload[0] ^= 0x01;
|
|
tampered.encrypted_payload = Some(payload);
|
|
assert!(!verify_fof_group_sig(&tampered, &built.gating),
|
|
"tampered encrypted_payload must invalidate group_sig");
|
|
|
|
// Tamper detection: claim a different pub_x_index.
|
|
let mut wrong_idx = comment.clone();
|
|
let bad_idx = (comment.pub_x_index.unwrap() + 1) % (built.gating.pub_post_set.len() as u32);
|
|
wrong_idx.pub_x_index = Some(bad_idx);
|
|
assert!(!verify_fof_group_sig(&wrong_idx, &built.gating),
|
|
"wrong pub_x_index must invalidate group_sig");
|
|
|
|
// Alice (using her cached CEK) decrypts Bob's comment payload.
|
|
let plaintext = decrypt_fof_comment_payload(&comment, &built.cek, &built.slot_binder_nonce)
|
|
.expect("Alice decrypts FoF comment");
|
|
assert_eq!(plaintext.body, "great post alice");
|
|
|
|
let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import
|
|
}
|
|
|
|
/// Revocation roundtrip: author publishes a Mode 2 FoF post, Bob
|
|
/// comments, author signs a revocation, apply_fof_revocation_locally
|
|
/// records it + cascade-deletes Bob's comment.
|
|
#[test]
|
|
fn fof_revocation_cascades() {
|
|
use crate::types::PostingIdentity;
|
|
|
|
let s = temp_storage();
|
|
let (alice_id, alice_seed) = make_persona(33);
|
|
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();
|
|
|
|
let (bob_id, bob_seed) = make_persona(44);
|
|
let mut v_x_bob = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut v_x_bob);
|
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
|
|
|
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
|
let post_id = [0xDE; 32];
|
|
|
|
// Persist the post so apply_fof_revocation_locally can resolve
|
|
// pub_x → pub_x_index via the post's pub_post_set.
|
|
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();
|
|
|
|
// Bob unlocks via his V_x and authors a comment. Persist it
|
|
// through the public store_comment path so the cascade-delete
|
|
// has something to clean up.
|
|
let bob_unlock = PostUnlock {
|
|
persona_id: bob_id,
|
|
slot_index: built.gating.pub_post_set.iter().position(|p| {
|
|
// Find a slot Bob's V_x unlocks.
|
|
let opened = crate::crypto::open_wrap_slot(
|
|
&v_x_bob, &built.slot_binder_nonce,
|
|
&built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].read_ciphertext,
|
|
&built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].sign_ciphertext,
|
|
);
|
|
opened.is_some()
|
|
}).expect("Bob's slot exists") as u32,
|
|
cek: built.cek,
|
|
priv_x_seed: {
|
|
// Re-derive by re-unwrapping.
|
|
let mut seed = [0u8; 32];
|
|
for slot in &built.gating.wrap_slots {
|
|
if let Some(o) = crate::crypto::open_wrap_slot(
|
|
&v_x_bob, &built.slot_binder_nonce,
|
|
&slot.read_ciphertext, &slot.sign_ciphertext,
|
|
) { seed = o.priv_x_seed; break; }
|
|
}
|
|
seed
|
|
},
|
|
};
|
|
let comment = build_fof_comment(
|
|
&post_id, &bob_unlock, &built.slot_binder_nonce,
|
|
&bob_id, &bob_seed, "hello", None, 4000,
|
|
).unwrap();
|
|
s.store_comment(&comment).unwrap();
|
|
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
|
|
|
|
// Resolve Bob's pub_x bytes from the gating's pub_post_set.
|
|
let bob_pub_x = built.gating.pub_post_set[bob_unlock.slot_index as usize];
|
|
|
|
// Author signs + applies revocation.
|
|
let revoked_at = 5000;
|
|
let sig = sign_fof_revocation(&alice_seed, &post_id, &bob_pub_x, revoked_at, 0);
|
|
assert!(verify_fof_revocation(&alice_id, &post_id, &bob_pub_x, revoked_at, 0, &sig));
|
|
|
|
let deleted = apply_fof_revocation_locally(
|
|
&s, &post_id, &bob_pub_x, revoked_at, 0, &sig,
|
|
).unwrap();
|
|
assert_eq!(deleted, 1, "Bob's comment retroactively deleted");
|
|
assert!(s.get_comments(&post_id).unwrap().is_empty(),
|
|
"no live comments remain on the post");
|
|
|
|
// Verify revocation is recorded for future CDN-verify lookups.
|
|
assert!(s.is_fof_pub_x_revoked(&post_id, &bob_pub_x).unwrap());
|
|
|
|
// Idempotent: re-apply is a no-op (returns 0 because comment already gone).
|
|
let re_deleted = apply_fof_revocation_locally(
|
|
&s, &post_id, &bob_pub_x, revoked_at, 0, &sig,
|
|
).unwrap();
|
|
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];
|
|
let revoked_pub_x = [0x02; 32];
|
|
let (alice_id, alice_seed) = make_persona(50);
|
|
let (mallory_id, mallory_seed) = make_persona(51);
|
|
|
|
let sig = sign_fof_revocation(&mallory_seed, &post_id, &revoked_pub_x, 1000, 0);
|
|
// Mallory signed but claims Alice authored → reject.
|
|
assert!(!verify_fof_revocation(&alice_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
|
|
// Self-signed → accept.
|
|
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
|
|
let _ = alice_seed;
|
|
}
|
|
}
|