feat(fof-layer3): Mode 1 publish + read + Tauri + UI wiring
End-to-end FoFClosed (Mode 1: encrypted body + FoF comments): Node API: - create_post_fof_closed(content) -> (PostId, Post, cek) Builds gating, encrypts body via fof::encrypt_fof_body, base64s it into post.content, stores with visibility=FoFClosed + intent=Public, propagates via update_neighbor_manifests_as. - read_fof_closed_body(post_id) -> Option<String> Trial-unlocks via find_unlock_for_post, decrypts body, returns plaintext. Returns None for non-FoFClosed or non-member readers. Tauri commands: - create_post_fof_closed, read_fof_closed_body. Registered in generate_handler!. Feed rendering: - PostDto.visibility carries the new "fof-closed" string. - renderPost(): FoFClosed posts render with a locked placeholder (data-fof-closed-pending=post_id span). Visual badge added. - unlockFoFClosedPlaceholders(rootEl): post-render async pass that scans for placeholder spans and dispatches read_fof_closed_body for each. Fills in body for FoF readers; falls back to a "not in this FoF set" notice otherwise. - Wired into feed-list and my-posts-list render paths. Compose: - "Body+Comments: FoF only (Mode 1)" option in comment-perm-select. Selected → dispatches to create_post_fof_closed. CLI feed renderer + Tauri feed-DTO match arms updated to handle FoFClosed. New end-to-end test brings total to 146: - fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with Alice's V_me in his keyring) unlocks + decrypts; Carol (no matching V_x) cannot unlock and sees only ciphertext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
856f386231
commit
66b78041fc
6 changed files with 255 additions and 4 deletions
|
|
@ -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<Option<String>> {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue