feat(fof-layer1): publish path embeds VouchGrantBatch
Wires the publish side of FoF Layer 1 vouch distribution: - VouchGrantBatch gains bio_pub_nonce (32B random per batch). Replaces the spec's circular "bio_post_id in HKDF info" — BLAKE3(post) depends on vouch_grants, so we need a content-independent binder. Recipient-free per HPKE key-privacy; serves the same anti-replay purpose as bio_post_id would have. - profile::build_vouch_grant_batch reads current_own_vouch_key + list_current_vouch_targets, generates eph keypair + bio_pub_nonce, seals V_me for each target, bucket-pads with random 48B dummies, and shuffles. Returns None when there are no targets. - next_vouch_batch_bucket implements the FoF Layer 3 padding rule: minimum bucket 8, power-of-2 up to 256, then linear +128 steps. Bucket-padding-tests verifies all boundaries. - Storage gains next_bio_epoch_for(persona_id): monotonic counter per persona, used by receivers' scan cache. Stored in settings. - build_profile_post signature extended to take Option<VouchGrantBatch> + bio_epoch: u32. Both publish_profile_post_as (initial post) and set_profile (subsequent edits) build the batch and bump the epoch on every publish. - Test sites updated to pass None/0 for the new args. Receive-side scan (next commit) reads VouchGrantBatch + bio_pub_nonce to trial-decrypt wrappers and populate vouch_keys_received. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc008c5049
commit
3ee5c30ad2
4 changed files with 156 additions and 6 deletions
|
|
@ -735,12 +735,22 @@ impl Node {
|
|||
bio: &str,
|
||||
avatar_cid: Option<[u8; 32]>,
|
||||
) -> anyhow::Result<()> {
|
||||
// FoF Layer 1: build the vouch-grant batch (if this persona has
|
||||
// any current vouch targets) + bump the bio_epoch.
|
||||
let (vouch_grants, bio_epoch) = {
|
||||
let storage = self.storage.get().await;
|
||||
let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?;
|
||||
let epoch = storage.next_bio_epoch_for(posting_id)?;
|
||||
(batch, epoch)
|
||||
};
|
||||
let profile_post = crate::profile::build_profile_post(
|
||||
posting_id,
|
||||
posting_secret,
|
||||
display_name,
|
||||
bio,
|
||||
avatar_cid,
|
||||
vouch_grants,
|
||||
bio_epoch,
|
||||
);
|
||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||
let timestamp_ms = profile_post.timestamp_ms;
|
||||
|
|
@ -1542,12 +1552,22 @@ impl Node {
|
|||
storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid)
|
||||
};
|
||||
|
||||
// FoF Layer 1: build the vouch-grant batch (if this persona has
|
||||
// any current vouch targets) + bump bio_epoch.
|
||||
let (vouch_grants, bio_epoch) = {
|
||||
let storage = self.storage.get().await;
|
||||
let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?;
|
||||
let epoch = storage.next_bio_epoch_for(&posting_id)?;
|
||||
(batch, epoch)
|
||||
};
|
||||
let profile_post = crate::profile::build_profile_post(
|
||||
&posting_id,
|
||||
&posting_secret,
|
||||
&display_name,
|
||||
&bio,
|
||||
avatar_cid,
|
||||
vouch_grants,
|
||||
bio_epoch,
|
||||
);
|
||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||
let timestamp_ms = profile_post.timestamp_ms;
|
||||
|
|
|
|||
|
|
@ -70,12 +70,19 @@ pub fn apply_profile_post_if_applicable(
|
|||
|
||||
/// 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.
|
||||
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,
|
||||
) -> Post {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -88,8 +95,8 @@ pub fn build_profile_post(
|
|||
avatar_cid,
|
||||
timestamp_ms,
|
||||
signature,
|
||||
vouch_grants: None,
|
||||
bio_epoch: 0,
|
||||
vouch_grants,
|
||||
bio_epoch,
|
||||
};
|
||||
Post {
|
||||
author: *author,
|
||||
|
|
@ -105,6 +112,110 @@ 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.
|
||||
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)
|
||||
|
|
@ -132,7 +243,7 @@ mod tests {
|
|||
let s = temp_storage();
|
||||
let (sec, pub_id) = make_keypair(11);
|
||||
|
||||
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None);
|
||||
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
|
||||
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
||||
|
||||
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
||||
|
|
@ -147,7 +258,7 @@ mod tests {
|
|||
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);
|
||||
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
|
||||
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());
|
||||
|
|
@ -159,7 +270,7 @@ mod tests {
|
|||
let (sec, pub_id) = make_keypair(3);
|
||||
|
||||
// Seed with a newer profile.
|
||||
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None);
|
||||
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
|
||||
// Hack the timestamp to make it clearly newer.
|
||||
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
||||
content.timestamp_ms = 10_000;
|
||||
|
|
@ -169,7 +280,7 @@ mod tests {
|
|||
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);
|
||||
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -4945,6 +4945,19 @@ impl Storage {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment and return the next bio-publish epoch for a persona.
|
||||
/// Counter is monotonic; used by receivers' scan cache to short-circuit
|
||||
/// re-scanning unchanged bios. Stored in `settings` keyed by persona.
|
||||
pub fn next_bio_epoch_for(&self, persona_id: &NodeId) -> anyhow::Result<u32> {
|
||||
let key = format!("bio_epoch_{}", hex::encode(persona_id));
|
||||
let current: u32 = self.get_setting(&key)?
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let next = current + 1;
|
||||
self.set_setting(&key, &next.to_string())?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
// --- File holders (flat, per-file, LRU-capped at 5) ---
|
||||
//
|
||||
// A single table for PostId-keyed engagement propagation and CID-keyed
|
||||
|
|
|
|||
|
|
@ -308,6 +308,12 @@ pub struct VouchGrantBatch {
|
|||
/// Epoch of the voucher's `V_me` being distributed in this batch.
|
||||
/// All wrappers in the batch carry the same epoch's key.
|
||||
pub v_x_epoch: u32,
|
||||
/// Random 32B nonce binding this batch's wrappers to this specific
|
||||
/// bio publish. Plays the role the spec calls "bio_post_id" in the
|
||||
/// HKDF info string — distinct from the actual `PostId` (which is
|
||||
/// `BLAKE3(post)` and would be circular here). Recipient-free per
|
||||
/// HPKE key-privacy requirements.
|
||||
pub bio_pub_nonce: [u8; 32],
|
||||
/// Real + dummy wrappers, shuffled. Each entry is exactly 48 bytes
|
||||
/// (32B sealed `V_me` + 16B AEAD tag); receivers identify "their"
|
||||
/// wrapper by successful AEAD decryption, not by position.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue