Zero-users ruling removes all wire-compat obligations. Net -1,443 LOC of Rust. Protocol break: - ALPN_V2 b"itsgoin/3" -> ALPN b"itsgoin/4" (renamed: the versioned name was what rotted). Old nodes are now refused at the QUIC handshake instead of half-interoperating with the v0.8 manifest/digest formats. Deploy gate from Iteration A is discharged. Dead wire surface removed (MessageType 46 -> 40): - DeleteRecord 0x51 (deletes ride ControlOp::DeletePost + InitialExchange) - VisibilityUpdate 0x52 + its two dead senders (ControlOp::UpdateVisibility) - SocialDisconnectNotice 0x71 (zero senders; checkin timeouts carry the signal) - MeshPrefer 0xB3 + request_prefer + handler (preferred peers are gone) - Legacy dual pull-matching half (have_post_ids) — behavior-identical - PullSyncResponse.visibility_updates + counters (control posts cover it) - serde(default) stripped from wire-only payloads (kept where skip_serializing_if makes it load-bearing; persisted-row defaults untouched) Preferred-peer subsystem eliminated (design ruling: CDN file_holders replaced the N+10 direct push/pull it served): slot tier, preferred_peers table (dropped), preferred_tree semantics, rebalance Priority 0, find_relays_for preferred tiers, 7-day prune + 30-day watcher, dead FromStr impl. Slots are now Local/Wide only (91 desktop / 12 mobile) pending Iteration C's single ~20-slot pool. Other dead code: hostlist encoder + base64url helper, always-empty downstream_addrs parameter chain, start_upnp_renewal_cycle no-op + 4 callers, RELAY_TARGET_RATE_LIMIT, GetSecretSeed, stale comments swept. Preserved deliberately: EDM scanner corpse (awaiting raw-UDP refactor), PortScanHeartbeat, Iteration A startup migrations, session-relay opt-in gating. Docs: design.html + tech.html synced (message counts, ALPN, purge status, Rework asides flipped past-tense). 190 core tests pass; CLI + desktop build; A3 integration 9/9 (independently re-run post-purge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
557 lines
21 KiB
Rust
557 lines
21 KiB
Rust
//! Profile posts: persona display metadata (display_name, bio, avatar_cid)
|
|
//! carried as a signed public post with `VisibilityIntent::Profile`.
|
|
//!
|
|
//! The post's `author` is the posting identity; the signature inside
|
|
//! `ProfilePostContent` is by that identity's secret. Profile posts propagate
|
|
//! via the normal CDN path (pull + header-diff). Receivers verify the
|
|
//! signature, then upsert a row in the `profiles` table keyed by the post's
|
|
//! author (= posting identity) with the new display fields.
|
|
//!
|
|
//! Profile posts are never rendered in feeds — the feed filter excludes
|
|
//! `VisibilityIntent::Profile` posts (see `Storage::get_feed*`).
|
|
|
|
use crate::crypto;
|
|
use crate::storage::Storage;
|
|
use crate::types::{NodeId, Post, PostId, PostVisibility, ProfilePostContent, PublicProfile, VisibilityIntent};
|
|
|
|
/// Verify a profile-post signature without any other side effects. Used by
|
|
/// receive paths before storing, so bogus profile posts with invalid
|
|
/// signatures never enter storage and can't be re-propagated.
|
|
pub fn verify_profile_post(post: &Post) -> anyhow::Result<ProfilePostContent> {
|
|
let content: ProfilePostContent = serde_json::from_str(&post.content)
|
|
.map_err(|e| anyhow::anyhow!("profile post content is not a valid ProfilePostContent: {}", e))?;
|
|
if !crypto::verify_profile(
|
|
&post.author,
|
|
&content.display_name,
|
|
&content.bio,
|
|
&content.avatar_cid,
|
|
content.timestamp_ms,
|
|
&content.signature,
|
|
) {
|
|
anyhow::bail!("invalid profile-post signature");
|
|
}
|
|
Ok(content)
|
|
}
|
|
|
|
/// If the post is a Profile post, verify + apply by upserting the
|
|
/// `profiles` row keyed by the post's author (= posting identity). Only
|
|
/// applied if newer than the existing row's `updated_at`.
|
|
pub fn apply_profile_post_if_applicable(
|
|
s: &Storage,
|
|
post: &Post,
|
|
intent: Option<&VisibilityIntent>,
|
|
) -> anyhow::Result<()> {
|
|
if !matches!(intent, Some(VisibilityIntent::Profile)) {
|
|
return Ok(());
|
|
}
|
|
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 {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let profile = PublicProfile {
|
|
node_id: post.author,
|
|
display_name: content.display_name,
|
|
bio: content.bio,
|
|
updated_at: content.timestamp_ms,
|
|
anchors: vec![],
|
|
recent_peers: vec![],
|
|
public_visible: true,
|
|
avatar_cid: content.avatar_cid,
|
|
};
|
|
s.store_profile(&profile)?;
|
|
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.
|
|
}
|
|
}
|
|
|
|
// FoF Layer 5: if a new V_x just landed for this persona,
|
|
// sweep the unreadable-posts queue for (persona, author) and
|
|
// re-attempt unlock. Posts that were previously not-in-set
|
|
// become readable as soon as this V_x lands.
|
|
if unlocked.is_some() {
|
|
let _ = crate::fof::sweep_unreadable_on_new_v_x(s, &persona.node_id, author);
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// Optional `vouch_grants` carries the FoF Layer 1 anonymous-wrapper
|
|
/// batch distributing the persona's current `V_me` to vouched personas.
|
|
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
|
|
/// short-circuit re-scanning unchanged bios.
|
|
/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including
|
|
/// the Greeting open slot when the persona's `greetings_open` consent is
|
|
/// on. `None` publishes a bio that accepts no greetings.
|
|
pub fn build_profile_post(
|
|
author: &NodeId,
|
|
author_secret: &[u8; 32],
|
|
display_name: &str,
|
|
bio: &str,
|
|
avatar_cid: Option<[u8; 32]>,
|
|
vouch_grants: Option<crate::types::VouchGrantBatch>,
|
|
bio_epoch: u32,
|
|
fof_gating: Option<crate::types::FoFCommentGating>,
|
|
) -> Post {
|
|
let timestamp_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
let signature = crypto::sign_profile(author_secret, display_name, bio, &avatar_cid, timestamp_ms);
|
|
let content = ProfilePostContent {
|
|
display_name: display_name.to_string(),
|
|
bio: bio.to_string(),
|
|
avatar_cid,
|
|
timestamp_ms,
|
|
signature,
|
|
vouch_grants,
|
|
bio_epoch,
|
|
};
|
|
Post {
|
|
author: *author,
|
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
|
attachments: vec![],
|
|
timestamp_ms,
|
|
fof_gating,
|
|
supersedes_post_id: None,
|
|
}
|
|
}
|
|
|
|
/// Profile-post visibility is always Public on the wire: the signature binds
|
|
/// the content to the posting identity and no recipient targeting is needed.
|
|
pub fn profile_post_visibility() -> PostVisibility {
|
|
PostVisibility::Public
|
|
}
|
|
|
|
/// FoF Layer 1: build the `VouchGrantBatch` for a persona's next bio
|
|
/// publish, drawing the current `V_me` from `vouch_keys_own` and the
|
|
/// recipient list from `own_vouch_targets` (current=1 only).
|
|
///
|
|
/// Returns `None` when the persona has no current vouch targets — the
|
|
/// bio post can be published without a vouch-grant batch in that case.
|
|
///
|
|
/// Padding: per FoF Layer 3, the wrapper count is bucketed: power-of-2
|
|
/// up to 256 (minimum bucket 8), then linear +128 steps. Real wrappers
|
|
/// + random-bytes dummies are shuffled together. Dummies are 48B random
|
|
/// sequences — AEAD-indistinguishable from real wrappers to outsiders.
|
|
pub fn build_vouch_grant_batch(
|
|
storage: &crate::storage::Storage,
|
|
persona_id: &NodeId,
|
|
) -> anyhow::Result<Option<crate::types::VouchGrantBatch>> {
|
|
use rand::RngCore;
|
|
use rand::seq::SliceRandom;
|
|
|
|
let Some((v_x_epoch, v_me)) = storage.current_own_vouch_key(persona_id)? else {
|
|
return Ok(None);
|
|
};
|
|
let targets = storage.list_current_vouch_targets(persona_id)?;
|
|
if targets.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
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();
|
|
|
|
// Real wrappers.
|
|
let mut wrappers: Vec<Vec<u8>> = Vec::with_capacity(targets.len());
|
|
for (_tid, x25519_pub, _at) in &targets {
|
|
let w = crypto::seal_vouch_grant(&eph_priv, x25519_pub, &bio_pub_nonce, &v_me)?;
|
|
wrappers.push(w);
|
|
}
|
|
|
|
// Dummy padding to the next bucket. Min 8; power-of-2 to 256; then
|
|
// +128 linear steps. See FoF Layer 3 lead decisions.
|
|
let target_count = next_vouch_batch_bucket(wrappers.len());
|
|
let mut rng = rand::rng();
|
|
while wrappers.len() < target_count {
|
|
let mut dummy = vec![0u8; 48];
|
|
rng.fill_bytes(&mut dummy);
|
|
wrappers.push(dummy);
|
|
}
|
|
|
|
// Shuffle so real and dummy positions are indistinguishable.
|
|
wrappers.shuffle(&mut rng);
|
|
|
|
Ok(Some(crate::types::VouchGrantBatch {
|
|
batch_eph_pub,
|
|
v_x_epoch,
|
|
bio_pub_nonce,
|
|
wrappers,
|
|
}))
|
|
}
|
|
|
|
/// Bucket-pad a real wrapper count to the next allowed bucket.
|
|
/// Minimum bucket is 8 (so a single-target post still publishes 8
|
|
/// wrappers, hiding "this persona has no vouchees" entirely).
|
|
/// Power-of-2 up to 256; linear +128 steps above 256.
|
|
pub(crate) fn next_vouch_batch_bucket(real: usize) -> usize {
|
|
if real <= 8 { return 8; }
|
|
if real <= 256 {
|
|
// smallest power of 2 >= real
|
|
let mut b = 8usize;
|
|
while b < real { b *= 2; }
|
|
return b;
|
|
}
|
|
// 384, 512, 640, ...
|
|
let above = real - 256;
|
|
let steps = (above + 127) / 128;
|
|
256 + steps * 128
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod batch_padding_tests {
|
|
use super::next_vouch_batch_bucket;
|
|
|
|
#[test]
|
|
fn buckets_match_spec() {
|
|
// Minimum floor.
|
|
assert_eq!(next_vouch_batch_bucket(0), 8);
|
|
assert_eq!(next_vouch_batch_bucket(1), 8);
|
|
assert_eq!(next_vouch_batch_bucket(7), 8);
|
|
assert_eq!(next_vouch_batch_bucket(8), 8);
|
|
|
|
// Power-of-2 progression.
|
|
assert_eq!(next_vouch_batch_bucket(9), 16);
|
|
assert_eq!(next_vouch_batch_bucket(16), 16);
|
|
assert_eq!(next_vouch_batch_bucket(17), 32);
|
|
assert_eq!(next_vouch_batch_bucket(129), 256);
|
|
assert_eq!(next_vouch_batch_bucket(256), 256);
|
|
|
|
// Linear +128 above 256.
|
|
assert_eq!(next_vouch_batch_bucket(257), 384);
|
|
assert_eq!(next_vouch_batch_bucket(384), 384);
|
|
assert_eq!(next_vouch_batch_bucket(385), 512);
|
|
assert_eq!(next_vouch_batch_bucket(500), 512);
|
|
assert_eq!(next_vouch_batch_bucket(513), 640);
|
|
}
|
|
}
|
|
|
|
/// Compute the `PostId` for a freshly-built profile post.
|
|
pub fn profile_post_id(post: &Post) -> PostId {
|
|
crate::content::compute_post_id(post)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::Storage;
|
|
use ed25519_dalek::SigningKey;
|
|
|
|
fn temp_storage() -> Storage {
|
|
Storage::open(":memory:").unwrap()
|
|
}
|
|
|
|
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
|
let seed = [seed_byte; 32];
|
|
let signing_key = SigningKey::from_bytes(&seed);
|
|
let public = signing_key.verifying_key();
|
|
(seed, *public.as_bytes())
|
|
}
|
|
|
|
#[test]
|
|
fn profile_roundtrip_verifies_and_stores() {
|
|
let s = temp_storage();
|
|
let (sec, pub_id) = make_keypair(11);
|
|
|
|
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None);
|
|
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
|
|
|
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
|
assert_eq!(stored.display_name, "Alice");
|
|
assert_eq!(stored.bio, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn profile_rejects_wrong_author_signature() {
|
|
let s = temp_storage();
|
|
let (_sec_a, pub_a) = make_keypair(1);
|
|
let (sec_b, _pub_b) = make_keypair(2);
|
|
|
|
// Build a post claiming `pub_a` but signing with `sec_b`.
|
|
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None);
|
|
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
|
assert!(res.is_err());
|
|
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn profile_ignores_older_timestamp() {
|
|
let s = temp_storage();
|
|
let (sec, pub_id) = make_keypair(3);
|
|
|
|
// Seed with a newer profile.
|
|
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None);
|
|
// Hack the timestamp to make it clearly newer.
|
|
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
|
content.timestamp_ms = 10_000;
|
|
content.signature = crypto::sign_profile(&sec, &content.display_name, &content.bio, &content.avatar_cid, content.timestamp_ms);
|
|
newer.content = serde_json::to_string(&content).unwrap();
|
|
newer.timestamp_ms = 10_000;
|
|
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
|
|
|
// Apply an older profile — should be ignored.
|
|
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None);
|
|
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
|
content_o.timestamp_ms = 5_000;
|
|
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
|
|
older.content = serde_json::to_string(&content_o).unwrap();
|
|
older.timestamp_ms = 5_000;
|
|
apply_profile_post_if_applicable(&s, &older, Some(&VisibilityIntent::Profile)).unwrap();
|
|
|
|
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,
|
|
fof_gating: None,
|
|
supersedes_post_id: None,
|
|
};
|
|
|
|
// 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,
|
|
fof_gating: None,
|
|
supersedes_post_id: None,
|
|
};
|
|
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");
|
|
}
|
|
}
|