feat(fof-layer5): unlock cache + retry sweep + author-direct fast path

Three new tables + cache/sweep wiring per docs/fof-spec/
layer-5-prefilter-and-cache.md:

vouch_unlock_cache (reader_persona, author) -> winning V_x
- Records the V_x that last unlocked a post from this author. Next
  post from same author: hot path is 1 HMAC + 1 AEAD attempt.

vouch_unreadable_posts (reader_persona, post_id)
- Queue of FoF posts that no held V_x currently unlocks. Swept when
  a new V_x lands in the persona's keyring.

own_fof_post_ceks (author_persona, post_id) -> (CEK, slot_binder_nonce)
- Author-direct decrypt fast path. Populated at publish so authors
  skip wrap-slot trial entirely when reading their own posts.

find_unlock_for_post:
- Cache fast path: look up winning V_x, prefilter, single AEAD.
- Full scan fallback on cache miss; record cache hit on success;
  record unreadable on full miss.

read_fof_closed_body:
- Author-direct fast path: lookup_own_fof_post_cek before trial-unlock.

sweep_unreadable_on_new_v_x:
- Walks ALL unreadable for the persona (the new V_x can unlock posts
  authored by anyone — the V_x owner may be a chain-link in some
  third party's keyring). Wired into the vouch-grant scan path so
  every new V_x triggers a sweep automatically.

Both FoF publish paths (Mode 1 + Mode 2) now cache CEK at publish.

Bug fix found by the sweep test: storage::get_post_with_visibility
wasn't loading fof_gating_json (always returned None). Fixed; reader
paths through that function now see the gating block.

16 fof:: tests pass (2 new: cache populates+hits, sweep after V_x
arrival). 150 total tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 19:29:12 -06:00
parent ce710a6596
commit 12a305889e
4 changed files with 547 additions and 41 deletions

View file

@ -205,49 +205,140 @@ pub struct PostUnlock {
/// 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.
/// FoF Layer 5: consults `vouch_unlock_cache` first to skip directly
/// to the V_x that worked last time for this `(persona, author)` pair.
/// Falls back to a full scan on miss. On success, updates the cache;
/// on failure, queues the post in `vouch_unreadable_posts` for later
/// retry when a new V_x arrives.
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 now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let post_id_for_cache = crate::content::compute_post_id(post);
let personas = storage.list_posting_identities()?;
// Fast path: try the cached winning V_x per (persona, author).
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,
}));
}
let Some((owner, epoch)) = storage.lookup_unlock_cache(&persona.node_id, &post.author)? else {
continue;
};
let v_x = if owner == persona.node_id {
storage.list_own_vouch_keys(&persona.node_id)?
.into_iter()
.find(|(e, _)| *e == epoch)
.map(|(_, k)| k)
} else {
storage.list_received_vouch_keys(&persona.node_id)?
.into_iter()
.find(|(o, e, _)| *o == owner && *e == epoch)
.map(|(_, _, k)| k)
};
if let Some(v_x) = v_x {
if let Some(unlock) = try_unlock_with_v_x(gating, &v_x, &persona.node_id) {
storage.record_unlock_hit(
&persona.node_id, &post.author, &owner, epoch, now_ms,
)?;
let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache);
return Ok(Some(unlock));
}
// Cache stale (e.g., post was key-burned). Fall through to
// full scan; next success overwrites the cache.
}
}
// Full scan path.
for persona in &personas {
let mut keyring: Vec<([u8; 32], NodeId, u32)> = Vec::new();
if let Some((epoch, own_key)) = storage.current_own_vouch_key(&persona.node_id)? {
keyring.push((own_key, persona.node_id, epoch));
}
for (owner, epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? {
keyring.push((key, owner, epoch));
}
for (v_x, owner, epoch) in &keyring {
if let Some(unlock) = try_unlock_with_v_x(gating, v_x, &persona.node_id) {
storage.record_unlock_hit(
&persona.node_id, &post.author, owner, *epoch, now_ms,
)?;
let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache);
return Ok(Some(unlock));
}
}
// No held V_x unlocks this post for this persona — queue for
// retry when a new V_x arrives in the keyring.
let _ = storage.record_unreadable_post(
&persona.node_id, &post_id_for_cache, &post.author, now_ms,
);
}
Ok(None)
}
/// FoF Layer 5: when a persona acquires a new V_x, walk ALL
/// unreadable posts for the persona and re-attempt unlock. The new
/// V_x can unlock posts authored by anyone — the author may hold the
/// V_x's owner as one of their vouches, so the post's wrap_slots
/// include a slot under the new V_x — even if the post's author is
/// someone else entirely. Successful unlocks populate
/// `vouch_unlock_cache` + clear the queue entry as a side effect.
///
/// Called by the receive path immediately after a new V_x lands in
/// the persona's keyring. `_v_x_owner` is informational (no narrowing
/// happens here because the sweep can't predict which authors will
/// have included this V_x in their gating).
pub fn sweep_unreadable_on_new_v_x(
storage: &Storage,
holder_persona_id: &NodeId,
_v_x_owner: &NodeId,
) -> Result<usize> {
let post_ids = storage.list_all_unreadable_posts(holder_persona_id)?;
let mut unlocked = 0usize;
for post_id in post_ids {
let Some((post, _vis)) = storage.get_post_with_visibility(&post_id)? else {
let _ = storage.clear_unreadable_post(holder_persona_id, &post_id);
continue;
};
// find_unlock_for_post records the cache hit + clears the
// unreadable entry as a side effect on success.
if find_unlock_for_post(storage, &post)?.is_some() {
unlocked += 1;
}
}
Ok(unlocked)
}
/// Inner helper: prefilter + AEAD-open against a single V_x.
fn try_unlock_with_v_x(
gating: &crate::types::FoFCommentGating,
v_x: &[u8; 32],
persona_id: &NodeId,
) -> Option<PostUnlock> {
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 Some(PostUnlock {
persona_id: *persona_id,
slot_index: idx as u32,
cek: opened.cek,
priv_x_seed: opened.priv_x_seed,
});
}
}
None
}
/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped
/// inside [`crate::types::InlineComment::encrypted_payload`].
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -1296,6 +1387,129 @@ mod tests {
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
}
/// FoF Layer 5: first find_unlock_for_post call populates the
/// cache; subsequent calls hit the fast path (cache lookup, single
/// AEAD attempt). Tested by verifying the cache table after first
/// call + cleared-unreadable invariant.
#[test]
fn fof_unlock_cache_populates_and_hits() {
use crate::types::PostingIdentity;
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(100);
let (bob_id, bob_seed) = make_persona(101);
s.upsert_posting_identity(&PostingIdentity {
node_id: bob_id, secret_seed: bob_seed,
display_name: "Bob".into(), created_at: 1000,
}).unwrap();
let mut v_x_bob = [0u8; 32];
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1000).unwrap();
// Build a post from Alice that includes Bob's V_x.
let alice_storage = temp_storage();
alice_storage.upsert_posting_identity(&PostingIdentity {
node_id: alice_id, secret_seed: alice_seed,
display_name: "Alice".into(), created_at: 500,
}).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, 500).unwrap();
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
};
// Bob's first scan — full scan path, populates cache.
let unlock1 = find_unlock_for_post(&s, &post).unwrap().expect("Bob unlocks");
assert_eq!(unlock1.cek, built.cek);
let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated");
// Bob's V_x is owned by Bob himself (insert_own_vouch_key).
assert_eq!(cached.0, bob_id);
assert_eq!(cached.1, 1);
// Second call should hit the cache. Sanity: still unlocks.
let unlock2 = find_unlock_for_post(&s, &post).unwrap().expect("cache hit");
assert_eq!(unlock2.cek, built.cek);
assert_eq!(unlock2.slot_index, unlock1.slot_index);
}
/// FoF Layer 5: non-member persona records the post as unreadable;
/// later arrival of a matching V_x + sweep brings it back into
/// readability and clears the queue.
#[test]
fn fof_unreadable_sweep_after_v_x_arrival() {
use crate::types::PostingIdentity;
// Build a post from Alice that requires Carol's V_x to unlock.
let alice_storage = temp_storage();
let (alice_id, alice_seed) = make_persona(110);
alice_storage.upsert_posting_identity(&PostingIdentity {
node_id: alice_id, secret_seed: alice_seed,
display_name: "Alice".into(), created_at: 100,
}).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, 100).unwrap();
let (carol_id, _) = make_persona(111);
let mut v_x_carol = [0u8; 32];
rand::rng().fill_bytes(&mut v_x_carol);
alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
// shouldn't unlock for him yet.
let s = temp_storage();
let (bob_id, bob_seed) = make_persona(112);
s.upsert_posting_identity(&PostingIdentity {
node_id: bob_id, secret_seed: bob_seed,
display_name: "Bob".into(), created_at: 300,
}).unwrap();
let mut v_me_bob = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_bob);
s.insert_own_vouch_key(&bob_id, 1, &v_me_bob, 300).unwrap();
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
};
// Persist the post so the sweep can re-fetch it.
s.store_post_with_intent(
&crate::content::compute_post_id(&post), &post,
&crate::types::PostVisibility::Public,
&crate::types::VisibilityIntent::Public,
).unwrap();
// First attempt: no V_x matches → unreadable queue grows.
let pre = find_unlock_for_post(&s, &post).unwrap();
assert!(pre.is_none(), "Bob can't unlock pre-V_x");
let queued = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap();
assert_eq!(queued.len(), 1);
// Carol vouches for Bob via... wait, Carol's V_x is sealed in
// Alice's post for Carol. Bob is unrelated. To make this test
// realistic, let's say Carol DOES vouch for Bob — Bob now
// holds V_x_carol in his keyring. After the sweep, Bob can
// unlock Alice's post via Carol's V_x (which IS sealed in the
// gating because Alice held Carol's V_x).
s.insert_received_vouch_key(&bob_id, &carol_id, 1, &v_x_carol, 400, None).unwrap();
let swept = sweep_unreadable_on_new_v_x(&s, &bob_id, &carol_id).unwrap();
assert_eq!(swept, 1, "sweep unlocks Alice's post via Carol's V_x");
// Post-sweep: unreadable queue cleared; unlock cache populated.
let after = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap();
assert!(after.is_empty());
let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated");
assert_eq!(cached.0, carol_id, "winning V_x owner is Carol");
assert_eq!(cached.1, 1);
}
/// End-to-end FoFClosed roundtrip at the helper level: Alice
/// encrypts a body; Bob (with Alice's V_me as a received V_x)
/// trial-unlocks the gating + decrypts the body. Carol (no