feat(fof-layer1): schema + storage API + vouch-grant crypto primitives
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}/<bio_post_id>". 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) <noreply@anthropic.com>
This commit is contained in:
parent
d7ce2f734c
commit
8a53d83306
2 changed files with 534 additions and 0 deletions
|
|
@ -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<Vec<u8>> {
|
||||
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<WrappedKey>)`.
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Option<(u32, [u8; 32])>> {
|
||||
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<u8> = 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<Vec<(u32, [u8; 32])>> {
|
||||
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<u8> = 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<Vec<(NodeId, u32, [u8; 32])>> {
|
||||
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<u8> = row.get(0)?;
|
||||
let epoch: i64 = row.get(1)?;
|
||||
let key: Vec<u8> = 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<Vec<(NodeId, u32, u64)>> {
|
||||
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<u8> = 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<Option<Option<u32>>> {
|
||||
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<i64> = 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<u32>,
|
||||
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<Vec<(NodeId, [u8; 32], u64)>> {
|
||||
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<u8> = row.get(0)?;
|
||||
let xpub: Vec<u8> = 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue