From 8a53d83306bad67258f236284675dc2628c8f0f0 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:29:43 -0400 Subject: [PATCH] feat(fof-layer1): schema + storage API + vouch-grant crypto primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the foundational pieces for FoF Layer 1 (vouch primitive) per docs/fof-spec/layer-1-vouch-primitive.md: Schema (init_tables, CREATE TABLE IF NOT EXISTS — safe for upgrade and fresh installs): - vouch_keys_own: per-persona V_me history, append-only on rotation - vouch_keys_received: per-persona inbound keyring, multi-epoch - vouch_bio_scan_cache: short-circuits unchanged-bio re-scans - own_vouch_targets: author-local, never on wire, drives batch assembly Storage API: insert/list/lookup for all four tables, including current_own_vouch_key, list_received_vouch_keys, list_vouchers_for, record_bio_scan_result, upsert/revoke_vouch_target. Crypto: HPKE-style seal_vouch_grant / open_vouch_grant using existing ed25519 → X25519 derivation. Per-batch ephemeral X25519 keypair via generate_vouch_batch_ephemeral. Wrapper is 48B (32B sealed V_me + 16B AEAD tag). Recipient-free derivation context per spec — info string is "itsgoin/vouch-grant/v1/{key|nonce}/". 3 unit tests cover roundtrip + wrong-post-id + random-bytes-as-dummy. No behavior change yet; nothing wired in. Layer 1 wire types, persona auto-gen, publish/scan paths follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/crypto.rs | 175 ++++++++++++++++++ crates/core/src/storage.rs | 359 +++++++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+) diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 6b9d24f..f7264c1 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -12,6 +12,12 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; +/// FoF Layer 1: vouch-grant HPKE-style wrapper construction. +/// HKDF/derive_key info MUST be recipient-free (key privacy). +/// `bio_post_id` ties the wrapper to the publishing bio post. +const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; +const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; + /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { let signing_key = SigningKey::from_bytes(seed); @@ -193,6 +199,108 @@ pub fn unwrap_group_cek( Ok(cek) } +// --- FoF Layer 1: vouch-grant HPKE-style seal/open --- +// +// Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher +// publishes anonymous per-recipient wrappers inside their bio post. Each +// wrapper carries `V_me` (the voucher's symmetric key) sealed under a +// shared secret derived from ECDH between a per-batch ephemeral X25519 +// keypair and the recipient's persona X25519 key. +// +// Recipient anonymity ("key privacy") is preserved because: +// 1. Wrappers carry no recipient identifier. +// 2. The KDF info string is recipient-free (only the post_id appears). +// 3. All wrappers in a batch share the same ephemeral pubkey. +// +// Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag). +// One 32B ephemeral pubkey shared across all wrappers in the batch. + +/// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch. +/// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the +/// ed25519 → X25519 derivation path that the rest of the codebase uses +/// so all X25519 endpoints are produced identically. +pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let eph_priv = ed25519_seed_to_x25519_private(&seed); + let signing_key = SigningKey::from_bytes(&seed); + let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes(); + (eph_priv, eph_pub) +} + +/// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from +/// the ECDH shared secret and the publishing bio post's ID. +fn derive_vouch_grant_key_nonce( + shared_secret: &[u8; 32], + bio_post_id: &PostId, +) -> ([u8; 32], [u8; 12]) { + // Bake bio_post_id into the derivation context. Recipient-free. + let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id)); + let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id)); + let wrapping_key = blake3::derive_key(&key_ctx, shared_secret); + let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (wrapping_key, nonce) +} + +fn hex_lower(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Seal `V_me` (32B) under the recipient's X25519 pubkey using the +/// batch's ephemeral X25519 private key. Returns the 48-byte wrapper +/// `ciphertext(32) || tag(16)`. +pub fn seal_vouch_grant( + eph_priv: &[u8; 32], + recipient_x25519_pub: &[u8; 32], + bio_post_id: &PostId, + v_me: &[u8; 32], +) -> Result> { + let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub); + let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) + .map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), v_me.as_slice()) + .map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?; + // ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B. + if ciphertext.len() != 48 { + bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len()); + } + Ok(ciphertext) +} + +/// Try to open a vouch-grant wrapper using the recipient's X25519 private +/// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e., +/// this wrapper was not addressed to this recipient). +pub fn open_vouch_grant( + recipient_x25519_priv: &[u8; 32], + batch_eph_pub: &[u8; 32], + bio_post_id: &PostId, + wrapper_ciphertext: &[u8], +) -> Option<[u8; 32]> { + if wrapper_ciphertext.len() != 48 { + return None; + } + let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub); + let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?; + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext) + .ok()?; + if plaintext.len() != 32 { + return None; + } + let mut v_me = [0u8; 32]; + v_me.copy_from_slice(&plaintext); + Some(v_me) +} + /// Encrypt a post with a provided CEK, wrapping for recipients. /// Returns `(base64_ciphertext, Vec)`. pub fn encrypt_post_with_cek( @@ -1347,4 +1455,71 @@ mod tests { // Different calls produce different noise (with very high probability) assert_ne!(random_slot_noise(64), random_slot_noise(64)); } + + // --- FoF Layer 1: vouch-grant seal/open --- + + fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) { + // Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring + // the production path personas use. + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let priv_x = ed25519_seed_to_x25519_private(&seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = signing_key.verifying_key().to_montgomery().to_bytes(); + (priv_x, pub_x) + } + + #[test] + fn vouch_grant_roundtrip() { + let (alice_priv, _alice_pub) = make_persona_x25519(11); + let (bob_priv, bob_pub) = make_persona_x25519(22); + let bio_post_id: PostId = [7u8; 32]; + let v_me: [u8; 32] = [42u8; 32]; + + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + + // Seal for Bob + let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap(); + assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)"); + + // Bob opens it + let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper); + assert_eq!(opened, Some(v_me)); + + // Alice (not the recipient) cannot open it + let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper); + assert_eq!(alice_attempt, None, "non-recipient must not decrypt"); + } + + #[test] + fn vouch_grant_wrong_bio_post_id_fails() { + let (_, bob_pub) = make_persona_x25519(22); + let (bob_priv, _) = make_persona_x25519(22); + let real_bio_id: PostId = [1u8; 32]; + let wrong_bio_id: PostId = [2u8; 32]; + let v_me: [u8; 32] = [99u8; 32]; + + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap(); + + // Wrong bio_post_id derives a different key+nonce → AEAD fails. + let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper); + assert_eq!(attempt, None); + + // Right bio_post_id succeeds. + let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper); + assert_eq!(ok, Some(v_me)); + } + + #[test] + fn vouch_grant_random_bytes_fail() { + let (bob_priv, _) = make_persona_x25519(22); + let bio_post_id: PostId = [5u8; 32]; + let (_, eph_pub) = generate_vouch_batch_ephemeral(); + + let mut junk = [0u8; 48]; + rand::rng().fill_bytes(&mut junk); + let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); + assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); + } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index e5507ab..a8f8b6b 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -415,6 +415,60 @@ impl Storage { secret_seed BLOB NOT NULL, display_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL + ); + -- FoF Layer 1: per-persona V_me history. Rows are append-only + -- on rotation (Layer 4); old epochs retained for unwrapping + -- historical wrap_slots. is_current marks the active outgoing + -- key. key_material is the 32B symmetric V_me bytes. + CREATE TABLE IF NOT EXISTS vouch_keys_own ( + persona_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + key_material BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + is_current INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (persona_id, epoch) + ); + CREATE INDEX IF NOT EXISTS idx_vouch_keys_own_current + ON vouch_keys_own(persona_id, is_current); + -- FoF Layer 1: per-persona keyring of received vouch keys from + -- others. holder_persona_id is whose keyring this row belongs + -- to; owner_id is the persona who issued the V_x; epoch is the + -- issuer's V_x epoch. Multi-epoch retention per Layer 4. + CREATE TABLE IF NOT EXISTS vouch_keys_received ( + holder_persona_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + key_material BLOB NOT NULL, + received_at_ms INTEGER NOT NULL, + source_bio_post_id BLOB, + PRIMARY KEY (holder_persona_id, owner_id, epoch) + ); + CREATE INDEX IF NOT EXISTS idx_vouch_keys_received_owner + ON vouch_keys_received(holder_persona_id, owner_id); + -- FoF Layer 1: short-circuit cache for re-scanning bio posts + -- that haven't changed. bio_epoch is the issuer's bio-post + -- revision counter. result=1 means a wrapper unlocked; 0 means + -- nothing for this persona. + CREATE TABLE IF NOT EXISTS vouch_bio_scan_cache ( + scanner_persona_id BLOB NOT NULL, + bio_author_id BLOB NOT NULL, + bio_epoch INTEGER NOT NULL, + result INTEGER NOT NULL, + unlocked_v_x_epoch INTEGER, + scanned_at_ms INTEGER NOT NULL, + PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch) + ); + -- FoF Layer 1: author-local record of who this persona has + -- vouched for. Never on the wire. Drives bio-post wrapper + -- batch assembly. current=1 means the target is in the latest + -- batch; current=0 means they were removed (revoked). + CREATE TABLE IF NOT EXISTS own_vouch_targets ( + voucher_persona_id BLOB NOT NULL, + target_persona_id BLOB NOT NULL, + target_x25519_pub BLOB NOT NULL, + granted_at_ms INTEGER NOT NULL, + current INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (voucher_persona_id, target_persona_id) );", )?; Ok(()) @@ -4586,6 +4640,311 @@ impl Storage { Ok(n as u64) } + // --- FoF Layer 1: Vouch keys (own + received) --- + + /// Insert a new V_me epoch for a persona. Marks it current; older + /// epochs are flipped to non-current. Append-only — old epochs are + /// never deleted by rotation (see Layer 4). + pub fn insert_own_vouch_key( + &self, + persona_id: &NodeId, + epoch: u32, + key_material: &[u8; 32], + created_at_ms: u64, + ) -> anyhow::Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "UPDATE vouch_keys_own SET is_current = 0 WHERE persona_id = ?1", + params![persona_id.as_slice()], + )?; + tx.execute( + "INSERT OR REPLACE INTO vouch_keys_own + (persona_id, epoch, key_material, created_at_ms, is_current) + VALUES (?1, ?2, ?3, ?4, 1)", + params![ + persona_id.as_slice(), + epoch as i64, + key_material.as_slice(), + created_at_ms as i64, + ], + )?; + tx.commit()?; + Ok(()) + } + + /// Return the persona's current V_me as `(epoch, key)`, or None if not set. + pub fn current_own_vouch_key( + &self, + persona_id: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT epoch, key_material FROM vouch_keys_own + WHERE persona_id = ?1 AND is_current = 1", + params![persona_id.as_slice()], + |row| { + let epoch: i64 = row.get(0)?; + let key: Vec = row.get(1)?; + Ok((epoch, key)) + }, + ); + match result { + Ok((epoch, key_bytes)) => { + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; + Ok(Some((epoch as u32, key))) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Return all V_me epochs for a persona (current + retained past). + /// Sorted newest-first. Used at unwrap time (try newest first) and + /// when a sender needs to publish multi-epoch grants. + pub fn list_own_vouch_keys( + &self, + persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT epoch, key_material FROM vouch_keys_own + WHERE persona_id = ?1 ORDER BY epoch DESC", + )?; + let rows = stmt.query_map(params![persona_id.as_slice()], |row| { + let epoch: i64 = row.get(0)?; + let key: Vec = row.get(1)?; + Ok((epoch, key)) + })?; + let mut out = Vec::new(); + for r in rows { + let (epoch, key_bytes) = r?; + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; + out.push((epoch as u32, key)); + } + Ok(out) + } + + /// Insert a received vouch key into a persona's keyring. Idempotent + /// on `(holder, owner, epoch)`. + pub fn insert_received_vouch_key( + &self, + holder_persona_id: &NodeId, + owner_id: &NodeId, + epoch: u32, + key_material: &[u8; 32], + received_at_ms: u64, + source_bio_post_id: Option<&[u8; 32]>, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR IGNORE INTO vouch_keys_received + (holder_persona_id, owner_id, epoch, key_material, received_at_ms, source_bio_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + holder_persona_id.as_slice(), + owner_id.as_slice(), + epoch as i64, + key_material.as_slice(), + received_at_ms as i64, + source_bio_post_id.map(|b| b.as_slice()), + ], + )?; + Ok(()) + } + + /// Return the full received-vouch keyring for a persona. Each row is + /// `(owner_id, epoch, key_material)`. Trial-unwrap iterates the result. + pub fn list_received_vouch_keys( + &self, + holder_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT owner_id, epoch, key_material FROM vouch_keys_received + WHERE holder_persona_id = ?1 + ORDER BY owner_id, epoch DESC", + )?; + let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { + let owner: Vec = row.get(0)?; + let epoch: i64 = row.get(1)?; + let key: Vec = row.get(2)?; + Ok((owner, epoch, key)) + })?; + let mut out = Vec::new(); + for r in rows { + let (owner_bytes, epoch, key_bytes) = r?; + let owner: NodeId = owner_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid owner_id in vouch_keys_received"))?; + let key: [u8; 32] = key_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid key in vouch_keys_received"))?; + out.push((owner, epoch as u32, key)); + } + Ok(out) + } + + /// List distinct owners that have vouched for a persona (for UI + /// "Who has vouched for me"). Latest epoch per owner. + pub fn list_vouchers_for( + &self, + holder_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT owner_id, MAX(epoch), MAX(received_at_ms) + FROM vouch_keys_received + WHERE holder_persona_id = ?1 + GROUP BY owner_id", + )?; + let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { + let owner: Vec = row.get(0)?; + let epoch: i64 = row.get(1)?; + let at: i64 = row.get(2)?; + Ok((owner, epoch, at)) + })?; + let mut out = Vec::new(); + for r in rows { + let (owner_bytes, epoch, at) = r?; + let owner: NodeId = owner_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid owner_id"))?; + out.push((owner, epoch as u32, at as u64)); + } + Ok(out) + } + + /// Lookup a scan-cache entry. Returns Some(unlocked_epoch) if the + /// cached result was a hit (Some(None) means the row exists as a miss). + /// Returns None if no cache row exists (scan needed). + pub fn lookup_bio_scan_cache( + &self, + scanner_persona_id: &NodeId, + bio_author_id: &NodeId, + bio_epoch: u32, + ) -> anyhow::Result>> { + let result = self.conn.query_row( + "SELECT result, unlocked_v_x_epoch FROM vouch_bio_scan_cache + WHERE scanner_persona_id = ?1 AND bio_author_id = ?2 AND bio_epoch = ?3", + params![ + scanner_persona_id.as_slice(), + bio_author_id.as_slice(), + bio_epoch as i64, + ], + |row| { + let result: i64 = row.get(0)?; + let unlocked: Option = row.get(1)?; + Ok((result, unlocked)) + }, + ); + match result { + Ok((res, unlocked)) => { + if res == 1 { + Ok(Some(unlocked.map(|e| e as u32))) + } else { + Ok(Some(None)) + } + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Record a scan-cache hit (result=1) or miss (result=0). + pub fn record_bio_scan_result( + &self, + scanner_persona_id: &NodeId, + bio_author_id: &NodeId, + bio_epoch: u32, + unlocked_v_x_epoch: Option, + scanned_at_ms: u64, + ) -> anyhow::Result<()> { + let result_flag: i64 = if unlocked_v_x_epoch.is_some() { 1 } else { 0 }; + self.conn.execute( + "INSERT OR REPLACE INTO vouch_bio_scan_cache + (scanner_persona_id, bio_author_id, bio_epoch, result, unlocked_v_x_epoch, scanned_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + scanner_persona_id.as_slice(), + bio_author_id.as_slice(), + bio_epoch as i64, + result_flag, + unlocked_v_x_epoch.map(|e| e as i64), + scanned_at_ms as i64, + ], + )?; + Ok(()) + } + + /// Upsert an outbound vouch target for a persona. `current=1` means + /// it'll be wrapped into the next bio-post batch. + pub fn upsert_vouch_target( + &self, + voucher_persona_id: &NodeId, + target_persona_id: &NodeId, + target_x25519_pub: &[u8; 32], + granted_at_ms: u64, + current: bool, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT INTO own_vouch_targets + (voucher_persona_id, target_persona_id, target_x25519_pub, granted_at_ms, current) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(voucher_persona_id, target_persona_id) DO UPDATE SET + target_x25519_pub = excluded.target_x25519_pub, + current = excluded.current", + params![ + voucher_persona_id.as_slice(), + target_persona_id.as_slice(), + target_x25519_pub.as_slice(), + granted_at_ms as i64, + current as i64, + ], + )?; + Ok(()) + } + + /// List current outbound vouch targets for a persona. + pub fn list_current_vouch_targets( + &self, + voucher_persona_id: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT target_persona_id, target_x25519_pub, granted_at_ms + FROM own_vouch_targets + WHERE voucher_persona_id = ?1 AND current = 1 + ORDER BY granted_at_ms ASC", + )?; + let rows = stmt.query_map(params![voucher_persona_id.as_slice()], |row| { + let tid: Vec = row.get(0)?; + let xpub: Vec = row.get(1)?; + let at: i64 = row.get(2)?; + Ok((tid, xpub, at)) + })?; + let mut out = Vec::new(); + for r in rows { + let (tid_bytes, xpub_bytes, at) = r?; + let tid: NodeId = tid_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid target_persona_id"))?; + let xpub: [u8; 32] = xpub_bytes.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid target_x25519_pub"))?; + out.push((tid, xpub, at as u64)); + } + Ok(out) + } + + /// Mark a vouch target as no longer current (soft revoke; row retained + /// so the audit trail / cascade-pickup is preserved). + pub fn revoke_vouch_target( + &self, + voucher_persona_id: &NodeId, + target_persona_id: &NodeId, + ) -> anyhow::Result<()> { + self.conn.execute( + "UPDATE own_vouch_targets SET current = 0 + WHERE voucher_persona_id = ?1 AND target_persona_id = ?2", + params![ + voucher_persona_id.as_slice(), + target_persona_id.as_slice(), + ], + )?; + Ok(()) + } + // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed