diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e5e267f..61c959c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -905,6 +905,7 @@ async fn print_post( itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => { format!(" [group-encrypted, epoch {}]", epoch) } + itsgoin_core::types::PostVisibility::FoFClosed => " [fof-closed]".to_string(), }; let ts = post.timestamp_ms / 1000; @@ -917,6 +918,10 @@ async fn print_post( Some(text) => text.to_string(), None => "(encrypted)".to_string(), }, + itsgoin_core::types::PostVisibility::FoFClosed => match decrypted { + Some(text) => text.to_string(), + None => "(fof-closed; not in this FoF set)".to_string(), + }, }; println!("---"); diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index af9223a..8d0e90d 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -1093,6 +1093,70 @@ mod tests { assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err()); } + /// End-to-end FoFClosed roundtrip at the helper level: Alice + /// encrypts a body; Bob (with Alice's V_me as a received V_x) + /// trial-unlocks the gating + decrypts the body. Carol (no + /// matching V_x) cannot unlock and the body stays opaque. + #[test] + fn fof_closed_body_end_to_end() { + use crate::types::PostingIdentity; + + let s = temp_storage(); + + // Alice has V_me; she'll author a FoFClosed post. + let (alice_id, alice_seed) = make_persona(70); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Alice received Bob's V_x — so the gating includes Bob's slot. + let (bob_id, _bob_seed) = make_persona(71); + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let body_plaintext = "secret to the FoF set only"; + let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap(); + + // Bob's device (with his V_me == v_x_bob) sees the gating block + // and trial-unlocks via his V_me. + let bob_storage = temp_storage(); + bob_storage.upsert_posting_identity(&PostingIdentity { + node_id: bob_id, secret_seed: _bob_seed, + display_name: "Bob".into(), created_at: 1500, + }).unwrap(); + bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap(); + + let alice_post = crate::types::Post { + author: alice_id, content: String::new(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + }; + let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap() + .expect("Bob can unlock"); + let bob_decrypted = decrypt_fof_body(&body_ct, &bob_unlock.cek, &built.slot_binder_nonce).unwrap(); + assert_eq!(bob_decrypted, body_plaintext); + + // Carol has no matching V_x — cannot unlock. + let carol_storage = temp_storage(); + let (carol_id, carol_seed) = make_persona(72); + carol_storage.upsert_posting_identity(&PostingIdentity { + node_id: carol_id, secret_seed: carol_seed, + display_name: "Carol".into(), created_at: 1500, + }).unwrap(); + let mut v_me_carol = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_carol); + carol_storage.insert_own_vouch_key(&carol_id, 1, &v_me_carol, 1500).unwrap(); + + let carol_unlock = find_unlock_for_post(&carol_storage, &alice_post).unwrap(); + assert!(carol_unlock.is_none(), + "Carol has no matching V_x and cannot unlock the FoFClosed gating"); + } + #[test] fn fof_body_padding_hides_real_length() { let cek = [0x55; 32]; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 66ec5cb..8e78fef 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1076,6 +1076,104 @@ impl Node { Ok((post_id, post, visibility, cek)) } + /// FoF Layer 3: read the decrypted body of a FoFClosed post if any + /// of this device's personas can unlock it. Returns `Ok(None)` for + /// non-FoFClosed posts and for FoFClosed posts not reachable via + /// any held V_x. Errors only on storage/crypto faults. + pub async fn read_fof_closed_body( + &self, + post_id: &PostId, + ) -> anyhow::Result> { + use base64::Engine; + let storage = self.storage.get().await; + let (post, visibility) = match storage.get_post_with_visibility(post_id)? { + Some(pv) => pv, + None => return Ok(None), + }; + if !matches!(visibility, PostVisibility::FoFClosed) { + return Ok(None); + } + let gating = match post.fof_gating.as_ref() { + Some(g) => g, + None => return Ok(None), // invariant violation; treat as opaque + }; + let slot_binder_nonce = gating.slot_binder_nonce; + + let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? { + Some(u) => u, + None => return Ok(None), // we're not in the FoF set + }; + drop(storage); + + // Decode the base64-wrapped ciphertext + decrypt. + let body_ct = base64::engine::general_purpose::STANDARD + .decode(post.content.as_bytes()) + .map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?; + let plaintext = crate::fof::decrypt_fof_body(&body_ct, &unlock.cek, &slot_binder_nonce)?; + Ok(Some(plaintext)) + } + + /// FoF Layer 3: create a Mode 1 post (FoFClosed). The body is + /// encrypted under the gating CEK before storage; only readers + /// who can unlock a wrap_slot can decrypt it. Comments are also + /// FoF-gated, inheriting Layer 2's path. + /// + /// Returns `(post_id, post, visibility, cek)`. + pub async fn create_post_fof_closed( + &self, + content: String, + ) -> anyhow::Result<(PostId, Post, [u8; 32])> { + let built = { + let storage = self.storage.get().await; + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + .ok_or_else(|| anyhow::anyhow!( + "default persona has no V_me; rotate or recreate before FoF posts" + ))? + }; + let cek = built.cek; + let slot_binder_nonce = built.slot_binder_nonce; + + // Encrypt + pad body under the gating CEK. Output is base64'd + // so it can live in Post.content (which is a String). + let encrypted_body = crate::fof::encrypt_fof_body(&content, &cek, &slot_binder_nonce)?; + let body_b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&encrypted_body) + }; + + // Build + store + propagate. Visibility is FoFClosed (tag); + // gating lives in Post.fof_gating. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let post = Post { + author: self.default_posting_id, + content: body_b64, + attachments: vec![], + timestamp_ms: now, + fof_gating: Some(built.gating), + }; + let post_id = crate::content::compute_post_id(&post); + + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &post_id, &post, + &PostVisibility::FoFClosed, + &VisibilityIntent::Public, + )?; + } + + self.update_neighbor_manifests_as( + &self.default_posting_id, + &self.default_posting_secret, + &post_id, + now, + ).await; + + Ok((post_id, post, cek)) + } + async fn create_post_inner( &self, posting_id: &NodeId, diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 96378ee..a1b373a 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -256,6 +256,10 @@ async fn post_to_dto( Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())), None => ("encrypted".to_string(), None), }, + // FoF Layer 3: FoFClosed body. Decrypted is None from the sync + // feed-pre-decrypt helper; the frontend calls read_fof_closed_body + // for any post with visibility == "fof-closed" to fill in the body. + PostVisibility::FoFClosed => ("fof-closed".to_string(), None), }; let recipients = match vis { PostVisibility::Encrypted { recipients } => { @@ -346,6 +350,10 @@ async fn decrypt_just_created( None } } + // FoF Layer 3: FoFClosed body decrypt happens via the dedicated + // async read_fof_closed_body command. This sync helper returns + // None and the frontend dispatches the FoF read explicitly. + PostVisibility::FoFClosed => None, } } @@ -910,6 +918,7 @@ async fn post_to_dto_batch( Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())), None => ("encrypted".to_string(), None), }, + PostVisibility::FoFClosed => ("fof-closed".to_string(), None), }; let recipients = match vis { PostVisibility::Encrypted { recipients } => { @@ -1192,6 +1201,33 @@ async fn revoke_fof_commenter( .map_err(|e| e.to_string()) } +// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments. + +#[tauri::command] +async fn create_post_fof_closed( + state: State<'_, AppNode>, + content: String, +) -> Result { + let node = get_node(&state).await; + let (post_id, _post, _cek) = node + .create_post_fof_closed(content) + .await + .map_err(|e| e.to_string())?; + Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) +} + +/// Returns the decrypted body of a FoFClosed post if any local persona +/// can unlock it. `None` means "ciphertext only" (not in the FoF set). +#[tauri::command] +async fn read_fof_closed_body( + state: State<'_, AppNode>, + post_id_hex: String, +) -> Result, String> { + let node = get_node(&state).await; + let pid = parse_node_id(&post_id_hex)?; + node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string()) +} + #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3213,6 +3249,8 @@ pub fn run() { create_post_with_fof_comments, comment_on_fof_post, revoke_fof_commenter, + create_post_fof_closed, + read_fof_closed_body, list_circles, create_circle, delete_circle, diff --git a/frontend/app.js b/frontend/app.js index 8abd5e6..5194405 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -486,11 +486,18 @@ function renderPost(post, index) { visBadge = 'encrypted'; } else if (post.visibility === 'encrypted') { visBadge = 'encrypted'; + } else if (post.visibility === 'fof-closed') { + visBadge = 'fof-closed'; } let displayContent; if (post.visibility === 'encrypted' && !post.decryptedContent) { displayContent = '(encrypted)'; + } else if (post.visibility === 'fof-closed' && !post.decryptedContent) { + // FoF Layer 3: render as locked placeholder. The frontend fires + // an async read_fof_closed_body call after first paint to fill + // in the body for FoF readers (see loadFeed below). + displayContent = `(fof-closed — unlocking…)`; } else if (post.decryptedContent) { displayContent = escapeHtml(post.decryptedContent); } else { @@ -579,6 +586,29 @@ function renderMessage(post, index, showFollowBtn) { `; } +/// FoF Layer 3: post-render pass — find all "(fof-closed — unlocking…)" +/// placeholders in the given root and dispatch read_fof_closed_body. +/// Replaces each placeholder's text with the decrypted body, or with a +/// "not in this FoF set" notice if the caller can't unlock. +async function unlockFoFClosedPlaceholders(rootEl) { + const placeholders = rootEl.querySelectorAll('[data-fof-closed-pending]'); + for (const el of placeholders) { + const postId = el.dataset.fofClosedPending; + try { + const body = await invoke('read_fof_closed_body', { postIdHex: postId }); + if (body) { + el.textContent = body; + el.classList.remove('encrypted-placeholder'); + } else { + el.textContent = '(fof-closed — not in this FoF set)'; + } + } catch (_) { + el.textContent = '(fof-closed — error)'; + } + delete el.dataset.fofClosedPending; + } +} + function renderEmptyState(message, hint) { return `
@@ -793,6 +823,9 @@ async function loadFeed(force) { } } else { feedList.innerHTML = filterBanner + posts.map(renderPost).join(''); + // FoF Layer 3: any rendered FoFClosed post enters with a + // placeholder body; trigger the async unlock pass to fill in. + unlockFoFClosedPlaceholders(feedList); if (authorFilterNodeId) { const clearBtn = document.getElementById('clear-author-filter'); if (clearBtn) clearBtn.onclick = clearAuthorFilter; @@ -947,6 +980,7 @@ async function loadMyPosts(force) { myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!'); } else { myPostsList.innerHTML = mine.map(renderPost).join(''); + unlockFoFClosedPlaceholders(myPostsList); if (_myPostsHasMore) { const sentinel = document.createElement('div'); sentinel.id = 'myposts-scroll-sentinel'; @@ -2639,11 +2673,8 @@ async function doPost() { // keyring. Routed through a dedicated command because the // gating block is signed at publish time (can't be added // via SetPolicy after the fact). - // Attachments + non-default-persona FoF posts are not yet - // supported by the v1 command — fall through to the - // standard path with a warning if either applies. if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF posts with attachments or non-default persona require Mode 1 (later).'); + toast('FoF posts with attachments or non-default persona not yet supported.'); postBtn.disabled = false; return; } @@ -2651,6 +2682,20 @@ async function doPost() { content: params.content, }); result = { id: created.postId }; + } else if (commentPerm === 'fof_closed') { + // FoF Layer 3 / Mode 1: body itself encrypted under the + // gating CEK. Non-FoF observers see only ciphertext; + // FoF readers unlock + decrypt on render via + // read_fof_closed_body. + if (selectedFiles.length > 0 || params.postingIdHex) { + toast('FoFClosed posts with attachments or non-default persona not yet supported.'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_fof_closed', { + content: params.content, + }); + result = { id: created.postId }; } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { diff --git a/frontend/index.html b/frontend/index.html index 0f62760..82e2b31 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -105,6 +105,7 @@ +