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