From bc008c50495752021d9b3214639b519de25a195d Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 01:35:19 -0400 Subject: [PATCH] feat(fof-layer1): wire types + V_me auto-gen on persona creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> 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) --- crates/core/src/node.rs | 39 ++++++++++++++++++++++++++++++++++++++ crates/core/src/profile.rs | 2 ++ crates/core/src/types.rs | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 3a1614d..cc0db25 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -63,6 +63,35 @@ pub struct Node { budget_last_reset_ms: Arc, } +/// 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) -> anyhow::Result { @@ -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, diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7d1b66b..3d2a297 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -88,6 +88,8 @@ pub fn build_profile_post( avatar_cid, timestamp_ms, signature, + vouch_grants: None, + bio_epoch: 0, }; Post { author: *author, diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 9102c6b..d5c591d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -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, + /// 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, + /// 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>, } /// Content payload of a `VisibilityIntent::Announcement` post.