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)?;
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue