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:
parent
ce710a6596
commit
12a305889e
4 changed files with 547 additions and 41 deletions
|
|
@ -205,49 +205,140 @@ pub struct PostUnlock {
|
||||||
/// persona on this device. Returns the first successful unlock found,
|
/// persona on this device. Returns the first successful unlock found,
|
||||||
/// or `None` if no held V_x matches.
|
/// or `None` if no held V_x matches.
|
||||||
///
|
///
|
||||||
/// Iteration order: personas as listed by storage; within each persona,
|
/// FoF Layer 5: consults `vouch_unlock_cache` first to skip directly
|
||||||
/// own current `V_me` first, then received V_x's. Slots are scanned in
|
/// to the V_x that worked last time for this `(persona, author)` pair.
|
||||||
/// order; the 2B prefilter lets us skip non-matching slots in O(1) per.
|
/// 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(
|
pub fn find_unlock_for_post(
|
||||||
storage: &Storage,
|
storage: &Storage,
|
||||||
post: &crate::types::Post,
|
post: &crate::types::Post,
|
||||||
) -> Result<Option<PostUnlock>> {
|
) -> Result<Option<PostUnlock>> {
|
||||||
let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); };
|
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()?;
|
let personas = storage.list_posting_identities()?;
|
||||||
|
|
||||||
|
// Fast path: try the cached winning V_x per (persona, author).
|
||||||
for persona in &personas {
|
for persona in &personas {
|
||||||
// Build this persona's V_x ring: own current + every received.
|
let Some((owner, epoch)) = storage.lookup_unlock_cache(&persona.node_id, &post.author)? else {
|
||||||
let mut keys: Vec<[u8; 32]> = Vec::new();
|
continue;
|
||||||
if let Some((_, own_key)) = storage.current_own_vouch_key(&persona.node_id)? {
|
};
|
||||||
keys.push(own_key);
|
let v_x = if owner == persona.node_id {
|
||||||
}
|
storage.list_own_vouch_keys(&persona.node_id)?
|
||||||
for (_owner, _epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? {
|
.into_iter()
|
||||||
keys.push(key);
|
.find(|(e, _)| *e == epoch)
|
||||||
}
|
.map(|(_, k)| k)
|
||||||
for v_x in &keys {
|
} else {
|
||||||
let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce);
|
storage.list_received_vouch_keys(&persona.node_id)?
|
||||||
for (idx, slot) in gating.wrap_slots.iter().enumerate() {
|
.into_iter()
|
||||||
if slot.prefilter_tag != prefilter {
|
.find(|(o, e, _)| *o == owner && *e == epoch)
|
||||||
continue;
|
.map(|(_, _, k)| k)
|
||||||
}
|
};
|
||||||
if let Some(opened) = crate::crypto::open_wrap_slot(
|
if let Some(v_x) = v_x {
|
||||||
v_x,
|
if let Some(unlock) = try_unlock_with_v_x(gating, &v_x, &persona.node_id) {
|
||||||
&gating.slot_binder_nonce,
|
storage.record_unlock_hit(
|
||||||
&slot.read_ciphertext,
|
&persona.node_id, &post.author, &owner, epoch, now_ms,
|
||||||
&slot.sign_ciphertext,
|
)?;
|
||||||
) {
|
let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache);
|
||||||
return Ok(Some(PostUnlock {
|
return Ok(Some(unlock));
|
||||||
persona_id: persona.node_id,
|
|
||||||
slot_index: idx as u32,
|
|
||||||
cek: opened.cek,
|
|
||||||
priv_x_seed: opened.priv_x_seed,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// 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)
|
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
|
/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped
|
||||||
/// inside [`crate::types::InlineComment::encrypted_payload`].
|
/// inside [`crate::types::InlineComment::encrypted_payload`].
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -1296,6 +1387,129 @@ mod tests {
|
||||||
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
|
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
|
/// End-to-end FoFClosed roundtrip at the helper level: Alice
|
||||||
/// encrypts a body; Bob (with Alice's V_me as a received V_x)
|
/// encrypts a body; Bob (with Alice's V_me as a received V_x)
|
||||||
/// trial-unlocks the gating + decrypts the body. Carol (no
|
/// trial-unlocks the gating + decrypts the body. Carol (no
|
||||||
|
|
|
||||||
|
|
@ -1079,6 +1079,8 @@ impl Node {
|
||||||
// FoF Layer 4: persist provenance so cascade-revocation can
|
// FoF Layer 4: persist provenance so cascade-revocation can
|
||||||
// resolve "which pub_x's on which of my posts were sealed
|
// resolve "which pub_x's on which of my posts were sealed
|
||||||
// under V_me epoch N" later.
|
// under V_me epoch N" later.
|
||||||
|
// FoF Layer 5: cache the CEK + slot_binder_nonce for author-
|
||||||
|
// direct decrypt without trial-unlocking on read.
|
||||||
{
|
{
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
for entry in &provenance {
|
for entry in &provenance {
|
||||||
|
|
@ -1087,6 +1089,14 @@ impl Node {
|
||||||
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
|
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Recover slot_binder_nonce via the Post we just built — it
|
||||||
|
// lives inside fof_gating.
|
||||||
|
if let Some(gating) = post.fof_gating.as_ref() {
|
||||||
|
let _ = storage.cache_own_fof_post_cek(
|
||||||
|
&self.default_posting_id, &post_id,
|
||||||
|
&cek, &gating.slot_binder_nonce,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((post_id, post, visibility, cek))
|
Ok((post_id, post, visibility, cek))
|
||||||
|
|
@ -1111,21 +1121,29 @@ impl Node {
|
||||||
}
|
}
|
||||||
let gating = match post.fof_gating.as_ref() {
|
let gating = match post.fof_gating.as_ref() {
|
||||||
Some(g) => g,
|
Some(g) => g,
|
||||||
None => return Ok(None), // invariant violation; treat as opaque
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
let slot_binder_nonce = gating.slot_binder_nonce;
|
|
||||||
|
|
||||||
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
|
// FoF Layer 5: author-direct fast path. If this device authored
|
||||||
Some(u) => u,
|
// the post, the CEK was cached at publish time; skip the
|
||||||
None => return Ok(None), // we're not in the FoF set
|
// wrap-slot trial entirely.
|
||||||
|
let (cek, slot_binder_nonce) = if let Some((cek, nonce)) =
|
||||||
|
storage.lookup_own_fof_post_cek(&post.author, post_id)?
|
||||||
|
{
|
||||||
|
(cek, nonce)
|
||||||
|
} else {
|
||||||
|
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
(unlock.cek, gating.slot_binder_nonce)
|
||||||
};
|
};
|
||||||
drop(storage);
|
drop(storage);
|
||||||
|
|
||||||
// Decode the base64-wrapped ciphertext + decrypt.
|
|
||||||
let body_ct = base64::engine::general_purpose::STANDARD
|
let body_ct = base64::engine::general_purpose::STANDARD
|
||||||
.decode(post.content.as_bytes())
|
.decode(post.content.as_bytes())
|
||||||
.map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?;
|
||||||
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &unlock.cek, &slot_binder_nonce)?;
|
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &cek, &slot_binder_nonce)?;
|
||||||
Ok(Some(plaintext))
|
Ok(Some(plaintext))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1187,6 +1205,10 @@ impl Node {
|
||||||
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
|
&entry.v_x_owner, entry.v_x_epoch, &entry.pub_x,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// FoF Layer 5: cache CEK for author-direct decrypt.
|
||||||
|
let _ = storage.cache_own_fof_post_cek(
|
||||||
|
&self.default_posting_id, &post_id, &cek, &slot_binder_nonce,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.update_neighbor_manifests_as(
|
self.update_neighbor_manifests_as(
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,14 @@ pub fn scan_vouch_grants_for_all_personas(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FoF Layer 5: if a new V_x just landed for this persona,
|
||||||
|
// sweep the unreadable-posts queue for (persona, author) and
|
||||||
|
// re-attempt unlock. Posts that were previously not-in-set
|
||||||
|
// become readable as soon as this V_x lands.
|
||||||
|
if unlocked.is_some() {
|
||||||
|
let _ = crate::fof::sweep_unreadable_on_new_v_x(s, &persona.node_id, author);
|
||||||
|
}
|
||||||
|
|
||||||
s.record_bio_scan_result(
|
s.record_bio_scan_result(
|
||||||
&persona.node_id,
|
&persona.node_id,
|
||||||
author,
|
author,
|
||||||
|
|
|
||||||
|
|
@ -505,7 +505,44 @@ impl Storage {
|
||||||
PRIMARY KEY (author_persona_id, post_id, slot_index)
|
PRIMARY KEY (author_persona_id, post_id, slot_index)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_own_post_slot_provenance_v_x
|
CREATE INDEX IF NOT EXISTS idx_own_post_slot_provenance_v_x
|
||||||
ON own_post_slot_provenance(author_persona_id, sealed_under_v_x_owner, sealed_under_v_x_epoch);",
|
ON own_post_slot_provenance(author_persona_id, sealed_under_v_x_owner, sealed_under_v_x_epoch);
|
||||||
|
-- FoF Layer 5: per-(reader_persona, author) winning-V_x cache.
|
||||||
|
-- First successful unlock from an author is remembered; subsequent
|
||||||
|
-- posts from the same author try the cached V_x first. Cuts the
|
||||||
|
-- hot-path trial-decrypt cost to ~1 HMAC + 1 AEAD attempt.
|
||||||
|
CREATE TABLE IF NOT EXISTS vouch_unlock_cache (
|
||||||
|
reader_persona_id BLOB NOT NULL,
|
||||||
|
author_id BLOB NOT NULL,
|
||||||
|
winning_v_x_owner BLOB NOT NULL,
|
||||||
|
winning_v_x_epoch INTEGER NOT NULL,
|
||||||
|
last_hit_ms INTEGER NOT NULL,
|
||||||
|
hit_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (reader_persona_id, author_id)
|
||||||
|
);
|
||||||
|
-- FoF Layer 5: queue of FoF posts no held V_x currently unlocks.
|
||||||
|
-- Swept on V_x arrival; on success the post moves to
|
||||||
|
-- vouch_unlock_cache + the row is removed.
|
||||||
|
CREATE TABLE IF NOT EXISTS vouch_unreadable_posts (
|
||||||
|
reader_persona_id BLOB NOT NULL,
|
||||||
|
post_id BLOB NOT NULL,
|
||||||
|
author_id BLOB NOT NULL,
|
||||||
|
first_seen_ms INTEGER NOT NULL,
|
||||||
|
last_attempt_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (reader_persona_id, post_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vouch_unreadable_author
|
||||||
|
ON vouch_unreadable_posts(reader_persona_id, author_id);
|
||||||
|
-- FoF Layer 5: author-direct fast path. Authors cache the CEK
|
||||||
|
-- + slot_binder_nonce for posts they authored so they can
|
||||||
|
-- decrypt without trial-unlocking via wrap_slots. Populated at
|
||||||
|
-- post-publish time.
|
||||||
|
CREATE TABLE IF NOT EXISTS own_fof_post_ceks (
|
||||||
|
author_persona_id BLOB NOT NULL,
|
||||||
|
post_id BLOB NOT NULL,
|
||||||
|
cek BLOB NOT NULL,
|
||||||
|
slot_binder_nonce BLOB NOT NULL,
|
||||||
|
PRIMARY KEY (author_persona_id, post_id)
|
||||||
|
);",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -986,7 +1023,8 @@ impl Storage {
|
||||||
id: &PostId,
|
id: &PostId,
|
||||||
) -> anyhow::Result<Option<(Post, PostVisibility)>> {
|
) -> anyhow::Result<Option<(Post, PostVisibility)>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT author, content, attachments, timestamp_ms, visibility FROM posts WHERE id = ?1",
|
"SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json
|
||||||
|
FROM posts WHERE id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let mut rows = stmt.query(params![id.as_slice()])?;
|
let mut rows = stmt.query(params![id.as_slice()])?;
|
||||||
if let Some(row) = rows.next()? {
|
if let Some(row) = rows.next()? {
|
||||||
|
|
@ -995,14 +1033,17 @@ impl Storage {
|
||||||
let vis_json: String = row.get(4)?;
|
let vis_json: String = row.get(4)?;
|
||||||
let visibility: PostVisibility =
|
let visibility: PostVisibility =
|
||||||
serde_json::from_str(&vis_json).unwrap_or_default();
|
serde_json::from_str(&vis_json).unwrap_or_default();
|
||||||
|
let fof_json: Option<String> = row.get(5)?;
|
||||||
|
let fof_gating = fof_json
|
||||||
|
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
|
||||||
Ok(Some((
|
Ok(Some((
|
||||||
Post {
|
Post {
|
||||||
author: blob_to_nodeid(row.get(0)?)?,
|
author: blob_to_nodeid(row.get(0)?)?,
|
||||||
content: row.get(1)?,
|
content: row.get(1)?,
|
||||||
attachments,
|
attachments,
|
||||||
timestamp_ms: row.get::<_, i64>(3)? as u64,
|
timestamp_ms: row.get::<_, i64>(3)? as u64,
|
||||||
fof_gating: None,
|
fof_gating,
|
||||||
supersedes_post_id: None,
|
supersedes_post_id: None,
|
||||||
},
|
},
|
||||||
visibility,
|
visibility,
|
||||||
)))
|
)))
|
||||||
|
|
@ -5058,6 +5099,227 @@ impl Storage {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- FoF Layer 5: unlock cache + retry queue ---
|
||||||
|
|
||||||
|
/// Look up the cached winning V_x for a `(reader_persona, author)`
|
||||||
|
/// pair, if any. Returns `(owner, epoch)` of the V_x that last
|
||||||
|
/// successfully unlocked. Hot-path optimization for repeated reads
|
||||||
|
/// from the same author.
|
||||||
|
pub fn lookup_unlock_cache(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
author_id: &NodeId,
|
||||||
|
) -> anyhow::Result<Option<(NodeId, u32)>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT winning_v_x_owner, winning_v_x_epoch
|
||||||
|
FROM vouch_unlock_cache
|
||||||
|
WHERE reader_persona_id = ?1 AND author_id = ?2",
|
||||||
|
params![reader_persona_id.as_slice(), author_id.as_slice()],
|
||||||
|
|row| {
|
||||||
|
let owner: Vec<u8> = row.get(0)?;
|
||||||
|
let epoch: i64 = row.get(1)?;
|
||||||
|
Ok((owner, epoch))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok((owner_bytes, epoch)) => {
|
||||||
|
let owner: NodeId = owner_bytes.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid winning_v_x_owner"))?;
|
||||||
|
Ok(Some((owner, epoch as u32)))
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful unlock in the cache. Bumps hit_count when
|
||||||
|
/// the cached entry matches; otherwise replaces it.
|
||||||
|
pub fn record_unlock_hit(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
author_id: &NodeId,
|
||||||
|
winning_v_x_owner: &NodeId,
|
||||||
|
winning_v_x_epoch: u32,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO vouch_unlock_cache
|
||||||
|
(reader_persona_id, author_id, winning_v_x_owner,
|
||||||
|
winning_v_x_epoch, last_hit_ms, hit_count)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 1)
|
||||||
|
ON CONFLICT(reader_persona_id, author_id) DO UPDATE SET
|
||||||
|
winning_v_x_owner = excluded.winning_v_x_owner,
|
||||||
|
winning_v_x_epoch = excluded.winning_v_x_epoch,
|
||||||
|
last_hit_ms = excluded.last_hit_ms,
|
||||||
|
hit_count = CASE
|
||||||
|
WHEN winning_v_x_owner = excluded.winning_v_x_owner
|
||||||
|
AND winning_v_x_epoch = excluded.winning_v_x_epoch
|
||||||
|
THEN hit_count + 1
|
||||||
|
ELSE 1
|
||||||
|
END",
|
||||||
|
params![
|
||||||
|
reader_persona_id.as_slice(),
|
||||||
|
author_id.as_slice(),
|
||||||
|
winning_v_x_owner.as_slice(),
|
||||||
|
winning_v_x_epoch as i64,
|
||||||
|
now_ms as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a post as unreadable by `reader_persona`. Swept later when
|
||||||
|
/// a new V_x arrives in the persona's keyring.
|
||||||
|
pub fn record_unreadable_post(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
author_id: &NodeId,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO vouch_unreadable_posts
|
||||||
|
(reader_persona_id, post_id, author_id, first_seen_ms, last_attempt_ms)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?4)
|
||||||
|
ON CONFLICT(reader_persona_id, post_id) DO UPDATE SET
|
||||||
|
last_attempt_ms = excluded.last_attempt_ms",
|
||||||
|
params![
|
||||||
|
reader_persona_id.as_slice(),
|
||||||
|
post_id.as_slice(),
|
||||||
|
author_id.as_slice(),
|
||||||
|
now_ms as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List unreadable posts queued for a `(reader_persona, author)`
|
||||||
|
/// pair. Used for narrow retries; sweep on V_x arrival uses
|
||||||
|
/// `list_all_unreadable_posts` since the new V_x may unlock posts
|
||||||
|
/// authored by anyone (not just the V_x's issuer).
|
||||||
|
pub fn list_unreadable_posts_for_author(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
author_id: &NodeId,
|
||||||
|
) -> anyhow::Result<Vec<PostId>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT post_id FROM vouch_unreadable_posts
|
||||||
|
WHERE reader_persona_id = ?1 AND author_id = ?2",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![
|
||||||
|
reader_persona_id.as_slice(),
|
||||||
|
author_id.as_slice(),
|
||||||
|
], |row| {
|
||||||
|
let b: Vec<u8> = row.get(0)?;
|
||||||
|
Ok(b)
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
let b = r?;
|
||||||
|
let pid: PostId = b.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid post_id in unreadable"))?;
|
||||||
|
out.push(pid);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List ALL unreadable posts for a reader persona. Used by the
|
||||||
|
/// V_x-arrival sweep — the new V_x can unlock posts by any author
|
||||||
|
/// (the V_x's owner could be a chain-link in the author's keyring,
|
||||||
|
/// not the author themselves).
|
||||||
|
pub fn list_all_unreadable_posts(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
) -> anyhow::Result<Vec<PostId>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT post_id FROM vouch_unreadable_posts
|
||||||
|
WHERE reader_persona_id = ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![reader_persona_id.as_slice()], |row| {
|
||||||
|
let b: Vec<u8> = row.get(0)?;
|
||||||
|
Ok(b)
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
let b = r?;
|
||||||
|
let pid: PostId = b.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid post_id in unreadable"))?;
|
||||||
|
out.push(pid);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a post from the unreadable queue (called after a
|
||||||
|
/// successful unlock).
|
||||||
|
pub fn clear_unreadable_post(
|
||||||
|
&self,
|
||||||
|
reader_persona_id: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM vouch_unreadable_posts
|
||||||
|
WHERE reader_persona_id = ?1 AND post_id = ?2",
|
||||||
|
params![reader_persona_id.as_slice(), post_id.as_slice()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FoF Layer 5: cache the post's CEK + slot_binder_nonce for
|
||||||
|
/// author-direct decrypt later. Populated at publish time so
|
||||||
|
/// authors skip the wrap-slot trial entirely when reading their
|
||||||
|
/// own posts.
|
||||||
|
pub fn cache_own_fof_post_cek(
|
||||||
|
&self,
|
||||||
|
author_persona_id: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
cek: &[u8; 32],
|
||||||
|
slot_binder_nonce: &[u8; 32],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO own_fof_post_ceks
|
||||||
|
(author_persona_id, post_id, cek, slot_binder_nonce)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![
|
||||||
|
author_persona_id.as_slice(),
|
||||||
|
post_id.as_slice(),
|
||||||
|
cek.as_slice(),
|
||||||
|
slot_binder_nonce.as_slice(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FoF Layer 5: look up the cached CEK + slot_binder_nonce for an
|
||||||
|
/// author's own post. Returns `None` for posts not authored on this
|
||||||
|
/// device (or pre-Layer-5 posts whose CEK wasn't cached).
|
||||||
|
pub fn lookup_own_fof_post_cek(
|
||||||
|
&self,
|
||||||
|
author_persona_id: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
) -> anyhow::Result<Option<([u8; 32], [u8; 32])>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT cek, slot_binder_nonce FROM own_fof_post_ceks
|
||||||
|
WHERE author_persona_id = ?1 AND post_id = ?2",
|
||||||
|
params![author_persona_id.as_slice(), post_id.as_slice()],
|
||||||
|
|row| {
|
||||||
|
let cek: Vec<u8> = row.get(0)?;
|
||||||
|
let nonce: Vec<u8> = row.get(1)?;
|
||||||
|
Ok((cek, nonce))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok((cek_bytes, nonce_bytes)) => {
|
||||||
|
let cek: [u8; 32] = cek_bytes.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid cached cek"))?;
|
||||||
|
let nonce: [u8; 32] = nonce_bytes.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid cached slot_binder_nonce"))?;
|
||||||
|
Ok(Some((cek, nonce)))
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// FoF Layer 4: record which V_x sealed which slot on one of the
|
/// FoF Layer 4: record which V_x sealed which slot on one of the
|
||||||
/// author's posts. Called at post-publish time. Used at
|
/// author's posts. Called at post-publish time. Used at
|
||||||
/// cascade-revocation time to find the pub_x's that need revoking
|
/// cascade-revocation time to find the pub_x's that need revoking
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue