From 34c5b60686dc3e1f6304c8ee9d3194d586c7d3df Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 13 May 2026 06:47:18 -0400 Subject: [PATCH] feat(fof-layer1): Tauri commands + frontend UI for vouches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node helpers (crates/core/src/node.rs): - vouch_for_peer(target): derives target X25519 pub from NodeId, inserts into own_vouch_targets, republishes bio post so the new VouchGrantBatch propagates to the receiver via CDN. - revoke_vouch_and_rotate(target): per Scott's design, revocation IS the rotation primitive. Marks target current=0, generates new V_me epoch in vouch_keys_own (prior retained, Layer 4 receiver-chain), republishes bio. Revoked persona retains old V_me → grandfathered access to old content; locked out of new content sealed under V_new. - list_vouches_given / list_vouches_received: enriched with display names via resolve_display_name. Tauri commands (crates/tauri-app/src/lib.rs): - vouch_for_peer, revoke_vouch_for_peer (single-action commands) - list_vouches_given, list_vouches_received (DTOs with camelCase) - All registered in the generate_handler! list. Frontend (frontend/index.html + app.js): - New "Vouches" section in Settings tab with side-by-side Given / Received lists. Per-row Revoke button on Given entries with confirm prompt explaining the rotation semantics. - Bio modal gains Vouch / Revoke Vouch button next to Follow/Unfollow. State derived from list_vouches_given on modal open. - loadVouches wired into the settings-tab activation handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/node.rs | 136 ++++++++++++++++++++++++++++++++++++ crates/tauri-app/src/lib.rs | 60 ++++++++++++++++ frontend/app.js | 81 ++++++++++++++++++++- frontend/index.html | 15 ++++ 4 files changed, 291 insertions(+), 1 deletion(-) diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 12f646b..1e5efb7 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1696,6 +1696,142 @@ impl Node { storage.get_display_name(node_id) } + // ---- FoF Layer 1: Vouches ---- + + /// Vouch for a persona from the current default posting identity. + /// Inserts into `own_vouch_targets` and republishes the bio post so + /// the recipient sees the vouch on their next scan. + pub async fn vouch_for_peer(&self, target: &NodeId) -> anyhow::Result<()> { + let (default_id, display_name, bio, avatar_cid, posting_secret) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + anyhow::bail!("no default posting identity"); + }; + let pi = storage.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; + let profile = storage.get_profile(&default_id)?; + let (name, bio, avatar) = match profile { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (pi.display_name.clone(), String::new(), None), + }; + (default_id, name, bio, avatar, pi.secret_seed) + }; + + // Convert the target's ed25519 NodeId to its X25519 pubkey via + // the same Montgomery derivation receivers use. + let target_x25519_pub = crate::crypto::ed25519_pubkey_to_x25519_public(target)?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + { + let storage = self.storage.get().await; + storage.upsert_vouch_target(&default_id, target, &target_x25519_pub, now_ms, true)?; + } + + // Republish bio post so the new vouch_grants batch propagates. + self.publish_profile_post_as( + &default_id, &posting_secret, &display_name, &bio, avatar_cid, + ).await?; + Ok(()) + } + + /// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS + /// the rotation primitive. The new V_me_epoch is generated and the + /// bio post is republished with wrappers for every remaining target + /// (current=1); the revoked persona only ever held the old V_me, so + /// they're frozen out of future content but retain access to old + /// content (grandfathered) per Layer 4. + pub async fn revoke_vouch_and_rotate(&self, target: &NodeId) -> anyhow::Result<()> { + use rand::RngCore; + let (default_id, display_name, bio, avatar_cid, posting_secret) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + anyhow::bail!("no default posting identity"); + }; + let pi = storage.get_posting_identity(&default_id)? + .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; + let profile = storage.get_profile(&default_id)?; + let (name, bio, avatar) = match profile { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (pi.display_name.clone(), String::new(), None), + }; + (default_id, name, bio, avatar, pi.secret_seed) + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + { + let storage = self.storage.get().await; + // Soft-revoke: drop from current set (row retained for the + // audit trail + cascade-pickup later if needed). + storage.revoke_vouch_target(&default_id, target)?; + + // Rotate V_me: pick the next epoch, insert as current. Prior + // epoch retained (Layer 4 receiver-chain model). + let next_epoch = storage.current_own_vouch_key(&default_id)? + .map(|(e, _)| e + 1) + .unwrap_or(1); + let mut new_key = [0u8; 32]; + rand::rng().fill_bytes(&mut new_key); + storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; + } + + // Republish bio post — new V_me wrapped to every still-current target. + self.publish_profile_post_as( + &default_id, &posting_secret, &display_name, &bio, avatar_cid, + ).await?; + Ok(()) + } + + /// List vouches the default persona has issued. Returns + /// `(target_node_id, display_name, granted_at_ms)` tuples. + pub async fn list_vouches_given(&self) -> anyhow::Result> { + let (default_id, targets) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + return Ok(Vec::new()); + }; + let targets = storage.list_current_vouch_targets(&default_id)?; + (default_id, targets) + }; + let _ = default_id; + let mut out = Vec::with_capacity(targets.len()); + for (tid, _xpub, at) in targets { + let display = match self.resolve_display_name(&tid).await { + Ok((name, _, _)) if !name.is_empty() => name, + _ => String::new(), + }; + out.push((tid, display, at)); + } + Ok(out) + } + + /// List vouches received by the default persona. Returns + /// `(voucher_node_id, display_name, latest_epoch, latest_received_at_ms)`. + pub async fn list_vouches_received(&self) -> anyhow::Result> { + let (default_id, vouchers) = { + let storage = self.storage.get().await; + let Some(default_id) = storage.get_default_posting_id()? else { + return Ok(Vec::new()); + }; + let vouchers = storage.list_vouchers_for(&default_id)?; + (default_id, vouchers) + }; + let _ = default_id; + let mut out = Vec::with_capacity(vouchers.len()); + for (owner, epoch, at) in vouchers { + let display = match self.resolve_display_name(&owner).await { + Ok((name, _, _)) if !name.is_empty() => name, + _ => String::new(), + }; + out.push((owner, display, epoch, at)); + } + Ok(out) + } + // ---- Blobs ---- /// Get a blob by CID from local store. diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 44e3c93..2b99819 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1089,6 +1089,62 @@ async fn list_ignored_peers(state: State<'_, AppNode>) -> Result, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.vouch_for_peer(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn list_vouches_given(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?; + Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto { + node_id: hex::encode(nid), + display_name: name, + granted_at_ms: at, + }).collect()) +} + +#[tauri::command] +async fn list_vouches_received(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?; + Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto { + node_id: hex::encode(nid), + display_name: name, + epoch, + received_at_ms: at, + }).collect()) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3103,6 +3159,10 @@ pub fn run() { ignore_peer, unignore_peer, list_ignored_peers, + vouch_for_peer, + revoke_vouch_for_peer, + list_vouches_given, + list_vouches_received, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index e01fe3b..2050913 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1633,8 +1633,10 @@ async function openBioModal(nodeId, preloadedName) { const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); const follows = await invoke('list_follows').catch(() => []); const ignored = await invoke('list_ignored_peers').catch(() => []); + const vouches = await invoke('list_vouches_given').catch(() => []); const following = follows.some(f => f.nodeId === nodeId); const isIgnored = ignored.some(i => i.nodeId === nodeId); + const isVouched = vouches.some(v => v.nodeId === nodeId); const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12); const bio = (resolved && resolved.bio) || ''; const icon = generateIdenticon(nodeId, 48); @@ -1654,6 +1656,9 @@ async function openBioModal(nodeId, preloadedName) { ${following ? `` : ``} + ${isVouched + ? `` + : ``} ${isIgnored ? `` @@ -1700,6 +1705,27 @@ async function openBioModal(nodeId, preloadedName) { try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); } catch (e) { toast('Error: ' + e); } }; + const vouch = document.getElementById('bio-vouch'); + if (vouch) vouch.onclick = async () => { + vouch.disabled = true; + try { + await invoke('vouch_for_peer', { nodeIdHex: nodeId }); + toast(`Vouched for ${name}`); + close(); + } catch (e) { toast('Error: ' + e); } + finally { vouch.disabled = false; } + }; + const revokeVouch = document.getElementById('bio-revoke-vouch'); + if (revokeVouch) revokeVouch.onclick = async () => { + if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; + revokeVouch.disabled = true; + try { + await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId }); + toast('Revoked and rotated'); + close(); + } catch (e) { toast('Error: ' + e); } + finally { revokeVouch.disabled = false; } + }; } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; } @@ -3062,7 +3088,7 @@ document.querySelectorAll('.tab').forEach(tab => { loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); } - if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); } + if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches(); } }); }); }); @@ -3618,6 +3644,59 @@ async function loadIgnored() { } } +async function loadVouches() { + const givenEl = document.getElementById('vouches-given-list'); + const recvEl = document.getElementById('vouches-received-list'); + if (!givenEl || !recvEl) return; + try { + const [given, received] = await Promise.all([ + invoke('list_vouches_given').catch(() => []), + invoke('list_vouches_received').catch(() => []), + ]); + if (!given || given.length === 0) { + givenEl.innerHTML = '

No vouches given.

'; + } else { + givenEl.innerHTML = given.map(v => { + const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12)); + const icon = generateIdenticon(v.nodeId, 18); + return `
+
${icon} ${label}
+
+ +
+
`; + }).join(''); + givenEl.querySelectorAll('.revoke-vouch-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.name; + if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; + btn.disabled = true; + try { + await invoke('revoke_vouch_for_peer', { nodeIdHex: btn.dataset.nodeId }); + toast('Revoked and rotated'); + loadVouches(); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + } + if (!received || received.length === 0) { + recvEl.innerHTML = '

No vouches received.

'; + } else { + recvEl.innerHTML = received.map(v => { + const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12)); + const icon = generateIdenticon(v.nodeId, 18); + return `
+
${icon} ${label}
+
epoch ${v.epoch}
+
`; + }).join(''); + } + } catch (e) { + givenEl.innerHTML = `

Error: ${e}

`; + } +} + // --- Release announcement / upgrade banner --- async function loadUpgradeBanner() { const banner = document.getElementById('upgrade-banner'); diff --git a/frontend/index.html b/frontend/index.html index 87d9c83..577fe08 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -198,6 +198,21 @@
+
+

Vouches

+

Vouches you've given let those friends read your Friend-of-Friend posts. Vouches you've received unlock the posts of those who vouched for you. Revoking rotates your vouch key — the revoked friend keeps access to your existing posts, but not future ones.

+
+
+

Given

+
+
+
+

Received

+
+
+
+
+

Updates

Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.