diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 29bcd12..0ae0183 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -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 = 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"); + } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 7958a40..7912b9f 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -1280,6 +1280,14 @@ impl Storage { Ok(()) } + /// Cheap membership check against the `follows` table. + pub fn is_follow(&self, node_id: &NodeId) -> anyhow::Result { + 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",