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:
parent
3ee5c30ad2
commit
d1afcec26a
2 changed files with 254 additions and 0 deletions
|
|
@ -46,6 +46,14 @@ pub fn apply_profile_post_if_applicable(
|
||||||
}
|
}
|
||||||
let content = verify_profile_post(post)?;
|
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).
|
// Only apply if newer than the stored row (last-writer-wins by timestamp).
|
||||||
if let Some(existing) = s.get_profile(&post.author)? {
|
if let Some(existing) = s.get_profile(&post.author)? {
|
||||||
if existing.updated_at >= content.timestamp_ms {
|
if existing.updated_at >= content.timestamp_ms {
|
||||||
|
|
@ -68,6 +76,90 @@ pub fn apply_profile_post_if_applicable(
|
||||||
Ok(())
|
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
|
/// Build a Profile post signed by the posting identity. Caller is
|
||||||
/// responsible for storing and propagating it.
|
/// responsible for storing and propagating it.
|
||||||
///
|
///
|
||||||
|
|
@ -291,4 +383,158 @@ mod tests {
|
||||||
let stored = s.get_profile(&pub_id).unwrap().unwrap();
|
let stored = s.get_profile(&pub_id).unwrap().unwrap();
|
||||||
assert_eq!(stored.display_name, "NewName");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1280,6 +1280,14 @@ impl Storage {
|
||||||
Ok(())
|
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<()> {
|
pub fn remove_follow(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"DELETE FROM follows WHERE node_id = ?1",
|
"DELETE FROM follows WHERE node_id = ?1",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue