From 12a305889e8c73b01e5400144d33da4ea4fb4beb Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 19:29:12 -0600 Subject: [PATCH] feat(fof-layer5): unlock cache + retry sweep + author-direct fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/core/src/fof.rs | 274 +++++++++++++++++++++++++++++++++---- crates/core/src/node.rs | 36 ++++- crates/core/src/profile.rs | 8 ++ crates/core/src/storage.rs | 270 +++++++++++++++++++++++++++++++++++- 4 files changed, 547 insertions(+), 41 deletions(-) diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index be8da58..5214f58 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -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> { 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 { + 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 { + 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 diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 7fcb91a..40baf9b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1079,6 +1079,8 @@ impl Node { // FoF Layer 4: persist provenance so cascade-revocation can // resolve "which pub_x's on which of my posts were sealed // 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; for entry in &provenance { @@ -1087,6 +1089,14 @@ impl Node { &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)) @@ -1111,21 +1121,29 @@ impl Node { } let gating = match post.fof_gating.as_ref() { 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)? { - Some(u) => u, - None => return Ok(None), // we're not in the FoF set + // FoF Layer 5: author-direct fast path. If this device authored + // the post, the CEK was cached at publish time; skip the + // 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); - // Decode the base64-wrapped ciphertext + decrypt. let body_ct = base64::engine::general_purpose::STANDARD .decode(post.content.as_bytes()) .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)) } @@ -1187,6 +1205,10 @@ impl Node { &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( diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7f41618..7d584f8 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -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( &persona.node_id, author, diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 49bb5e3..6a40e8d 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -505,7 +505,44 @@ impl Storage { PRIMARY KEY (author_persona_id, post_id, slot_index) ); 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(()) } @@ -986,7 +1023,8 @@ impl Storage { id: &PostId, ) -> anyhow::Result> { 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()])?; if let Some(row) = rows.next()? { @@ -995,14 +1033,17 @@ impl Storage { let vis_json: String = row.get(4)?; let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default(); + let fof_json: Option = row.get(5)?; + let fof_gating = fof_json + .and_then(|s| serde_json::from_str::(&s).ok()); Ok(Some(( Post { author: blob_to_nodeid(row.get(0)?)?, content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, - fof_gating: None, - supersedes_post_id: None, + fof_gating, + supersedes_post_id: None, }, visibility, ))) @@ -5058,6 +5099,227 @@ impl Storage { 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> { + 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 = 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> { + 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 = 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> { + 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 = 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> { + 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 = row.get(0)?; + let nonce: Vec = 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 /// author's posts. Called at post-publish time. Used at /// cascade-revocation time to find the pub_x's that need revoking