feat(fof-layer1): receive-path scan populates vouch_keys_received

Wires the receive side of Layer 1 vouch distribution:

- profile::scan_vouch_grants_for_all_personas reads the VouchGrantBatch
  from an incoming profile post, trial-decrypts each wrapper against
  every persona's X25519 private scalar, and inserts successful unlocks
  into vouch_keys_received. Idempotent via the bio_scan_cache.

- apply_profile_post_if_applicable now calls the scan before the
  timestamp-based last-writer-wins short-circuit on display_name/bio.
  A profile post that arrives "older" than what we've stored can still
  carry vouch grants we haven't seen — bio_epoch is the actual
  freshness signal for the wrapper batch.

- Follow-gated per the spec: skipped if the bio author isn't in
  follows. Self-authored posts skipped (we already have our own V_me).

- storage::is_follow helper added (cheap COUNT membership check).

Two new integration tests cover the wire:

- vouch_grant_end_to_end_via_bio_post: Bob's signed profile post
  carries a real wrapper for Alice + 7 dummies; Alice's keyring picks
  up V_bob and the scan cache records the hit.

- vouch_grant_skipped_for_non_followed_author: same post, but Alice
  doesn't follow Bob → no scan, no keyring entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 01:44:54 -04:00
parent 3ee5c30ad2
commit d1afcec26a
2 changed files with 254 additions and 0 deletions

View file

@ -46,6 +46,14 @@ pub fn apply_profile_post_if_applicable(
}
let content = verify_profile_post(post)?;
// FoF Layer 1: scan any embedded vouch-grant batch BEFORE the
// timestamp short-circuit below. A profile post that arrives older
// than what we've stored (last-writer-wins on display_name/bio) can
// still carry vouch grants we haven't seen — bio_epoch is the actual
// freshness signal for the wrapper batch, distinct from the
// post's display timestamp.
scan_vouch_grants_for_all_personas(s, &post.author, &content)?;
// Only apply if newer than the stored row (last-writer-wins by timestamp).
if let Some(existing) = s.get_profile(&post.author)? {
if existing.updated_at >= content.timestamp_ms {
@ -68,6 +76,90 @@ pub fn apply_profile_post_if_applicable(
Ok(())
}
/// FoF Layer 1: trial-decrypt every wrapper in the post's
/// `vouch_grants` batch against every persona on this device, recording
/// successful unlocks into `vouch_keys_received`. Idempotent via the
/// `(scanner_persona, bio_author, bio_epoch)` scan cache.
///
/// Follow-gated per the spec: skipped if the bio author is not in
/// `follows`. The manual "check this bio for a vouch for me" gesture
/// (post-Layer-1) will call a separate force-scan entrypoint.
///
/// Self-authored posts are skipped (we already have our own V_me).
pub fn scan_vouch_grants_for_all_personas(
s: &Storage,
author: &NodeId,
content: &ProfilePostContent,
) -> anyhow::Result<()> {
let Some(batch) = &content.vouch_grants else { return Ok(()); };
// Skip if we authored this post.
if s.get_posting_identity(author)?.is_some() {
return Ok(());
}
// Follow-gate: only auto-scan bios of accounts we follow.
if !s.is_follow(author)? {
return Ok(());
}
let personas = s.list_posting_identities()?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
for persona in &personas {
// Per-persona scan cache: skip if we already trialed this
// (scanner_persona, bio_author, bio_epoch) tuple.
if s.lookup_bio_scan_cache(&persona.node_id, author, content.bio_epoch)?.is_some() {
continue;
}
// Derive persona's X25519 private scalar to trial-decrypt
// wrappers under the batch ephemeral pubkey.
let persona_x25519_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed);
let mut unlocked: Option<u32> = None;
for wrapper_bytes in &batch.wrappers {
if let Some(v_me) = crypto::open_vouch_grant(
&persona_x25519_priv,
&batch.batch_eph_pub,
&batch.bio_pub_nonce,
wrapper_bytes,
) {
// This wrapper was addressed to this persona.
// Use the post's author as the source post-id field
// (informational only — the cryptographic binder is
// bio_pub_nonce inside the batch).
s.insert_received_vouch_key(
&persona.node_id,
author,
batch.v_x_epoch,
&v_me,
now_ms,
None,
)?;
unlocked = Some(batch.v_x_epoch);
// Continue iterating — a future multi-epoch batch may
// address this persona twice (different epoch wrappers
// for the same persona). Today only one epoch ships per
// batch, but the loop is correct either way.
}
}
s.record_bio_scan_result(
&persona.node_id,
author,
content.bio_epoch,
unlocked,
now_ms,
)?;
}
Ok(())
}
/// Build a Profile post signed by the posting identity. Caller is
/// responsible for storing and propagating it.
///
@ -291,4 +383,158 @@ mod tests {
let stored = s.get_profile(&pub_id).unwrap().unwrap();
assert_eq!(stored.display_name, "NewName");
}
/// End-to-end Layer 1: voucher's bio post carries a VouchGrantBatch
/// addressed to the receiver's persona; receiver auto-scans on
/// apply_profile_post_if_applicable and populates vouch_keys_received.
#[test]
fn vouch_grant_end_to_end_via_bio_post() {
use crate::types::{PostingIdentity, VouchGrantBatch};
use rand::RngCore;
let s = temp_storage();
// Two personas on this device (the "receiver" device). Alice is
// the only one we're acting as; "bob" is the voucher whose bio
// post arrives.
let (alice_seed, alice_id) = make_keypair(50);
let (bob_seed, bob_id) = make_keypair(60);
s.upsert_posting_identity(&PostingIdentity {
node_id: alice_id,
secret_seed: alice_seed,
display_name: "Alice".into(),
created_at: 1000,
}).unwrap();
// Receiver-device follows the voucher; otherwise auto-scan is
// follow-gated off and would skip.
s.add_follow(&bob_id).unwrap();
// Build bob's V_me + the wrapper batch addressed to alice's
// persona X25519 pubkey.
let mut v_me_bob = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_bob);
let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap();
let mut bio_pub_nonce = [0u8; 32];
rand::rng().fill_bytes(&mut bio_pub_nonce);
let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral();
let real_wrapper = crypto::seal_vouch_grant(
&eph_priv,
&alice_x25519_pub,
&bio_pub_nonce,
&v_me_bob,
).unwrap();
// Mix in some dummy wrappers to confirm the scan finds the real
// one even when most positions fail AEAD.
let mut wrappers = vec![real_wrapper];
for _ in 0..7 {
let mut dummy = vec![0u8; 48];
rand::rng().fill_bytes(&mut dummy);
wrappers.push(dummy);
}
let batch = VouchGrantBatch {
batch_eph_pub,
v_x_epoch: 1,
bio_pub_nonce,
wrappers,
};
// Construct bob's bio post with the batch.
let timestamp_ms = 2000;
let display_name = "Bob";
let bio = "hi";
let signature = crypto::sign_profile(&bob_seed, display_name, bio, &None, timestamp_ms);
let content = ProfilePostContent {
display_name: display_name.to_string(),
bio: bio.to_string(),
avatar_cid: None,
timestamp_ms,
signature,
vouch_grants: Some(batch),
bio_epoch: 1,
};
let post = Post {
author: bob_id,
content: serde_json::to_string(&content).unwrap(),
attachments: vec![],
timestamp_ms,
};
// Apply. Auto-scan should fire and store the unwrapped V_me.
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
// Alice's keyring should now hold V_bob at epoch 1.
let received = s.list_received_vouch_keys(&alice_id).unwrap();
assert_eq!(received.len(), 1, "expected one received vouch");
let (owner, epoch, key) = &received[0];
assert_eq!(*owner, bob_id);
assert_eq!(*epoch, 1);
assert_eq!(*key, v_me_bob);
// Scan cache should record the hit so a re-apply is a no-op
// (idempotent + cheap).
let cache = s.lookup_bio_scan_cache(&alice_id, &bob_id, 1).unwrap();
assert_eq!(cache, Some(Some(1)));
}
/// Same setup, but receiver-device does NOT follow the voucher.
/// Auto-scan must skip; no vouch keys recorded.
#[test]
fn vouch_grant_skipped_for_non_followed_author() {
use crate::types::{PostingIdentity, VouchGrantBatch};
use rand::RngCore;
let s = temp_storage();
let (alice_seed, alice_id) = make_keypair(70);
let (bob_seed, bob_id) = make_keypair(80);
s.upsert_posting_identity(&PostingIdentity {
node_id: alice_id,
secret_seed: alice_seed,
display_name: "Alice".into(),
created_at: 1000,
}).unwrap();
// NOT following bob — scan must skip.
let mut v_me_bob = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_bob);
let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap();
let mut bio_pub_nonce = [0u8; 32];
rand::rng().fill_bytes(&mut bio_pub_nonce);
let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral();
let wrapper = crypto::seal_vouch_grant(
&eph_priv, &alice_x25519_pub, &bio_pub_nonce, &v_me_bob,
).unwrap();
let batch = VouchGrantBatch {
batch_eph_pub,
v_x_epoch: 1,
bio_pub_nonce,
wrappers: vec![wrapper],
};
let timestamp_ms = 2000;
let signature = crypto::sign_profile(&bob_seed, "Bob", "", &None, timestamp_ms);
let content = ProfilePostContent {
display_name: "Bob".into(),
bio: String::new(),
avatar_cid: None,
timestamp_ms,
signature,
vouch_grants: Some(batch),
bio_epoch: 1,
};
let post = Post {
author: bob_id,
content: serde_json::to_string(&content).unwrap(),
attachments: vec![],
timestamp_ms,
};
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
let received = s.list_received_vouch_keys(&alice_id).unwrap();
assert!(received.is_empty(), "non-followed author must not auto-scan");
}
}

View file

@ -1280,6 +1280,14 @@ impl Storage {
Ok(())
}
/// Cheap membership check against the `follows` table.
pub fn is_follow(&self, node_id: &NodeId) -> anyhow::Result<bool> {
let n: i64 = self.conn.prepare(
"SELECT COUNT(*) FROM follows WHERE node_id = ?1",
)?.query_row(params![node_id.as_slice()], |row| row.get(0))?;
Ok(n > 0)
}
pub fn remove_follow(&self, node_id: &NodeId) -> anyhow::Result<()> {
self.conn.execute(
"DELETE FROM follows WHERE node_id = ?1",