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:
Scott Reimers 2026-05-13 01:29:43 -04:00
parent d7ce2f734c
commit 8a53d83306
2 changed files with 534 additions and 0 deletions

View file

@ -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