feat(fof-layer1): wire types + V_me auto-gen on persona creation

Adds VouchGrantBatch type to types.rs and extends ProfilePostContent
with optional vouch_grants + bio_epoch fields (back-compat via
#[serde(default)]). VouchGrantBatch carries one shared batch_eph_pub
+ a Vec<Vec<u8>> of 48-byte wrappers; receivers identify their wrapper
by AEAD success, not position.

Wires V_me auto-generation into both persona-creation paths:
- Node::open first-run auto-persona block now seeds vouch_keys_own
  epoch=1 alongside upsert_posting_identity.
- create_posting_identity calls ensure_initial_v_me after the new
  persona is stored.

Helpers live as private free functions at module scope so both the
sync (Node::open holds a Storage guard) and async (create_posting
holds a StoragePool) sites can share them. Idempotent — re-running
on a persona that already has a current key is a no-op.

Two existing ProfilePostContent construction sites updated to set the
new fields to defaults (None / 0); they'll get real values when the
publish path is wired up in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-13 01:35:19 -04:00
parent 8a53d83306
commit bc008c5049
3 changed files with 79 additions and 0 deletions

View file

@ -63,6 +63,35 @@ pub struct Node {
budget_last_reset_ms: Arc<AtomicU64>,
}
/// FoF Layer 1: generate a fresh 32B `V_me` and insert it as the
/// persona's current epoch (epoch=1). Idempotent if the persona already
/// has a current key — does nothing in that case.
fn generate_and_store_initial_v_me(
storage: &crate::storage::Storage,
persona_id: &NodeId,
now_ms: u64,
) -> anyhow::Result<()> {
use rand::RngCore;
if storage.current_own_vouch_key(persona_id)?.is_some() {
return Ok(());
}
let mut key = [0u8; 32];
rand::rng().fill_bytes(&mut key);
storage.insert_own_vouch_key(persona_id, 1, &key, now_ms)?;
Ok(())
}
/// Async wrapper used by `Node::create_posting_identity`. Acquires the
/// storage handle and delegates to the sync helper.
async fn ensure_initial_v_me(
storage: &StoragePool,
persona_id: &NodeId,
now_ms: u64,
) -> anyhow::Result<()> {
let s = storage.get().await;
generate_and_store_initial_v_me(&s, persona_id, now_ms)
}
impl Node {
/// Create or open a node in the given data directory (Desktop profile)
pub async fn open(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
@ -133,6 +162,8 @@ impl Node {
created_at: now,
})?;
s.set_default_posting_id(&nid)?;
// FoF Layer 1: auto-gen V_me epoch 1 for this fresh persona.
generate_and_store_initial_v_me(&s, &nid, now)?;
// Mark this as the disposable auto-gen persona from the
// fresh-install flow. If the user subsequently imports, we
// prune this id iff it's still pristine (no name, no posts,
@ -684,6 +715,12 @@ impl Node {
}
}
// FoF Layer 1: every persona owns its own V_me (symmetric 32B key).
// Auto-generate epoch 1 at creation. Stored in vouch_keys_own with
// is_current=1. Per Layer 4, rotations append new epochs; this row
// is never deleted automatically.
ensure_initial_v_me(&self.storage, &node_id, now).await?;
Ok(identity)
}
@ -772,6 +809,8 @@ impl Node {
avatar_cid: None,
timestamp_ms: pi.created_at,
signature,
vouch_grants: None,
bio_epoch: 0,
};
let post = Post {
author: pi.node_id,

View file

@ -88,6 +88,8 @@ pub fn build_profile_post(
avatar_cid,
timestamp_ms,
signature,
vouch_grants: None,
bio_epoch: 0,
};
Post {
author: *author,

View file

@ -274,6 +274,44 @@ pub struct ProfilePostContent {
/// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
/// layout signed by the posting identity.
pub signature: Vec<u8>,
/// FoF Layer 1: HPKE-style anonymous wrapper batch carrying the
/// voucher's V_me to each non-revoked recipient. `None` on bio posts
/// that don't issue vouches (e.g., the inaugural empty-state profile
/// post for a brand-new persona). Bound to this post via the
/// `bio_post_id` baked into each wrapper's HKDF info.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vouch_grants: Option<VouchGrantBatch>,
/// FoF Layer 1: monotonic bio-post revision counter for this persona.
/// Used by receivers to short-circuit the scan via `vouch_bio_scan_cache`.
/// Increments on every bio publish; 0 for pre-Layer-1 posts (back-compat).
#[serde(default)]
pub bio_epoch: u32,
}
/// FoF Layer 1: a batch of per-recipient HPKE-style wrappers carrying
/// the voucher's `V_me` to each currently-vouched persona. Sits inside
/// a `VisibilityIntent::Profile` post. The post's author is the voucher.
///
/// Wire shape: one shared 32B ephemeral X25519 pubkey + N 48-byte
/// wrappers (32B sealed `V_me` + 16B AEAD tag). Wrappers carry no
/// recipient identifier; recipient anonymity is preserved by HPKE key
/// privacy. The HKDF info string includes the bio post id but NEVER
/// a recipient identifier — including one would break key privacy.
///
/// Dummy wrappers are random 48-byte sequences indistinguishable from
/// real ones; they AEAD-fail on every persona. Bucketed padding is
/// applied by the publisher (Layer 3 specifies the bucket boundaries).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VouchGrantBatch {
/// Shared ephemeral X25519 pubkey for this batch.
pub batch_eph_pub: [u8; 32],
/// 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,
/// 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.
pub wrappers: Vec<Vec<u8>>,
}
/// Content payload of a `VisibilityIntent::Announcement` post.