diff --git a/Cargo.lock b/Cargo.lock index 5689dce..a895881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.0" +version = "0.6.2" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.0" +version = "0.6.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.6.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8515386..478c1d2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.0" +version = "0.6.2" edition = "2021" [[bin]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 61c959c..e5e267f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -905,7 +905,6 @@ 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; @@ -918,10 +917,6 @@ 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/Cargo.toml b/crates/core/Cargo.toml index 67d7750..a894f92 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.0" +version = "0.6.2" edition = "2021" [dependencies] diff --git a/crates/core/src/announcement.rs b/crates/core/src/announcement.rs index c9b9f02..a3a6c89 100644 --- a/crates/core/src/announcement.rs +++ b/crates/core/src/announcement.rs @@ -143,8 +143,6 @@ pub fn build_announcement_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, } } diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 9a022a5..1d4917b 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6171,53 +6171,6 @@ impl ConnectionManager { } } crate::types::CommentPermission::Public => {} - crate::types::CommentPermission::FriendsOfFriends => { - // FoF Layer 2 CDN four-check accept rule: - // 1. parent post must carry fof_gating - // (otherwise the policy is ambient - // with no key material to verify); - // 2. pub_x_index must point at a real - // entry in pub_post_set; - // 3. group_sig must validate against - // pub_post_set[pub_x_index]; - // 4. revocation_list must not contain - // pub_post_set[pub_x_index]; - // 5. identity_sig (existing comment - // signature field) verified below. - // - // Failures drop the comment without - // forwarding — kills the bandwidth-DoS - // attack an admitted-but-malicious FoF - // member could otherwise mount. - let parent = match storage.get_post(&payload.post_id) { - Ok(Some(p)) => p, - _ => continue, - }; - let Some(gating) = parent.fof_gating.as_ref() else { continue; }; - if !crate::fof::verify_fof_group_sig(comment, gating) { - continue; - } - // Revocation check (step 4). Two - // sources: the post's snapshot - // revocation_list (rarely populated on - // publish) and the live local table - // fof_revocations (accumulated as - // revocation diffs arrive on the wire). - if let Some(idx) = comment.pub_x_index { - let pub_x = gating.pub_post_set - .get(idx as usize) - .copied(); - if let Some(pub_x) = pub_x { - if gating.revocation_list.iter() - .any(|r| r.revoked_pub_x == pub_x) { - continue; - } - if storage.is_fof_pub_x_revoked(&payload.post_id, &pub_x).unwrap_or(false) { - continue; - } - } - } - } } if !crate::crypto::verify_comment_signature( &comment.author, @@ -6248,63 +6201,6 @@ impl ConnectionManager { let _ = storage.set_comment_policy(&payload.post_id, new_policy); } } - BlobHeaderDiffOp::FoFRevocation { - post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, - } => { - // Verify author identity signature before applying. - // payload.author is the engagement-diff sender; the - // post's real author lives in storage. - let post_author = match storage.get_post(post_id) { - Ok(Some(p)) => p.author, - _ => continue, - }; - if !crate::fof::verify_fof_revocation( - &post_author, post_id, revoked_pub_x, - *revoked_at_ms, *reason_code, author_sig, - ) { - continue; - } - // Apply: record + cascade-delete stored comments. - let _ = crate::fof::apply_fof_revocation_locally( - &storage, post_id, revoked_pub_x, - *revoked_at_ms, *reason_code, author_sig, - ); - } - BlobHeaderDiffOp::FoFAccessGrant { - post_id, new_pub_x, new_wrap_slot, granted_at_ms, author_sig, - } => { - let post_author = match storage.get_post(post_id) { - Ok(Some(p)) => p.author, - _ => continue, - }; - if !crate::fof::verify_fof_access_grant( - &post_author, post_id, new_pub_x, new_wrap_slot, - *granted_at_ms, author_sig, - ) { - continue; - } - let _ = crate::fof::apply_fof_access_grant_locally( - &storage, post_id, new_pub_x, new_wrap_slot, - ); - } - BlobHeaderDiffOp::FoFKeyBurn { - post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms, author_sig, - } => { - let post_author = match storage.get_post(post_id) { - Ok(Some(p)) => p.author, - _ => continue, - }; - if !crate::fof::verify_fof_key_burn( - &post_author, post_id, *slot_index, new_pub_x, new_wrap_slot, - *burned_at_ms, author_sig, - ) { - continue; - } - let _ = crate::fof::apply_fof_key_burn_locally( - &storage, post_id, *slot_index, new_pub_x, new_wrap_slot, - *burned_at_ms, - ); - } BlobHeaderDiffOp::ThreadSplit { new_post_id } => { let _ = storage.store_thread_meta(&crate::types::ThreadMeta { post_id: *new_post_id, diff --git a/crates/core/src/content.rs b/crates/core/src/content.rs index 97bb6c3..ecb3664 100644 --- a/crates/core/src/content.rs +++ b/crates/core/src/content.rs @@ -23,8 +23,6 @@ mod tests { content: "hello world".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; let id1 = compute_post_id(&post); let id2 = compute_post_id(&post); @@ -38,16 +36,12 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; let post2 = Post { author: [1u8; 32], content: "world".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; assert_ne!(compute_post_id(&post1), compute_post_id(&post2)); } @@ -59,8 +53,6 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; let id = compute_post_id(&post); assert!(verify_post_id(&id, &post)); diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs index 2f9b984..9d75d98 100644 --- a/crates/core/src/control.rs +++ b/crates/core/src/control.rs @@ -120,15 +120,6 @@ pub fn receive_post( _ => {} } - // FoF Layer 2/3 hardening (pre-deploy audit): reject malformed - // FoF gating blocks BEFORE storage. Bounds wrap_slots count, - // enforces 1:1 wrap_slots/pub_post_set parity, validates - // ciphertext field sizes, caps revocation_list. Also enforces - // the FoFClosed-implies-gating invariant. Rejection prevents - // bad data from being re-propagated via neighbor-manifest diffs. - crate::fof::validate_fof_closed_has_gating(post, visibility)?; - crate::fof::validate_fof_gating_on_receive(post)?; - let stored = if let Some(intent) = intent { s.store_post_with_intent(id, post, visibility, intent)? } else { @@ -164,8 +155,6 @@ pub fn build_delete_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, } } @@ -193,8 +182,6 @@ pub fn build_visibility_control_post( content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, } } @@ -225,8 +212,6 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); @@ -255,8 +240,6 @@ mod tests { content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 9130d6b..6b9d24f 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -12,23 +12,6 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1"; -/// FoF Layer 1: vouch-grant HPKE-style wrapper construction. -/// HKDF/derive_key info MUST be recipient-free (key privacy). -/// `bio_post_id` ties the wrapper to the publishing bio post. -const VOUCH_GRANT_KEY_CONTEXT: &str = "itsgoin/vouch-grant/v1/key"; -const VOUCH_GRANT_NONCE_CONTEXT: &str = "itsgoin/vouch-grant/v1/nonce"; - -/// FoF Layer 2: per-V_x wrap-slot derivation contexts. Each slot is -/// dual-derived under a different sub-context: `read` yields the CEK -/// (read capability), `sign` yields the per-V_x signing seed. Bound to -/// the post via `slot_binder_nonce` (a random 32B nonce in the post -/// header — not the PostId, which would be circular). -const WRAP_SLOT_READ_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/key"; -const WRAP_SLOT_READ_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/read/nonce"; -const WRAP_SLOT_SIGN_KEY_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/key"; -const WRAP_SLOT_SIGN_NONCE_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/sign/nonce"; -const WRAP_SLOT_PREFILTER_CONTEXT: &str = "itsgoin/fof-wrap-slot/v1/prefilter"; - /// Convert an ed25519 seed (32 bytes from identity.key) to X25519 private scalar bytes. pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] { let signing_key = SigningKey::from_bytes(seed); @@ -210,260 +193,6 @@ pub fn unwrap_group_cek( Ok(cek) } -// --- FoF Layer 1: vouch-grant HPKE-style seal/open --- -// -// Per the FoF spec (docs/fof-spec/layer-1-vouch-primitive.md), a voucher -// publishes anonymous per-recipient wrappers inside their bio post. Each -// wrapper carries `V_me` (the voucher's symmetric key) sealed under a -// shared secret derived from ECDH between a per-batch ephemeral X25519 -// keypair and the recipient's persona X25519 key. -// -// Recipient anonymity ("key privacy") is preserved because: -// 1. Wrappers carry no recipient identifier. -// 2. The KDF info string is recipient-free (only the post_id appears). -// 3. All wrappers in a batch share the same ephemeral pubkey. -// -// Wire shape: 48 bytes per wrapper (32B sealed V_me + 16B AEAD tag). -// One 32B ephemeral pubkey shared across all wrappers in the batch. - -/// Generate a fresh ephemeral X25519 keypair for a vouch-grant batch. -/// Returns `(eph_priv_scalar, eph_pub)` in X25519 byte form. Reuses the -/// ed25519 → X25519 derivation path that the rest of the codebase uses -/// so all X25519 endpoints are produced identically. -pub fn generate_vouch_batch_ephemeral() -> ([u8; 32], [u8; 32]) { - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let eph_priv = ed25519_seed_to_x25519_private(&seed); - let signing_key = SigningKey::from_bytes(&seed); - let eph_pub = signing_key.verifying_key().to_montgomery().to_bytes(); - (eph_priv, eph_pub) -} - -/// Derive the (wrapping_key, nonce) pair for a vouch-grant wrapper from -/// the ECDH shared secret and the publishing bio post's ID. -fn derive_vouch_grant_key_nonce( - shared_secret: &[u8; 32], - bio_post_id: &PostId, -) -> ([u8; 32], [u8; 12]) { - // Bake bio_post_id into the derivation context. Recipient-free. - let key_ctx = format!("{}/{}", VOUCH_GRANT_KEY_CONTEXT, hex_lower(bio_post_id)); - let nonce_ctx = format!("{}/{}", VOUCH_GRANT_NONCE_CONTEXT, hex_lower(bio_post_id)); - let wrapping_key = blake3::derive_key(&key_ctx, shared_secret); - let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&nonce_full[..12]); - (wrapping_key, nonce) -} - -fn hex_lower(bytes: &[u8; 32]) -> String { - let mut s = String::with_capacity(64); - for b in bytes { - s.push_str(&format!("{:02x}", b)); - } - s -} - -/// Seal `V_me` (32B) under the recipient's X25519 pubkey using the -/// batch's ephemeral X25519 private key. Returns the 48-byte wrapper -/// `ciphertext(32) || tag(16)`. -pub fn seal_vouch_grant( - eph_priv: &[u8; 32], - recipient_x25519_pub: &[u8; 32], - bio_post_id: &PostId, - v_me: &[u8; 32], -) -> Result> { - let shared_secret = x25519_dh(eph_priv, recipient_x25519_pub); - let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); - let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key) - .map_err(|e| anyhow::anyhow!("vouch-grant cipher init: {}", e))?; - let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce), v_me.as_slice()) - .map_err(|e| anyhow::anyhow!("vouch-grant seal: {}", e))?; - // ChaCha20-Poly1305 output is 32B plaintext + 16B tag = 48B. - if ciphertext.len() != 48 { - bail!("unexpected vouch-grant wrapper length: {}", ciphertext.len()); - } - Ok(ciphertext) -} - -/// Try to open a vouch-grant wrapper using the recipient's X25519 private -/// scalar. Returns `Some(V_me)` on success, `None` on AEAD failure (i.e., -/// this wrapper was not addressed to this recipient). -pub fn open_vouch_grant( - recipient_x25519_priv: &[u8; 32], - batch_eph_pub: &[u8; 32], - bio_post_id: &PostId, - wrapper_ciphertext: &[u8], -) -> Option<[u8; 32]> { - if wrapper_ciphertext.len() != 48 { - return None; - } - let shared_secret = x25519_dh(recipient_x25519_priv, batch_eph_pub); - let (wrapping_key, nonce) = derive_vouch_grant_key_nonce(&shared_secret, bio_post_id); - let cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key).ok()?; - let plaintext = cipher - .decrypt(Nonce::from_slice(&nonce), wrapper_ciphertext) - .ok()?; - if plaintext.len() != 32 { - return None; - } - let mut v_me = [0u8; 32]; - v_me.copy_from_slice(&plaintext); - Some(v_me) -} - -// --- FoF Layer 2: wrap-slot seal/open (dual-derived read + sign) --- -// -// Each post under FoF comment-gating carries one wrap slot per -// admitted V_x. The slot is dual-derived: one half yields the post's -// shared CEK (read capability), the other yields the per-V_x signing -// seed priv_x (comment-authorship capability for that voucher-chain). -// -// Receivers trial-decrypt slots whose prefilter tag matches one of -// their held V_x's. Successful AEAD-open on the `read` part gives -// them CEK; the `sign` part gives them priv_x. They derive the -// matching pub_x = ed25519_pub(priv_x_seed); the CDN verifies their -// comment signatures against pub_x via the post's pub_post_set. -// -// All AEAD derivation is bound to a per-post `slot_binder_nonce` -// (random 32B in the post header). This plays the same role as the -// spec's "post_id in HKDF info" but isn't circular (PostId = -// BLAKE3(post) depends on wrap_slots → circular). -// -// Wire shape: -// prefilter_tag: 2 bytes (HMAC(V_x, slot_binder_nonce)[:2]) -// read_part: 48 bytes (32B sealed CEK + 16B tag) -// sign_part: 48 bytes (32B sealed priv_x seed + 16B tag) -// Total: 98 bytes per slot. - -/// Output of [`seal_wrap_slot`]. All fields are wire-stable. See module -/// doc above for derivation details. -#[derive(Debug, Clone)] -pub struct SealedWrapSlot { - pub prefilter_tag: [u8; 2], - pub read_ciphertext: Vec, // 48 bytes - pub sign_ciphertext: Vec, // 48 bytes -} - -/// Compute the 2-byte prefilter tag for a (V_x, slot_binder_nonce) -/// pair. Cheap; receivers precompute one per held V_x per post and -/// skip non-matching slots entirely. -pub fn wrap_slot_prefilter_tag(v_x: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 2] { - let mut input = [0u8; 64]; - input[..32].copy_from_slice(slot_binder_nonce); - input[32..].copy_from_slice(v_x); - let tag = blake3::derive_key(WRAP_SLOT_PREFILTER_CONTEXT, &input); - [tag[0], tag[1]] -} - -fn derive_wrap_slot_part( - v_x: &[u8; 32], - slot_binder_nonce: &[u8; 32], - key_ctx: &str, - nonce_ctx: &str, -) -> ([u8; 32], [u8; 12]) { - let mut input = [0u8; 64]; - input[..32].copy_from_slice(slot_binder_nonce); - input[32..].copy_from_slice(v_x); - let key = blake3::derive_key(key_ctx, &input); - let nonce_full = blake3::derive_key(nonce_ctx, &input); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&nonce_full[..12]); - (key, nonce) -} - -/// Seal one wrap slot for a specific V_x. Pair (CEK, priv_x_seed) is -/// the slot plaintext: the read part carries CEK, the sign part carries -/// priv_x_seed. Both halves are bound to `slot_binder_nonce` via HKDF. -pub fn seal_wrap_slot( - v_x: &[u8; 32], - slot_binder_nonce: &[u8; 32], - cek: &[u8; 32], - priv_x_seed: &[u8; 32], -) -> Result { - let (read_key, read_nonce) = derive_wrap_slot_part( - v_x, slot_binder_nonce, - WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, - ); - let (sign_key, sign_nonce) = derive_wrap_slot_part( - v_x, slot_binder_nonce, - WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, - ); - let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key) - .map_err(|e| anyhow::anyhow!("read cipher init: {}", e))?; - let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key) - .map_err(|e| anyhow::anyhow!("sign cipher init: {}", e))?; - let read_ct = read_cipher - .encrypt(Nonce::from_slice(&read_nonce), cek.as_slice()) - .map_err(|e| anyhow::anyhow!("read seal: {}", e))?; - let sign_ct = sign_cipher - .encrypt(Nonce::from_slice(&sign_nonce), priv_x_seed.as_slice()) - .map_err(|e| anyhow::anyhow!("sign seal: {}", e))?; - if read_ct.len() != 48 || sign_ct.len() != 48 { - bail!("unexpected wrap-slot ciphertext length"); - } - Ok(SealedWrapSlot { - prefilter_tag: wrap_slot_prefilter_tag(v_x, slot_binder_nonce), - read_ciphertext: read_ct, - sign_ciphertext: sign_ct, - }) -} - -/// Output of a successful [`open_wrap_slot`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenedWrapSlot { - pub cek: [u8; 32], - pub priv_x_seed: [u8; 32], -} - -/// Try to open a wrap slot using one of the receiver's V_x's. Returns -/// `None` if either AEAD fails (this slot isn't sealed under this V_x). -pub fn open_wrap_slot( - v_x: &[u8; 32], - slot_binder_nonce: &[u8; 32], - read_ciphertext: &[u8], - sign_ciphertext: &[u8], -) -> Option { - if read_ciphertext.len() != 48 || sign_ciphertext.len() != 48 { - return None; - } - let (read_key, read_nonce) = derive_wrap_slot_part( - v_x, slot_binder_nonce, - WRAP_SLOT_READ_KEY_CONTEXT, WRAP_SLOT_READ_NONCE_CONTEXT, - ); - let (sign_key, sign_nonce) = derive_wrap_slot_part( - v_x, slot_binder_nonce, - WRAP_SLOT_SIGN_KEY_CONTEXT, WRAP_SLOT_SIGN_NONCE_CONTEXT, - ); - let read_cipher = ChaCha20Poly1305::new_from_slice(&read_key).ok()?; - let sign_cipher = ChaCha20Poly1305::new_from_slice(&sign_key).ok()?; - let cek_bytes = read_cipher - .decrypt(Nonce::from_slice(&read_nonce), read_ciphertext) - .ok()?; - let seed_bytes = sign_cipher - .decrypt(Nonce::from_slice(&sign_nonce), sign_ciphertext) - .ok()?; - if cek_bytes.len() != 32 || seed_bytes.len() != 32 { - return None; - } - let mut cek = [0u8; 32]; - cek.copy_from_slice(&cek_bytes); - let mut priv_x_seed = [0u8; 32]; - priv_x_seed.copy_from_slice(&seed_bytes); - Some(OpenedWrapSlot { cek, priv_x_seed }) -} - -/// Derive the per-post comments CEK from the wrap-slot CEK. The -/// comments-CEK is used to encrypt comment bodies separately from the -/// post body — preserves the option of body-public + comments-private -/// (Mode 2) without leaking the body CEK relationship. -pub fn derive_cek_comments(cek: &[u8; 32], slot_binder_nonce: &[u8; 32]) -> [u8; 32] { - let mut input = [0u8; 64]; - input[..32].copy_from_slice(cek); - input[32..].copy_from_slice(slot_binder_nonce); - blake3::derive_key("itsgoin/fof-cek-comments/v1", &input) -} - /// Encrypt a post with a provided CEK, wrapping for recipients. /// Returns `(base64_ciphertext, Vec)`. pub fn encrypt_post_with_cek( @@ -1618,150 +1347,4 @@ mod tests { // Different calls produce different noise (with very high probability) assert_ne!(random_slot_noise(64), random_slot_noise(64)); } - - // --- FoF Layer 1: vouch-grant seal/open --- - - fn make_persona_x25519(seed_byte: u8) -> ([u8; 32], [u8; 32]) { - // Derive (x25519_priv, x25519_pub) from an ed25519 seed, mirroring - // the production path personas use. - let mut seed = [0u8; 32]; - seed[0] = seed_byte; - let priv_x = ed25519_seed_to_x25519_private(&seed); - let signing_key = SigningKey::from_bytes(&seed); - let pub_x = signing_key.verifying_key().to_montgomery().to_bytes(); - (priv_x, pub_x) - } - - #[test] - fn vouch_grant_roundtrip() { - let (alice_priv, _alice_pub) = make_persona_x25519(11); - let (bob_priv, bob_pub) = make_persona_x25519(22); - let bio_post_id: PostId = [7u8; 32]; - let v_me: [u8; 32] = [42u8; 32]; - - let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); - - // Seal for Bob - let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &bio_post_id, &v_me).unwrap(); - assert_eq!(wrapper.len(), 48, "wrapper must be 48 bytes (32 sealed + 16 tag)"); - - // Bob opens it - let opened = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &wrapper); - assert_eq!(opened, Some(v_me)); - - // Alice (not the recipient) cannot open it - let alice_attempt = open_vouch_grant(&alice_priv, &eph_pub, &bio_post_id, &wrapper); - assert_eq!(alice_attempt, None, "non-recipient must not decrypt"); - } - - #[test] - fn vouch_grant_wrong_bio_post_id_fails() { - let (_, bob_pub) = make_persona_x25519(22); - let (bob_priv, _) = make_persona_x25519(22); - let real_bio_id: PostId = [1u8; 32]; - let wrong_bio_id: PostId = [2u8; 32]; - let v_me: [u8; 32] = [99u8; 32]; - - let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); - let wrapper = seal_vouch_grant(&eph_priv, &bob_pub, &real_bio_id, &v_me).unwrap(); - - // Wrong bio_post_id derives a different key+nonce → AEAD fails. - let attempt = open_vouch_grant(&bob_priv, &eph_pub, &wrong_bio_id, &wrapper); - assert_eq!(attempt, None); - - // Right bio_post_id succeeds. - let ok = open_vouch_grant(&bob_priv, &eph_pub, &real_bio_id, &wrapper); - assert_eq!(ok, Some(v_me)); - } - - #[test] - fn vouch_grant_random_bytes_fail() { - let (bob_priv, _) = make_persona_x25519(22); - let bio_post_id: PostId = [5u8; 32]; - let (_, eph_pub) = generate_vouch_batch_ephemeral(); - - let mut junk = [0u8; 48]; - rand::rng().fill_bytes(&mut junk); - let attempt = open_vouch_grant(&bob_priv, &eph_pub, &bio_post_id, &junk); - assert_eq!(attempt, None, "random bytes must AEAD-fail (dummy wrapper indistinguishable)"); - } - - // --- FoF Layer 2: wrap-slot seal/open --- - - #[test] - fn wrap_slot_roundtrip() { - let v_x: [u8; 32] = [0x42; 32]; - let slot_binder_nonce: [u8; 32] = [0xAB; 32]; - let cek: [u8; 32] = [0x01; 32]; - let priv_x_seed: [u8; 32] = [0x02; 32]; - - let sealed = seal_wrap_slot(&v_x, &slot_binder_nonce, &cek, &priv_x_seed).unwrap(); - assert_eq!(sealed.read_ciphertext.len(), 48); - assert_eq!(sealed.sign_ciphertext.len(), 48); - - // Same V_x opens it. - let opened = open_wrap_slot( - &v_x, &slot_binder_nonce, - &sealed.read_ciphertext, &sealed.sign_ciphertext, - ).unwrap(); - assert_eq!(opened.cek, cek); - assert_eq!(opened.priv_x_seed, priv_x_seed); - - // Different V_x must not. - let wrong_v_x: [u8; 32] = [0x99; 32]; - let attempt = open_wrap_slot( - &wrong_v_x, &slot_binder_nonce, - &sealed.read_ciphertext, &sealed.sign_ciphertext, - ); - assert_eq!(attempt, None); - } - - #[test] - fn wrap_slot_wrong_binder_fails() { - let v_x: [u8; 32] = [0x42; 32]; - let real_nonce: [u8; 32] = [0xAB; 32]; - let wrong_nonce: [u8; 32] = [0xCD; 32]; - let cek: [u8; 32] = [0x01; 32]; - let priv_x_seed: [u8; 32] = [0x02; 32]; - - let sealed = seal_wrap_slot(&v_x, &real_nonce, &cek, &priv_x_seed).unwrap(); - // Same V_x but wrong slot_binder_nonce → AEAD-fail. - let attempt = open_wrap_slot( - &v_x, &wrong_nonce, - &sealed.read_ciphertext, &sealed.sign_ciphertext, - ); - assert_eq!(attempt, None); - } - - #[test] - fn wrap_slot_prefilter_tag_is_stable_and_keyed() { - let v_a: [u8; 32] = [0x11; 32]; - let v_b: [u8; 32] = [0x22; 32]; - let nonce_x: [u8; 32] = [0xAA; 32]; - let nonce_y: [u8; 32] = [0xBB; 32]; - - let t1 = wrap_slot_prefilter_tag(&v_a, &nonce_x); - let t2 = wrap_slot_prefilter_tag(&v_a, &nonce_x); - assert_eq!(t1, t2, "deterministic for same inputs"); - - // Different V_x or different nonce → different tag (overwhelmingly). - let t3 = wrap_slot_prefilter_tag(&v_b, &nonce_x); - assert_ne!(t1, t3); - let t4 = wrap_slot_prefilter_tag(&v_a, &nonce_y); - assert_ne!(t1, t4); - } - - #[test] - fn cek_comments_is_distinct_per_post() { - let cek: [u8; 32] = [0x01; 32]; - let nonce_a: [u8; 32] = [0xAA; 32]; - let nonce_b: [u8; 32] = [0xBB; 32]; - let a = derive_cek_comments(&cek, &nonce_a); - let b = derive_cek_comments(&cek, &nonce_b); - assert_ne!(a, b); - // Stable. - assert_eq!(derive_cek_comments(&cek, &nonce_a), a); - // Different from the base CEK. - assert_ne!(a, cek); - } } diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs deleted file mode 100644 index f43cd90..0000000 --- a/crates/core/src/fof.rs +++ /dev/null @@ -1,1965 +0,0 @@ -//! FoF Layer 2: post-side construction + verification for FoF-gated -//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the -//! wire shape and threat model. -//! -//! This module owns: -//! - **Author publish path** ([`build_fof_comment_gating`]): seal wrap -//! slots, generate per-V_x signing keypairs, bucket-pad, shuffle. -//! - **Reader/commenter unlock path** ([`find_unlock_for_post`], -//! [`build_fof_comment`]): trial-decrypt slots with any held V_x; -//! if a slot opens, derive priv_x + comments-CEK, encrypt the -//! comment body, sign with priv_x, attach pub_x_index. -//! -//! The CDN four-check verification path and revocation handling live -//! in sibling modules (added in subsequent slices). - -use anyhow::Result; -use ed25519_dalek::SigningKey; -use rand::seq::SliceRandom; -use rand::RngCore; - -use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; -use crate::storage::Storage; -use crate::types::{FoFCommentGating, NodeId, WrapSlot}; - -/// Build the `FoFCommentGating` block for a post about to be published -/// under `CommentPermission::FriendsOfFriends`. The author's keyring -/// (own current `V_me` + every distinct received `V_x`) drives the -/// real-slot set; dummies pad the count to the next bucket. -/// -/// Returns `None` if the author has no current `V_me` set — every -/// persona is supposed to have one (auto-generated on creation in -/// Layer 1), but this gracefully no-ops if not. -/// -/// Side effect: this function is pure; no storage writes. The caller -/// owns persisting the resulting Post. -pub fn build_fof_comment_gating( - storage: &Storage, - author_persona_id: &NodeId, -) -> Result> { - // Gather the author's keyring with provenance: (V_x, owner, epoch). - // The author's own V_me appears with owner=author_persona_id. - let Some((own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else { - return Ok(None); - }; - let received = storage.list_received_vouch_keys(author_persona_id)?; - - // Dedup at the V_x byte level — keep the first sighting (which is - // own_v_me for the author's slot, then received keys in storage - // order). Per Layer 3 spec: one slot per unique V_x. - let mut tagged_keys: Vec<([u8; 32], NodeId, u32)> = - Vec::with_capacity(1 + received.len()); - tagged_keys.push((own_v_me, *author_persona_id, own_epoch)); - for (owner, epoch, key) in &received { - if !tagged_keys.iter().any(|(existing, _, _)| existing == key) { - tagged_keys.push((*key, *owner, *epoch)); - } - } - - // Generate the per-post CEK + slot_binder_nonce. - let mut cek = [0u8; 32]; - rand::rng().fill_bytes(&mut cek); - let mut slot_binder_nonce = [0u8; 32]; - rand::rng().fill_bytes(&mut slot_binder_nonce); - - // Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2 - // resolved decision — per-post keypair generation). Then seal the - // slot. - // - // We carry a `kind` tag through the shuffle so we can recover - // (slot_index, owner, epoch, pub_x) afterward for provenance. - enum EntryKind { - Real { v_x_owner: NodeId, v_x_epoch: u32 }, - Dummy, - } - let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); - for (v_x, owner, epoch) in &tagged_keys { - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let signing_key = SigningKey::from_bytes(&seed); - let pub_x = *signing_key.verifying_key().as_bytes(); - - let sealed: SealedWrapSlot = seal_wrap_slot(v_x, &slot_binder_nonce, &cek, &seed)?; - let slot = WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }; - entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); - } - - // Pad to bucket with dummies. - let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); - let mut rng = rand::rng(); - while entries.len() < bucket { - let mut dummy_pub_x = [0u8; 32]; - rng.fill_bytes(&mut dummy_pub_x); - let mut dummy_prefilter = [0u8; 2]; - rng.fill_bytes(&mut dummy_prefilter); - let mut dummy_read = vec![0u8; 48]; - rng.fill_bytes(&mut dummy_read); - let mut dummy_sign = vec![0u8; 48]; - rng.fill_bytes(&mut dummy_sign); - entries.push(( - EntryKind::Dummy, - dummy_pub_x, - WrapSlot { - prefilter_tag: dummy_prefilter, - read_ciphertext: dummy_read, - sign_ciphertext: dummy_sign, - }, - )); - } - - // Shuffle so real and dummy positions are indistinguishable. - entries.shuffle(&mut rng); - - let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); - let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); - let mut real_slot_provenance: Vec = Vec::new(); - for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { - if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { - real_slot_provenance.push(RealSlotProvenance { - slot_index: idx as u32, - v_x_owner, - v_x_epoch, - pub_x, - }); - } - pub_post_set.push(pub_x); - wrap_slots.push(slot); - } - - let gating = FoFCommentGating { - slot_binder_nonce, - pub_post_set, - wrap_slots, - revocation_list: Vec::new(), - }; - - Ok(Some(FoFCommentGatingBuilt { - gating, - cek, - slot_binder_nonce, - real_slot_provenance, - })) -} - -/// Output of [`build_fof_comment_gating`]. The gating block goes into -/// `Post.fof_gating`; the side outputs are author-local state the -/// caller persists (CEK cache, `own_post_slot_provenance` for cascade -/// revocations). -#[derive(Debug, Clone)] -pub struct FoFCommentGatingBuilt { - pub gating: FoFCommentGating, - /// The post's body/comments encryption key. Authors keep this - /// locally keyed by `post_id` so they can read/decrypt without - /// needing to unwrap a slot themselves. - pub cek: [u8; 32], - /// Same nonce as `gating.slot_binder_nonce`; mirrored here for - /// callers who want it without reaching into the gating struct. - pub slot_binder_nonce: [u8; 32], - /// FoF Layer 4 provenance: which V_x sealed which slot. Real - /// slots only (dummies are excluded). Caller persists into - /// `own_post_slot_provenance` for later cascade revocations. - /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). - pub real_slot_provenance: Vec, -} - -/// One entry per real (non-dummy) slot in a published FoF post. -#[derive(Debug, Clone)] -pub struct RealSlotProvenance { - pub slot_index: u32, - /// Persona who issued the V_x this slot was sealed under. For the - /// author's own slot this is the author themselves. - pub v_x_owner: NodeId, - /// V_x epoch — important for cascade revocation when an old - /// epoch is retired. - pub v_x_epoch: u32, - pub pub_x: [u8; 32], -} - -// --- Reader / commenter side --- - -/// FoF Layer 2: a persona's successful unlock of a gated post. -/// Carries everything the persona needs to read encrypted comments AND -/// author new ones. -#[derive(Debug, Clone)] -pub struct PostUnlock { - /// Which persona unlocked the post (the V_x that matched belongs to - /// this persona's keyring — owned `V_me` or received). - pub persona_id: NodeId, - /// Index into `pub_post_set` / `wrap_slots` of the slot that - /// unlocked. Comments author by this persona will set - /// `InlineComment.pub_x_index` to this. - pub slot_index: u32, - /// Per-post shared CEK (unwrapped from the slot's read part). - pub cek: [u8; 32], - /// Ed25519 signing seed for the per-V_x keypair admitted to this - /// post (unwrapped from the slot's sign part). Used to sign - /// `group_sig` on FoF comments. - pub priv_x_seed: [u8; 32], -} - -/// Trial-decrypt the post's `fof_gating.wrap_slots` against every -/// persona on this device. Returns the first successful unlock found, -/// or `None` if no held V_x matches. -/// -/// FoF Layer 5: consults `vouch_unlock_cache` first to skip directly -/// to the V_x that worked last time for this `(persona, author)` pair. -/// Falls back to a full scan on miss. On success, updates the cache; -/// on failure, queues the post in `vouch_unreadable_posts` for later -/// retry when a new V_x arrives. -pub fn find_unlock_for_post( - storage: &Storage, - post: &crate::types::Post, -) -> Result> { - let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); }; - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let post_id_for_cache = crate::content::compute_post_id(post); - let personas = storage.list_posting_identities()?; - - // Fast path: try the cached winning V_x per (persona, author). - for persona in &personas { - let Some((owner, epoch)) = storage.lookup_unlock_cache(&persona.node_id, &post.author)? else { - continue; - }; - let v_x = if owner == persona.node_id { - storage.list_own_vouch_keys(&persona.node_id)? - .into_iter() - .find(|(e, _)| *e == epoch) - .map(|(_, k)| k) - } else { - storage.list_received_vouch_keys(&persona.node_id)? - .into_iter() - .find(|(o, e, _)| *o == owner && *e == epoch) - .map(|(_, _, k)| k) - }; - if let Some(v_x) = v_x { - if let Some(unlock) = try_unlock_with_v_x(gating, &v_x, &persona.node_id) { - storage.record_unlock_hit( - &persona.node_id, &post.author, &owner, epoch, now_ms, - )?; - let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache); - return Ok(Some(unlock)); - } - // Cache stale (e.g., post was key-burned). Fall through to - // full scan; next success overwrites the cache. - } - } - - // Full scan path. - for persona in &personas { - let mut keyring: Vec<([u8; 32], NodeId, u32)> = Vec::new(); - if let Some((epoch, own_key)) = storage.current_own_vouch_key(&persona.node_id)? { - keyring.push((own_key, persona.node_id, epoch)); - } - for (owner, epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? { - keyring.push((key, owner, epoch)); - } - for (v_x, owner, epoch) in &keyring { - if let Some(unlock) = try_unlock_with_v_x(gating, v_x, &persona.node_id) { - storage.record_unlock_hit( - &persona.node_id, &post.author, owner, *epoch, now_ms, - )?; - let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache); - return Ok(Some(unlock)); - } - } - // No held V_x unlocks this post for this persona — queue for - // retry when a new V_x arrives in the keyring. - let _ = storage.record_unreadable_post( - &persona.node_id, &post_id_for_cache, &post.author, now_ms, - ); - } - Ok(None) -} - -/// Maximum posts processed per `sweep_unreadable_on_new_v_x` call. -/// Bounds lock-hold time. Unprocessed entries remain queued and will -/// be tried on the next V_x arrival or via a periodic background -/// sweep (future Layer 5+ work). -const MAX_SWEEP_PER_CALL: usize = 256; - -/// FoF Layer 5: when a persona acquires a new V_x, walk the -/// unreadable-posts queue and re-attempt unlock. The new V_x can -/// unlock posts authored by anyone (the author may hold the V_x's -/// owner as one of their vouches). Successful unlocks populate -/// `vouch_unlock_cache` + clear the queue entry as a side effect. -/// -/// Bounded to `MAX_SWEEP_PER_CALL` posts per invocation to cap -/// lock-hold time and prevent spam-grant-triggered DoS. Remaining -/// entries are processed on subsequent V_x arrivals. -pub fn sweep_unreadable_on_new_v_x( - storage: &Storage, - holder_persona_id: &NodeId, - _v_x_owner: &NodeId, -) -> Result { - let post_ids = storage.list_all_unreadable_posts(holder_persona_id)?; - let mut unlocked = 0usize; - for post_id in post_ids.into_iter().take(MAX_SWEEP_PER_CALL) { - let Some((post, _vis)) = storage.get_post_with_visibility(&post_id)? else { - let _ = storage.clear_unreadable_post(holder_persona_id, &post_id); - continue; - }; - // find_unlock_for_post records the cache hit + clears the - // unreadable entry as a side effect on success. - if find_unlock_for_post(storage, &post)?.is_some() { - unlocked += 1; - } - } - Ok(unlocked) -} - -/// Inner helper: prefilter + AEAD-open against a single V_x. -fn try_unlock_with_v_x( - gating: &crate::types::FoFCommentGating, - v_x: &[u8; 32], - persona_id: &NodeId, -) -> Option { - let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce); - for (idx, slot) in gating.wrap_slots.iter().enumerate() { - if slot.prefilter_tag != prefilter { - continue; - } - if let Some(opened) = crate::crypto::open_wrap_slot( - v_x, - &gating.slot_binder_nonce, - &slot.read_ciphertext, - &slot.sign_ciphertext, - ) { - return Some(PostUnlock { - persona_id: *persona_id, - slot_index: idx as u32, - cek: opened.cek, - priv_x_seed: opened.priv_x_seed, - }); - } - } - None -} - -/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped -/// inside [`crate::types::InlineComment::encrypted_payload`]. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct FoFCommentPayload { - /// User-visible comment body. - pub body: String, - /// Optional reply parent. - #[serde(default)] - pub parent_comment_id: Option<[u8; 32]>, - /// HMAC(V_x, post_id || comment_hash)[:16B] — author-side - /// attribution to a specific voucher-chain. Computed by the - /// commenter; not yet enforced in this commit. - #[serde(default)] - pub vouch_mac: Option<[u8; 16]>, -} - -/// Build a Mode 2 / Mode 1 FoF comment on a parent post. The post must -/// have `fof_gating = Some`; the caller must already hold a successful -/// `PostUnlock` for it (typically from [`find_unlock_for_post`]). -/// -/// Produces an `InlineComment` with empty `content` (the body lives -/// encrypted in `encrypted_payload`) + `pub_x_index` + `group_sig` + -/// `encrypted_payload`. The conventional `signature` field carries the -/// commenter's identity-key signature over the existing payload (so -/// non-FoF nodes can still verify identity without unwrapping the slot). -pub fn build_fof_comment( - parent_post_id: &[u8; 32], - unlock: &PostUnlock, - slot_binder_nonce: &[u8; 32], - commenter_id: &NodeId, - commenter_secret: &[u8; 32], - body: &str, - parent_comment_id: Option<[u8; 32]>, - now_ms: u64, -) -> Result { - use ed25519_dalek::{Signer, SigningKey}; - - // Encrypt the comment body under CEK_comments derived from the - // post's CEK + slot_binder_nonce. - let cek_comments = crate::crypto::derive_cek_comments(&unlock.cek, slot_binder_nonce); - let payload = FoFCommentPayload { - body: body.to_string(), - parent_comment_id, - vouch_mac: None, - }; - let plaintext = serde_json::to_vec(&payload) - .map_err(|e| anyhow::anyhow!("serialize fof comment payload: {}", e))?; - let encrypted = crate::crypto::encrypt_bytes_with_cek(&plaintext, &cek_comments)?; - - // Sign (encrypted || post_id || pub_x_index_le) with priv_x. - let priv_x_signer = SigningKey::from_bytes(&unlock.priv_x_seed); - let mut to_sign = Vec::with_capacity(encrypted.len() + 32 + 4); - to_sign.extend_from_slice(&encrypted); - to_sign.extend_from_slice(parent_post_id); - to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); - let group_sig = priv_x_signer.sign(&to_sign).to_bytes().to_vec(); - - // Conventional InlineComment.signature: commenter's identity-key - // signature over the existing comment_signature tuple. We use empty - // content here (the body lives in encrypted_payload). Existing - // non-FoF receivers can still verify the identity sig and tombstone - // logic continues to work. - let identity_signature = crate::crypto::sign_comment( - commenter_secret, - commenter_id, - parent_post_id, - "", - now_ms, - None, - ); - - Ok(crate::types::InlineComment { - author: *commenter_id, - post_id: *parent_post_id, - content: String::new(), - timestamp_ms: now_ms, - signature: identity_signature, - deleted_at: None, - ref_post_id: None, - pub_x_index: Some(unlock.slot_index), - group_sig: Some(group_sig), - encrypted_payload: Some(encrypted), - }) -} - -/// Verify the `group_sig` on an incoming FoF comment against the post's -/// `pub_post_set`. Used by the CDN four-check accept rule (next slice). -/// Returns `true` iff the comment carries a valid Ed25519 signature -/// under `pub_post_set[pub_x_index]` over -/// (encrypted_payload || post_id || pub_x_index_le). -pub fn verify_fof_group_sig( - comment: &crate::types::InlineComment, - gating: &FoFCommentGating, -) -> bool { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - let Some(pub_x_index) = comment.pub_x_index else { return false; }; - let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; - let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; - let idx = pub_x_index as usize; - if idx >= gating.pub_post_set.len() { return false; } - if group_sig.len() != 64 { return false; } - let pub_x = &gating.pub_post_set[idx]; - let Ok(verifying_key) = VerifyingKey::from_bytes(pub_x) else { return false; }; - let sig_bytes: [u8; 64] = match group_sig.as_slice().try_into() { - Ok(b) => b, Err(_) => return false, - }; - let sig = Signature::from_bytes(&sig_bytes); - let mut to_verify = Vec::with_capacity(encrypted_payload.len() + 32 + 4); - to_verify.extend_from_slice(encrypted_payload); - to_verify.extend_from_slice(&comment.post_id); - to_verify.extend_from_slice(&pub_x_index.to_le_bytes()); - verifying_key.verify(&to_verify, &sig).is_ok() -} - -/// Decrypt the `encrypted_payload` of an FoF comment back to its -/// plaintext body / vouch_mac / parent_comment_id, using the CEK -/// recovered via [`find_unlock_for_post`]. -pub fn decrypt_fof_comment_payload( - comment: &crate::types::InlineComment, - cek: &[u8; 32], - slot_binder_nonce: &[u8; 32], -) -> Result { - let encrypted = comment.encrypted_payload.as_ref() - .ok_or_else(|| anyhow::anyhow!("comment has no encrypted_payload"))?; - let cek_comments = crate::crypto::derive_cek_comments(cek, slot_binder_nonce); - let plaintext = crate::crypto::decrypt_bytes_with_cek(encrypted, &cek_comments)?; - serde_json::from_slice(&plaintext) - .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) -} - -// --- Wire-shape validation for incoming posts (DoS hardening) --- -// -// Called from control::receive_post before any storage write. Rejects -// malformed FoF gating blocks so they never enter storage and get -// re-propagated via neighbor-manifest diffs. - -/// Maximum allowed wrap_slots / pub_post_set entries on an incoming -/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256; -/// at a 4096-vouchee max realistic graph that's ~4224. Round up for -/// headroom; anything larger is presumed attacker-shaped. -const MAX_FOF_WRAP_SLOTS: usize = 8192; - -/// Maximum allowed revocation_list entries in a t=0 published gating -/// block. Initial publish should have an EMPTY list; receivers -/// accumulate revocations via diffs into the live fof_revocations -/// table. A non-empty list on first publish is suspicious but not -/// strictly invalid — bound it generously. -const MAX_FOF_REVOCATION_LIST: usize = 4096; - -/// Validate a FoF gating block on receive. Rejects: -/// - wrap_slots / pub_post_set length mismatch (indexing invariant) -/// - bucket-size violation (DoS bound) -/// - wrong WrapSlot ciphertext sizes (always 48 bytes today) -/// - oversized revocation_list (DoS bound) -/// -/// Sound caller pattern (control::receive_post): -/// - Visibility == FoFClosed implies post.fof_gating MUST be Some. -/// - Any post with fof_gating Some passes this check. -pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> { - // Invariant: FoFClosed visibility implies Some gating. - // (No way to recover the body without it.) - // The visibility is checked by the caller; this validates the - // gating shape when it's present. - let Some(gating) = post.fof_gating.as_ref() else { - return Ok(()); - }; - - // 1:1 invariant: every wrap_slot has a matching pub_post_set entry - // so pub_x_index lookups always succeed within bounds. - if gating.wrap_slots.len() != gating.pub_post_set.len() { - anyhow::bail!( - "FoF wrap_slots/pub_post_set length mismatch: {} vs {}", - gating.wrap_slots.len(), - gating.pub_post_set.len(), - ); - } - - // Bucket-size cap — bounds memory + scan cost. - if gating.wrap_slots.len() > MAX_FOF_WRAP_SLOTS { - anyhow::bail!( - "FoF wrap_slots oversized: {} > {} (DoS cap)", - gating.wrap_slots.len(), - MAX_FOF_WRAP_SLOTS, - ); - } - - // Per-slot field-size invariants. seal_wrap_slot always produces - // 48-byte ciphertexts (32B sealed plaintext + 16B AEAD tag); any - // other size is malformed and shouldn't tie up AEAD attempts. - for (i, slot) in gating.wrap_slots.iter().enumerate() { - if slot.read_ciphertext.len() != 48 || slot.sign_ciphertext.len() != 48 { - anyhow::bail!( - "FoF wrap_slot {} has wrong ciphertext sizes: read={} sign={} (must both be 48)", - i, - slot.read_ciphertext.len(), - slot.sign_ciphertext.len(), - ); - } - } - - // Bound revocation_list size — revocations should arrive as diffs, - // not be stuffed into the t=0 publish. Cap generously. - if gating.revocation_list.len() > MAX_FOF_REVOCATION_LIST { - anyhow::bail!( - "FoF revocation_list oversized: {} > {} (DoS cap)", - gating.revocation_list.len(), - MAX_FOF_REVOCATION_LIST, - ); - } - - Ok(()) -} - -/// Companion check: enforce the visibility-implies-gating invariant. -/// Called from `control::receive_post` after the gating-shape check. -pub fn validate_fof_closed_has_gating( - post: &crate::types::Post, - visibility: &crate::types::PostVisibility, -) -> Result<()> { - if matches!(visibility, crate::types::PostVisibility::FoFClosed) - && post.fof_gating.is_none() - { - anyhow::bail!("FoFClosed visibility requires a fof_gating block"); - } - Ok(()) -} - -// --- Mode 1 (FoFClosed) body encryption --- -// -// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is -// that the post body is itself encrypted under that CEK before -// publish; readers who can unlock a slot (recovering CEK) decrypt the -// body too. Non-FoF readers see only the ciphertext; they can still -// store + propagate the post as a CDN host without decrypting. - -/// FoF Layer 3: encrypt a post body under the gating CEK, padded to a -/// bucket per the spec. Output is `body_bucket_padding_nonce(12) || -/// real_len_u32_le || ciphertext_with_tag(body+pad+16)` so the reader -/// can recover the original byte length after decryption. -pub fn encrypt_fof_body( - body: &str, - cek: &[u8; 32], - slot_binder_nonce: &[u8; 32], -) -> Result> { - use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; - use rand::RngCore; - - let real = body.as_bytes(); - // Pre-tag size we want to encrypt = 4-byte length prefix + body + padding. - let prefixed_len = 4 + real.len(); - let bucket = next_body_size_bucket(prefixed_len); - let mut plaintext = Vec::with_capacity(bucket); - plaintext.extend_from_slice(&(real.len() as u32).to_le_bytes()); - plaintext.extend_from_slice(real); - // Pad with random bytes (NOT zeros — zeros would be a small - // distinguisher under known-plaintext but doesn't matter under - // AEAD; random is defense-in-depth.) - let mut pad = vec![0u8; bucket.saturating_sub(plaintext.len())]; - rand::rng().fill_bytes(&mut pad); - plaintext.extend_from_slice(&pad); - - // Derive a per-body nonce-context key so we don't reuse the slot - // AEAD nonces. blake3-derive over (cek, slot_binder_nonce) gives - // us a stable seed; we then prepend a fresh random 12B nonce. - let cipher = ChaCha20Poly1305::new_from_slice(cek) - .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; - let mut nonce_bytes = [0u8; 12]; - rand::rng().fill_bytes(&mut nonce_bytes); - // AAD = slot_binder_nonce. Binds body decrypt to the post's gating - // (an attacker who steals the body ciphertext + CEK can't decrypt - // against a different post's wrap slots). - let ciphertext = cipher - .encrypt( - Nonce::from_slice(&nonce_bytes), - chacha20poly1305::aead::Payload { - msg: &plaintext, - aad: slot_binder_nonce, - }, - ) - .map_err(|e| anyhow::anyhow!("body encrypt: {}", e))?; - - let mut out = Vec::with_capacity(12 + ciphertext.len()); - out.extend_from_slice(&nonce_bytes); - out.extend_from_slice(&ciphertext); - Ok(out) -} - -/// FoF Layer 3: decrypt a body sealed by [`encrypt_fof_body`]. Returns -/// the original `String` body (padding stripped via the 4-byte length -/// prefix). -pub fn decrypt_fof_body( - encrypted: &[u8], - cek: &[u8; 32], - slot_binder_nonce: &[u8; 32], -) -> Result { - use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; - - if encrypted.len() < 12 + 16 { - anyhow::bail!("encrypted body too short"); - } - let nonce = Nonce::from_slice(&encrypted[..12]); - let cipher = ChaCha20Poly1305::new_from_slice(cek) - .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; - let plaintext = cipher - .decrypt( - nonce, - chacha20poly1305::aead::Payload { - msg: &encrypted[12..], - aad: slot_binder_nonce, - }, - ) - .map_err(|e| anyhow::anyhow!("body decrypt: {}", e))?; - if plaintext.len() < 4 { - anyhow::bail!("body plaintext missing length prefix"); - } - let real_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize; - if 4 + real_len > plaintext.len() { - anyhow::bail!("body length prefix overruns plaintext"); - } - let body_bytes = &plaintext[4..4 + real_len]; - String::from_utf8(body_bytes.to_vec()) - .map_err(|e| anyhow::anyhow!("body not valid UTF-8: {}", e)) -} - -/// FoF Layer 3 body-size bucket rule. -/// Power-of-2 from a minimum of 1 KiB up to 256 KiB, then linear -/// +256 KiB steps above. Hides body length within meaningful brackets. -pub(crate) fn next_body_size_bucket(real: usize) -> usize { - const MIN_BUCKET: usize = 1024; - const POW2_CEIL: usize = 256 * 1024; - const LINEAR_STEP: usize = 256 * 1024; - if real <= MIN_BUCKET { return MIN_BUCKET; } - if real <= POW2_CEIL { - let mut b = MIN_BUCKET; - while b < real { b *= 2; } - return b; - } - // 256K, 512K, 768K, ... - let above = real - POW2_CEIL; - let steps = (above + LINEAR_STEP - 1) / LINEAR_STEP; - POW2_CEIL + steps * LINEAR_STEP -} - -// --- Revocation: sign + verify + apply --- - -/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`. -/// Constructed identically on both ends so the verify is deterministic. -fn fof_revocation_signing_bytes( - post_id: &[u8; 32], - revoked_pub_x: &[u8; 32], - revoked_at_ms: u64, - reason_code: u8, -) -> [u8; 32 + 32 + 8 + 1] { - let mut out = [0u8; 32 + 32 + 8 + 1]; - out[..32].copy_from_slice(post_id); - out[32..64].copy_from_slice(revoked_pub_x); - out[64..72].copy_from_slice(&revoked_at_ms.to_le_bytes()); - out[72] = reason_code; - out -} - -/// Author-side: sign a revocation entry with the post author's -/// identity secret. Returns the 64-byte Ed25519 signature. -pub fn sign_fof_revocation( - author_secret: &[u8; 32], - post_id: &[u8; 32], - revoked_pub_x: &[u8; 32], - revoked_at_ms: u64, - reason_code: u8, -) -> Vec { - use ed25519_dalek::{Signer, SigningKey}; - let signing_key = SigningKey::from_bytes(author_secret); - let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); - signing_key.sign(&bytes).to_bytes().to_vec() -} - -/// CDN-side: verify a revocation entry's author_sig against the post -/// author's public key. Returns `false` on any shape/key/signature -/// failure. -pub fn verify_fof_revocation( - post_author: &NodeId, - post_id: &[u8; 32], - revoked_pub_x: &[u8; 32], - revoked_at_ms: u64, - reason_code: u8, - author_sig: &[u8], -) -> bool { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - if author_sig.len() != 64 { return false; } - let sig_bytes: [u8; 64] = match author_sig.try_into() { - Ok(b) => b, Err(_) => return false, - }; - let sig = Signature::from_bytes(&sig_bytes); - let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; - let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); - verifying_key.verify(&bytes, &sig).is_ok() -} - -// --- Access-grant: sign + verify + apply --- - -/// Bytes covered by a `BlobHeaderDiffOp::FoFAccessGrant.author_sig`. -/// Wrap-slot is canonicalized by serializing its three fields in order: -/// prefilter_tag || read_ciphertext || sign_ciphertext. -fn fof_access_grant_signing_bytes( - post_id: &[u8; 32], - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - granted_at_ms: u64, -) -> Vec { - let mut out = Vec::with_capacity(32 + 32 + 2 + 48 + 48 + 8); - out.extend_from_slice(post_id); - out.extend_from_slice(new_pub_x); - out.extend_from_slice(&new_wrap_slot.prefilter_tag); - out.extend_from_slice(&new_wrap_slot.read_ciphertext); - out.extend_from_slice(&new_wrap_slot.sign_ciphertext); - out.extend_from_slice(&granted_at_ms.to_le_bytes()); - out -} - -/// Author-side: sign an access-grant entry. -pub fn sign_fof_access_grant( - author_secret: &[u8; 32], - post_id: &[u8; 32], - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - granted_at_ms: u64, -) -> Vec { - use ed25519_dalek::{Signer, SigningKey}; - let signing_key = SigningKey::from_bytes(author_secret); - let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); - signing_key.sign(&bytes).to_bytes().to_vec() -} - -/// CDN-side: verify an access-grant entry's author_sig. -pub fn verify_fof_access_grant( - post_author: &NodeId, - post_id: &[u8; 32], - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - granted_at_ms: u64, - author_sig: &[u8], -) -> bool { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - if author_sig.len() != 64 { return false; } - let sig_bytes: [u8; 64] = match author_sig.try_into() { - Ok(b) => b, Err(_) => return false, - }; - let sig = Signature::from_bytes(&sig_bytes); - let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; - let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); - verifying_key.verify(&bytes, &sig).is_ok() -} - -// --- Key burn: sign + verify + apply (Layer 4) --- - -fn fof_key_burn_signing_bytes( - post_id: &[u8; 32], - slot_index: u32, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - burned_at_ms: u64, -) -> Vec { - let mut out = Vec::with_capacity(32 + 4 + 32 + 2 + 48 + 48 + 8); - out.extend_from_slice(post_id); - out.extend_from_slice(&slot_index.to_le_bytes()); - out.extend_from_slice(new_pub_x); - out.extend_from_slice(&new_wrap_slot.prefilter_tag); - out.extend_from_slice(&new_wrap_slot.read_ciphertext); - out.extend_from_slice(&new_wrap_slot.sign_ciphertext); - out.extend_from_slice(&burned_at_ms.to_le_bytes()); - out -} - -pub fn sign_fof_key_burn( - author_secret: &[u8; 32], - post_id: &[u8; 32], - slot_index: u32, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - burned_at_ms: u64, -) -> Vec { - use ed25519_dalek::{Signer, SigningKey}; - let signing_key = SigningKey::from_bytes(author_secret); - let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); - signing_key.sign(&bytes).to_bytes().to_vec() -} - -pub fn verify_fof_key_burn( - post_author: &NodeId, - post_id: &[u8; 32], - slot_index: u32, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - burned_at_ms: u64, - author_sig: &[u8], -) -> bool { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - if author_sig.len() != 64 { return false; } - let sig_bytes: [u8; 64] = match author_sig.try_into() { - Ok(b) => b, Err(_) => return false, - }; - let sig = Signature::from_bytes(&sig_bytes); - let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; - let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); - verifying_key.verify(&bytes, &sig).is_ok() -} - -/// Apply a verified key-burn to local storage. Replaces the wrap_slot -/// + pub_x at the indicated slot in the stored post's fof_gating. The -/// old key's holders can no longer decrypt this post via this slot -/// (locally-cached plaintext on already-read devices is out of scope). -/// -/// `burned_at_ms` enforces monotonic ordering: older signed key-burn -/// diffs cannot revert newer state. -pub fn apply_fof_key_burn_locally( - storage: &Storage, - post_id: &[u8; 32], - slot_index: u32, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - burned_at_ms: u64, -) -> Result { - storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms) -} - -/// Apply a verified access-grant to local storage. Appends the new -/// (pub_x, wrap_slot) at the tail of the stored post's fof_gating. -/// Idempotent on `(post_id, new_pub_x)`. Must only be called after -/// `verify_fof_access_grant` returns true. -/// -/// Refuses to apply if `new_pub_x` is already in the post's -/// revocation_list (prevents accidental re-admission of a previously- -/// revoked signer; spec-resolved). -pub fn apply_fof_access_grant_locally( - storage: &Storage, - post_id: &[u8; 32], - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, -) -> Result { - if storage.is_fof_pub_x_revoked(post_id, new_pub_x)? { - return Ok(false); - } - let appended = storage.append_fof_access_grant(post_id, new_pub_x, new_wrap_slot)?; - Ok(appended) -} - -/// Apply a verified revocation to local storage + cascade delete -/// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`. -/// Returns the count of comments deleted. -/// -/// Must only be called after `verify_fof_revocation` returns true. -/// The caller (CDN receive path) is responsible for that gate. -pub fn apply_fof_revocation_locally( - storage: &Storage, - post_id: &[u8; 32], - revoked_pub_x: &[u8; 32], - revoked_at_ms: u64, - reason_code: u8, - author_sig: &[u8], -) -> Result { - storage.add_fof_revocation(post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig)?; - - // Resolve pub_x -> pub_x_index via the post's pub_post_set, then - // cascade-delete locally-stored comments matching that index. - let Some(post) = storage.get_post(post_id)? else { return Ok(0); }; - let Some(gating) = post.fof_gating.as_ref() else { return Ok(0); }; - let mut deleted = 0; - for (idx, pub_x) in gating.pub_post_set.iter().enumerate() { - if pub_x == revoked_pub_x { - deleted += storage.delete_fof_comments_by_pub_x_index(post_id, idx as u32)?; - } - } - Ok(deleted) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::storage::Storage; - use crate::crypto::{ed25519_seed_to_x25519_private, open_wrap_slot}; - use ed25519_dalek::SigningKey; - use rand::RngCore; - - fn temp_storage() -> Storage { - Storage::open(":memory:").unwrap() - } - - fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { - let mut seed = [0u8; 32]; - seed[0] = seed_byte; - let signing_key = SigningKey::from_bytes(&seed); - (*signing_key.verifying_key().as_bytes(), seed) - } - - /// Author has a V_me + one received V_x → build_fof_comment_gating - /// produces a real-slot-count of 2 (own + received), padded to - /// bucket 8. - #[test] - fn build_gating_realcount_and_padding() { - let s = temp_storage(); - let (alice_id, _alice_seed) = make_persona(1); - let (bob_id, _bob_seed) = make_persona(2); - - // Alice has her own V_me - 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 a V_x from Bob - 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("gating built"); - // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). - assert_eq!(built.gating.pub_post_set.len(), 8); - assert_eq!(built.gating.wrap_slots.len(), 8); - assert_eq!(built.gating.revocation_list.len(), 0); - - // Every slot is exactly 48+48 bytes; prefilter is 2 bytes. - for slot in &built.gating.wrap_slots { - assert_eq!(slot.read_ciphertext.len(), 48); - assert_eq!(slot.sign_ciphertext.len(), 48); - } - - // Alice can find HER OWN slot by trial-unwrap with her V_me. - let mut own_hit = None; - for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { - if let Some(opened) = open_wrap_slot( - &v_me_alice, &built.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext, - ) { - assert_eq!(opened.cek, built.cek); - own_hit = Some((idx, opened)); - break; - } - } - let (own_idx, opened) = own_hit.expect("author's own V_me must unlock one slot"); - - // The pub_x in pub_post_set at the same index must match the - // ed25519 pubkey derived from the unwrapped priv_x seed. - let signing_key = SigningKey::from_bytes(&opened.priv_x_seed); - let derived_pub_x = signing_key.verifying_key().to_bytes(); - assert_eq!(built.gating.pub_post_set[own_idx], derived_pub_x); - - // A holder of V_x_bob can also unlock exactly one slot (Bob's - // chain to Alice's post). - let mut bob_hit = None; - for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { - if let Some(opened) = open_wrap_slot( - &v_x_bob, &built.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext, - ) { - bob_hit = Some((idx, opened)); - break; - } - } - let (bob_idx, bob_opened) = bob_hit.expect("Bob's V_x must unlock one slot"); - assert_ne!(bob_idx, own_idx, "different V_x's must hit different slots"); - assert_eq!(bob_opened.cek, built.cek, "same CEK across all real slots"); - } - - #[test] - fn build_gating_returns_none_without_vme() { - let s = temp_storage(); - let (alice_id, _) = make_persona(5); - // No V_me inserted. - let built = build_fof_comment_gating(&s, &alice_id).unwrap(); - assert!(built.is_none(), "no V_me → no gating block"); - } - - #[test] - fn build_gating_deduplicates_repeated_v_x() { - let s = temp_storage(); - let (alice_id, _) = make_persona(7); - let (bob_id, _) = make_persona(8); - let (carol_id, _) = make_persona(9); - - 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(); - - // Two different vouchers happened to issue the SAME key bytes - // (contrived but tests dedup). Real probability is 2^-256; - // here we force it for the test. - let same_key = [0xCC; 32]; - s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); - s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); - - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); - // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. - // We can't assert real count directly without exposing internals, - // but we can confirm exactly two distinct successful unwraps: - let mut alice_hits = 0; - let mut same_key_hits = 0; - for slot in &built.gating.wrap_slots { - if open_wrap_slot(&v_me_alice, &built.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { - alice_hits += 1; - } - if open_wrap_slot(&same_key, &built.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { - same_key_hits += 1; - } - } - assert_eq!(alice_hits, 1, "exactly one slot for V_me_alice"); - assert_eq!(same_key_hits, 1, "exactly one slot for the duplicated key (dedup'd)"); - } - - // Silences the unused-import warning when the X25519 derivation is - // only exercised in future slices. - #[test] - fn x25519_derivation_helper_compiles() { - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let _ = ed25519_seed_to_x25519_private(&seed); - } - - /// End-to-end FoF comment roundtrip: - /// 1. Alice authors a Mode 2 FoF post (her keyring: own V_me + Bob's V_x). - /// 2. A "receiver device" (Bob's) holds Bob's V_x. It finds the unlock. - /// 3. Bob authors a comment on Alice's post. - /// 4. The comment's group_sig verifies against the post's pub_post_set. - /// 5. Alice (using her cached cek) decrypts Bob's comment plaintext. - #[test] - fn fof_comment_authoring_roundtrip() { - use crate::types::PostingIdentity; - use ed25519_dalek::SigningKey; - - // Alice's device: has her V_me and Bob's V_x (received from Bob). - let alice_storage = temp_storage(); - let (alice_id, alice_seed) = make_persona(1); - alice_storage.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); - alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); - - let (bob_id, _) = make_persona(2); - let mut v_x_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_bob); - alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - - // Alice publishes the gating block. - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); - let parent_post_id = [0xCC; 32]; - - // Bob's device: has his V_me (which is v_x_bob since he handed it out). - let bob_storage = temp_storage(); - let (bob_id_, bob_seed) = make_persona(2); - assert_eq!(bob_id, bob_id_); - 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(); - - // Wrap Alice's published gating into a Post struct as it would - // appear on the wire. - let post = crate::types::Post { - author: alice_id, - content: "alice's public post".into(), - attachments: vec![], - timestamp_ms: 3000, - fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - - // Bob's device unlocks the post via his V_me (= v_x_bob). - let unlock = find_unlock_for_post(&bob_storage, &post).unwrap() - .expect("Bob's persona must unlock the post"); - assert_eq!(unlock.persona_id, bob_id); - assert_eq!(unlock.cek, built.cek, "Bob recovers Alice's CEK"); - - // Bob authors a comment. - let comment = build_fof_comment( - &parent_post_id, - &unlock, - &built.slot_binder_nonce, - &bob_id, - &bob_seed, - "great post alice", - None, - 4000, - ).unwrap(); - assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); - assert!(comment.pub_x_index.is_some()); - assert!(comment.group_sig.is_some()); - assert!(comment.encrypted_payload.is_some()); - - // CDN-level verification: group_sig validates against pub_post_set. - assert!(verify_fof_group_sig(&comment, &built.gating), - "valid FoF comment must pass CDN four-check (group_sig leg)"); - - // Tamper detection: flip a byte in encrypted_payload and re-verify. - let mut tampered = comment.clone(); - let mut payload = tampered.encrypted_payload.clone().unwrap(); - payload[0] ^= 0x01; - tampered.encrypted_payload = Some(payload); - assert!(!verify_fof_group_sig(&tampered, &built.gating), - "tampered encrypted_payload must invalidate group_sig"); - - // Tamper detection: claim a different pub_x_index. - let mut wrong_idx = comment.clone(); - let bad_idx = (comment.pub_x_index.unwrap() + 1) % (built.gating.pub_post_set.len() as u32); - wrong_idx.pub_x_index = Some(bad_idx); - assert!(!verify_fof_group_sig(&wrong_idx, &built.gating), - "wrong pub_x_index must invalidate group_sig"); - - // Alice (using her cached CEK) decrypts Bob's comment payload. - let plaintext = decrypt_fof_comment_payload(&comment, &built.cek, &built.slot_binder_nonce) - .expect("Alice decrypts FoF comment"); - assert_eq!(plaintext.body, "great post alice"); - - let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import - } - - /// Revocation roundtrip: author publishes a Mode 2 FoF post, Bob - /// comments, author signs a revocation, apply_fof_revocation_locally - /// records it + cascade-deletes Bob's comment. - #[test] - fn fof_revocation_cascades() { - use crate::types::PostingIdentity; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(33); - 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(); - - let (bob_id, bob_seed) = make_persona(44); - 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 post_id = [0xDE; 32]; - - // Persist the post so apply_fof_revocation_locally can resolve - // pub_x → pub_x_index via the post's pub_post_set. - let post = crate::types::Post { - author: alice_id, content: "alice".into(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - s.store_post_with_intent( - &post_id, &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ).unwrap(); - - // Bob unlocks via his V_x and authors a comment. Persist it - // through the public store_comment path so the cascade-delete - // has something to clean up. - let bob_unlock = PostUnlock { - persona_id: bob_id, - slot_index: built.gating.pub_post_set.iter().position(|p| { - // Find a slot Bob's V_x unlocks. - let opened = crate::crypto::open_wrap_slot( - &v_x_bob, &built.slot_binder_nonce, - &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].read_ciphertext, - &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].sign_ciphertext, - ); - opened.is_some() - }).expect("Bob's slot exists") as u32, - cek: built.cek, - priv_x_seed: { - // Re-derive by re-unwrapping. - let mut seed = [0u8; 32]; - for slot in &built.gating.wrap_slots { - if let Some(o) = crate::crypto::open_wrap_slot( - &v_x_bob, &built.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext, - ) { seed = o.priv_x_seed; break; } - } - seed - }, - }; - let comment = build_fof_comment( - &post_id, &bob_unlock, &built.slot_binder_nonce, - &bob_id, &bob_seed, "hello", None, 4000, - ).unwrap(); - s.store_comment(&comment).unwrap(); - assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); - - // Resolve Bob's pub_x bytes from the gating's pub_post_set. - let bob_pub_x = built.gating.pub_post_set[bob_unlock.slot_index as usize]; - - // Author signs + applies revocation. - let revoked_at = 5000; - let sig = sign_fof_revocation(&alice_seed, &post_id, &bob_pub_x, revoked_at, 0); - assert!(verify_fof_revocation(&alice_id, &post_id, &bob_pub_x, revoked_at, 0, &sig)); - - let deleted = apply_fof_revocation_locally( - &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, - ).unwrap(); - assert_eq!(deleted, 1, "Bob's comment retroactively deleted"); - assert!(s.get_comments(&post_id).unwrap().is_empty(), - "no live comments remain on the post"); - - // Verify revocation is recorded for future CDN-verify lookups. - assert!(s.is_fof_pub_x_revoked(&post_id, &bob_pub_x).unwrap()); - - // Idempotent: re-apply is a no-op (returns 0 because comment already gone). - let re_deleted = apply_fof_revocation_locally( - &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, - ).unwrap(); - assert_eq!(re_deleted, 0); - } - - /// Access-grant roundtrip: Alice publishes a Mode 2 FoF post, then - /// later vouches for Carol. Alice signs an access-grant adding - /// Carol's V_x to the post. apply_fof_access_grant_locally appends - /// the new slot; Carol's device can now unlock the post. - #[test] - fn fof_access_grant_appends_and_unlocks() { - use crate::types::PostingIdentity; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(60); - 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(); - - // Initial gating: Alice only. - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); - let post_id = [0xBC; 32]; - let post = crate::types::Post { - author: alice_id, content: "alice".into(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - s.store_post_with_intent( - &post_id, &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ).unwrap(); - - // Carol's V_x (Carol vouches for herself or is granted access). - let mut v_x_carol = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_carol); - - // Pre-grant: Carol can NOT unlock the post via her V_x. - let pre_post = s.get_post(&post_id).unwrap().unwrap(); - let pre_unlock = pre_post.fof_gating.as_ref() - .map(|g| g.wrap_slots.iter().any(|slot| { - crate::crypto::open_wrap_slot( - &v_x_carol, &g.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext, - ).is_some() - })) - .unwrap_or(false); - assert!(!pre_unlock, "Carol cannot unlock pre-grant"); - - // Alice authors an access-grant: seal a new slot under Carol's V_x. - let mut new_priv_x_seed = [0u8; 32]; - rand::rng().fill_bytes(&mut new_priv_x_seed); - let new_pub_x = SigningKey::from_bytes(&new_priv_x_seed) - .verifying_key().to_bytes(); - let sealed = crate::crypto::seal_wrap_slot( - &v_x_carol, &built.slot_binder_nonce, &built.cek, &new_priv_x_seed, - ).unwrap(); - let new_wrap_slot = crate::types::WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }; - - let granted_at = 5000; - let sig = sign_fof_access_grant( - &alice_seed, &post_id, &new_pub_x, &new_wrap_slot, granted_at, - ); - assert!(verify_fof_access_grant( - &alice_id, &post_id, &new_pub_x, &new_wrap_slot, granted_at, &sig, - )); - - let applied = apply_fof_access_grant_locally( - &s, &post_id, &new_pub_x, &new_wrap_slot, - ).unwrap(); - assert!(applied, "access-grant appended"); - - // Post-grant: stored post's gating now includes Carol's slot. - let post = s.get_post(&post_id).unwrap().unwrap(); - let g = post.fof_gating.as_ref().unwrap(); - let unlocked = g.wrap_slots.iter().any(|slot| { - crate::crypto::open_wrap_slot( - &v_x_carol, &g.slot_binder_nonce, - &slot.read_ciphertext, &slot.sign_ciphertext, - ).map(|o| o.cek == built.cek).unwrap_or(false) - }); - assert!(unlocked, "Carol can now unlock the post and recover Alice's CEK"); - - // Idempotent: re-applying the same grant is a no-op. - let again = apply_fof_access_grant_locally( - &s, &post_id, &new_pub_x, &new_wrap_slot, - ).unwrap(); - assert!(!again, "duplicate grant skipped"); - - // Revocation blocks re-admission. - s.add_fof_revocation(&post_id, &new_pub_x, 6000, 0, &[0u8; 64]).unwrap(); - let blocked = apply_fof_access_grant_locally( - &s, &post_id, &new_pub_x, &new_wrap_slot, - ).unwrap(); - assert!(!blocked, "revoked pub_x must not be re-granted access"); - } - - /// Key-burn roundtrip: Alice publishes a Mode 2 FoF post sealed - /// under V_me_old; signs + applies a key-burn that swaps the slot - /// to a new V_me. After burn, old V_me cannot unlock the burned - /// slot but new V_me can. - #[test] - fn fof_key_burn_replaces_slot() { - use crate::types::PostingIdentity; - use ed25519_dalek::SigningKey; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(90); - s.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, secret_seed: alice_seed, - display_name: "Alice".into(), created_at: 1000, - }).unwrap(); - - // Alice has V_me_old. Build a gating block with just her slot. - let mut v_me_old = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_old); - s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); - - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); - let post_id = [0xAB; 32]; - let post = crate::types::Post { - author: alice_id, content: "x".into(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - s.store_post_with_intent( - &post_id, &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ).unwrap(); - - // Find Alice's slot (the one V_me_old unlocks). - let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { - crate::crypto::open_wrap_slot( - &v_me_old, &built.slot_binder_nonce, - &built.gating.wrap_slots[i].read_ciphertext, - &built.gating.wrap_slots[i].sign_ciphertext, - ).is_some() - }).expect("alice slot exists"); - - // Simulate leak: Alice burns the slot under a new V_me. - let mut v_me_new = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_new); - let mut new_seed = [0u8; 32]; - rand::rng().fill_bytes(&mut new_seed); - let new_pub_x = SigningKey::from_bytes(&new_seed).verifying_key().to_bytes(); - let sealed = crate::crypto::seal_wrap_slot( - &v_me_new, &built.slot_binder_nonce, &built.cek, &new_seed, - ).unwrap(); - let new_wrap_slot = crate::types::WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }; - - let burned_at = 5000; - let sig = sign_fof_key_burn( - &alice_seed, &post_id, alice_slot_idx as u32, - &new_pub_x, &new_wrap_slot, burned_at, - ); - assert!(verify_fof_key_burn( - &alice_id, &post_id, alice_slot_idx as u32, - &new_pub_x, &new_wrap_slot, burned_at, &sig, - )); - - let applied = apply_fof_key_burn_locally( - &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at, - ).unwrap(); - assert!(applied); - - // Post-burn: V_me_old can NO LONGER unlock the burned slot. - let stored = s.get_post(&post_id).unwrap().unwrap(); - let g = stored.fof_gating.as_ref().unwrap(); - let old_attempt = crate::crypto::open_wrap_slot( - &v_me_old, &g.slot_binder_nonce, - &g.wrap_slots[alice_slot_idx].read_ciphertext, - &g.wrap_slots[alice_slot_idx].sign_ciphertext, - ); - assert!(old_attempt.is_none(), "V_me_old can no longer unlock the burned slot"); - - // V_me_new CAN unlock and recovers the same CEK. - let new_attempt = crate::crypto::open_wrap_slot( - &v_me_new, &g.slot_binder_nonce, - &g.wrap_slots[alice_slot_idx].read_ciphertext, - &g.wrap_slots[alice_slot_idx].sign_ciphertext, - ).expect("V_me_new unlocks the new slot"); - assert_eq!(new_attempt.cek, built.cek, "CEK is unchanged across the burn"); - - // pub_post_set at that slot is now the new pub_x. - assert_eq!(g.pub_post_set[alice_slot_idx], new_pub_x); - } - - // --- Pre-deploy hardening: wire-shape validation + DoS caps --- - - fn dummy_wrap_slot() -> crate::types::WrapSlot { - crate::types::WrapSlot { - prefilter_tag: [0u8; 2], - read_ciphertext: vec![0u8; 48], - sign_ciphertext: vec![0u8; 48], - } - } - - fn dummy_gating(slot_count: usize) -> crate::types::FoFCommentGating { - crate::types::FoFCommentGating { - slot_binder_nonce: [0u8; 32], - pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(), - wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(), - revocation_list: vec![], - } - } - - fn dummy_post(g: Option) -> crate::types::Post { - crate::types::Post { - author: [0u8; 32], content: String::new(), attachments: vec![], - timestamp_ms: 0, fof_gating: g, supersedes_post_id: None, - } - } - - #[test] - fn validate_rejects_length_mismatch() { - let mut g = dummy_gating(8); - g.pub_post_set.pop(); - let p = dummy_post(Some(g)); - let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); - assert!(err.contains("length mismatch"), "got: {}", err); - } - - #[test] - fn validate_rejects_oversized_slots() { - let g = dummy_gating(MAX_FOF_WRAP_SLOTS + 1); - let p = dummy_post(Some(g)); - let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); - assert!(err.contains("oversized"), "got: {}", err); - } - - #[test] - fn validate_rejects_wrong_ciphertext_size() { - let mut g = dummy_gating(8); - g.wrap_slots[3].read_ciphertext = vec![0u8; 32]; // wrong size - let p = dummy_post(Some(g)); - let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); - assert!(err.contains("ciphertext sizes"), "got: {}", err); - } - - #[test] - fn validate_accepts_well_formed_gating() { - let g = dummy_gating(16); - let p = dummy_post(Some(g)); - validate_fof_gating_on_receive(&p).unwrap(); - } - - #[test] - fn validate_accepts_post_without_gating() { - let p = dummy_post(None); - validate_fof_gating_on_receive(&p).unwrap(); - } - - #[test] - fn validate_fof_closed_requires_gating() { - let p = dummy_post(None); - let err = validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::FoFClosed) - .unwrap_err().to_string(); - assert!(err.contains("requires a fof_gating"), "got: {}", err); - // FoFClosed + gating Some → OK - let p2 = dummy_post(Some(dummy_gating(8))); - validate_fof_closed_has_gating(&p2, &crate::types::PostVisibility::FoFClosed).unwrap(); - // Public + None → OK - validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::Public).unwrap(); - } - - #[test] - fn unreadable_queue_is_capped() { - let s = temp_storage(); - let (persona, _) = make_persona(200); - let (author, _) = make_persona(201); - // Fill to the cap (4096 entries) — use distinct post_ids. - for i in 0..crate::storage::Storage::max_unreadable_per_persona_for_test() as u32 { - let mut pid = [0u8; 32]; - pid[..4].copy_from_slice(&i.to_le_bytes()); - s.record_unreadable_post(&persona, &pid, &author, 1000 + i as u64).unwrap(); - } - // Try to add one more. Should be silently dropped (no INSERT). - let mut overflow_pid = [0u8; 32]; - overflow_pid[..4].copy_from_slice(&999_999u32.to_le_bytes()); - s.record_unreadable_post(&persona, &overflow_pid, &author, 999_999).unwrap(); - - let queued = s.list_all_unreadable_posts(&persona).unwrap(); - assert_eq!( - queued.len() as i64, - crate::storage::Storage::max_unreadable_per_persona_for_test(), - "queue stays at cap; overflow dropped", - ); - // Overflow post was NOT added. - assert!(!queued.contains(&overflow_pid)); - } - - /// Key-burn replay rejection: applying an older signed burn after - /// a newer one must not revert state. - #[test] - fn fof_key_burn_replay_rejected() { - use crate::types::PostingIdentity; - use ed25519_dalek::SigningKey; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(220); - 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(); - - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); - let post_id = [0xEE; 32]; - let post = crate::types::Post { - author: alice_id, content: String::new(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - s.store_post_with_intent( - &post_id, &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ).unwrap(); - - // Find Alice's slot. - let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { - crate::crypto::open_wrap_slot( - &v_me_alice, &built.slot_binder_nonce, - &built.gating.wrap_slots[i].read_ciphertext, - &built.gating.wrap_slots[i].sign_ciphertext, - ).is_some() - }).expect("alice slot exists") as u32; - - // First burn (Monday): switch to V_me_new1. - let mut v_me_new1 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new1); - let mut seed1 = [0u8; 32]; rand::rng().fill_bytes(&mut seed1); - let pub_x1 = SigningKey::from_bytes(&seed1).verifying_key().to_bytes(); - let sealed1 = crate::crypto::seal_wrap_slot( - &v_me_new1, &built.slot_binder_nonce, &built.cek, &seed1, - ).unwrap(); - let wrap1 = crate::types::WrapSlot { - prefilter_tag: sealed1.prefilter_tag, - read_ciphertext: sealed1.read_ciphertext, - sign_ciphertext: sealed1.sign_ciphertext, - }; - let monday = 100_000; - apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday).unwrap(); - - // Second burn (Friday, later timestamp): switch to V_me_new2. - let mut v_me_new2 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new2); - let mut seed2 = [0u8; 32]; rand::rng().fill_bytes(&mut seed2); - let pub_x2 = SigningKey::from_bytes(&seed2).verifying_key().to_bytes(); - let sealed2 = crate::crypto::seal_wrap_slot( - &v_me_new2, &built.slot_binder_nonce, &built.cek, &seed2, - ).unwrap(); - let wrap2 = crate::types::WrapSlot { - prefilter_tag: sealed2.prefilter_tag, - read_ciphertext: sealed2.read_ciphertext, - sign_ciphertext: sealed2.sign_ciphertext, - }; - let friday = 200_000; - apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x2, &wrap2, friday).unwrap(); - - // Replay of Monday's burn (older timestamp) — must be rejected. - let applied = apply_fof_key_burn_locally( - &s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday, - ).unwrap(); - assert!(!applied, "older burn must be rejected as replay"); - - // Stored state must still reflect Friday's burn. - let stored = s.get_post(&post_id).unwrap().unwrap(); - let g = stored.fof_gating.as_ref().unwrap(); - assert_eq!(g.pub_post_set[alice_slot_idx as usize], pub_x2, - "Friday's pub_x preserved despite replayed Monday burn"); - } - - #[test] - fn body_bucket_rule_boundaries() { - // Sub-1KB. - assert_eq!(next_body_size_bucket(0), 1024); - assert_eq!(next_body_size_bucket(1024), 1024); - // Power-of-2 progression sub-256KB. - assert_eq!(next_body_size_bucket(1025), 2048); - assert_eq!(next_body_size_bucket(2048), 2048); - assert_eq!(next_body_size_bucket(100_000), 131_072); // 128KB - assert_eq!(next_body_size_bucket(200_000), 262_144); // 256KB - // Linear +256KB above. - assert_eq!(next_body_size_bucket(262_145), 524_288); // 512KB - assert_eq!(next_body_size_bucket(500_000), 524_288); - assert_eq!(next_body_size_bucket(524_289), 786_432); // 768KB - } - - #[test] - fn fof_body_roundtrip() { - let cek = [0x55; 32]; - let slot_binder_nonce = [0xCC; 32]; - let body = "Hello, this is a Mode 1 FoFClosed body that nobody outside the FoF set should be able to read on the wire."; - - let encrypted = encrypt_fof_body(body, &cek, &slot_binder_nonce).unwrap(); - // Encryption pads to bucket: small body → 1KB ciphertext-ish. - assert!(encrypted.len() >= 1024, "body padded to >= 1KB bucket"); - - let decrypted = decrypt_fof_body(&encrypted, &cek, &slot_binder_nonce).unwrap(); - assert_eq!(decrypted, body); - - // Wrong CEK fails AEAD. - let wrong_cek = [0x11; 32]; - assert!(decrypt_fof_body(&encrypted, &wrong_cek, &slot_binder_nonce).is_err()); - - // Wrong AAD (slot_binder_nonce) fails too. - let wrong_nonce = [0xFF; 32]; - assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err()); - } - - /// FoF Layer 5: first find_unlock_for_post call populates the - /// cache; subsequent calls hit the fast path (cache lookup, single - /// AEAD attempt). Tested by verifying the cache table after first - /// call + cleared-unreadable invariant. - #[test] - fn fof_unlock_cache_populates_and_hits() { - use crate::types::PostingIdentity; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(100); - let (bob_id, bob_seed) = make_persona(101); - s.upsert_posting_identity(&PostingIdentity { - node_id: bob_id, secret_seed: bob_seed, - display_name: "Bob".into(), created_at: 1000, - }).unwrap(); - let mut v_x_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_bob); - s.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1000).unwrap(); - - // Build a post from Alice that includes Bob's V_x. - let alice_storage = temp_storage(); - alice_storage.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, secret_seed: alice_seed, - display_name: "Alice".into(), created_at: 500, - }).unwrap(); - let mut v_me_alice = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_alice); - alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap(); - alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); - let post = crate::types::Post { - author: alice_id, content: String::new(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - - // Bob's first scan — full scan path, populates cache. - let unlock1 = find_unlock_for_post(&s, &post).unwrap().expect("Bob unlocks"); - assert_eq!(unlock1.cek, built.cek); - let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated"); - // Bob's V_x is owned by Bob himself (insert_own_vouch_key). - assert_eq!(cached.0, bob_id); - assert_eq!(cached.1, 1); - - // Second call should hit the cache. Sanity: still unlocks. - let unlock2 = find_unlock_for_post(&s, &post).unwrap().expect("cache hit"); - assert_eq!(unlock2.cek, built.cek); - assert_eq!(unlock2.slot_index, unlock1.slot_index); - } - - /// FoF Layer 5: non-member persona records the post as unreadable; - /// later arrival of a matching V_x + sweep brings it back into - /// readability and clears the queue. - #[test] - fn fof_unreadable_sweep_after_v_x_arrival() { - use crate::types::PostingIdentity; - - // Build a post from Alice that requires Carol's V_x to unlock. - let alice_storage = temp_storage(); - let (alice_id, alice_seed) = make_persona(110); - alice_storage.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, secret_seed: alice_seed, - display_name: "Alice".into(), created_at: 100, - }).unwrap(); - let mut v_me_alice = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_alice); - alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 100).unwrap(); - - let (carol_id, _) = make_persona(111); - let mut v_x_carol = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_carol); - alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap(); - - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); - - // Bob's storage: holds his own V_me only (no Carol-V_x). The post - // shouldn't unlock for him yet. - let s = temp_storage(); - let (bob_id, bob_seed) = make_persona(112); - s.upsert_posting_identity(&PostingIdentity { - node_id: bob_id, secret_seed: bob_seed, - display_name: "Bob".into(), created_at: 300, - }).unwrap(); - let mut v_me_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_bob); - s.insert_own_vouch_key(&bob_id, 1, &v_me_bob, 300).unwrap(); - - let post = crate::types::Post { - author: alice_id, content: String::new(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - // Persist the post so the sweep can re-fetch it. - s.store_post_with_intent( - &crate::content::compute_post_id(&post), &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ).unwrap(); - - // First attempt: no V_x matches → unreadable queue grows. - let pre = find_unlock_for_post(&s, &post).unwrap(); - assert!(pre.is_none(), "Bob can't unlock pre-V_x"); - let queued = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap(); - assert_eq!(queued.len(), 1); - - // Carol vouches for Bob via... wait, Carol's V_x is sealed in - // Alice's post for Carol. Bob is unrelated. To make this test - // realistic, let's say Carol DOES vouch for Bob — Bob now - // holds V_x_carol in his keyring. After the sweep, Bob can - // unlock Alice's post via Carol's V_x (which IS sealed in the - // gating because Alice held Carol's V_x). - s.insert_received_vouch_key(&bob_id, &carol_id, 1, &v_x_carol, 400, None).unwrap(); - let swept = sweep_unreadable_on_new_v_x(&s, &bob_id, &carol_id).unwrap(); - assert_eq!(swept, 1, "sweep unlocks Alice's post via Carol's V_x"); - - // Post-sweep: unreadable queue cleared; unlock cache populated. - let after = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap(); - assert!(after.is_empty()); - let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated"); - assert_eq!(cached.0, carol_id, "winning V_x owner is Carol"); - assert_eq!(cached.1, 1); - } - - /// 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()), - supersedes_post_id: None, - }; - 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]; - let nonce = [0xCC; 32]; - // A 5-byte body and a 500-byte body should both produce the - // same on-wire size (1KB bucket). - let small = encrypt_fof_body("short", &cek, &nonce).unwrap(); - let medium = encrypt_fof_body(&"x".repeat(500), &cek, &nonce).unwrap(); - assert_eq!(small.len(), medium.len(), - "different bodies in the same bucket produce same-sized ciphertexts"); - } - - /// Provenance roundtrip: build_fof_comment_gating populates - /// real_slot_provenance; entries match the actual real slots in - /// the gating block. - #[test] - fn fof_gating_real_slot_provenance() { - use crate::types::PostingIdentity; - use ed25519_dalek::SigningKey; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(80); - 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, 7, &v_me_alice, 1000).unwrap(); - - // Two received V_x's at different epochs. - let (bob_id, _) = make_persona(81); - let (carol_id, _) = make_persona(82); - let mut v_x_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_bob); - let mut v_x_carol = [0u8; 32]; - rand::rng().fill_bytes(&mut v_x_carol); - s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); - s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); - - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); - // 3 unique V_x's → 3 real slots, padded to bucket 8. - assert_eq!(built.real_slot_provenance.len(), 3); - assert_eq!(built.gating.pub_post_set.len(), 8); - - // Provenance entries must reference real positions whose pub_x - // matches gating.pub_post_set[slot_index]. - for prov in &built.real_slot_provenance { - assert_eq!( - built.gating.pub_post_set[prov.slot_index as usize], - prov.pub_x, - "provenance pub_x matches gating at indexed slot" - ); - } - - // Provenance covers exactly Alice's own V_me (epoch 7) + Bob (3) + Carol (5). - let owners: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_owner).collect(); - assert!(owners.contains(&alice_id)); - assert!(owners.contains(&bob_id)); - assert!(owners.contains(&carol_id)); - let epochs: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_epoch).collect(); - assert!(epochs.contains(&7)); - assert!(epochs.contains(&3)); - assert!(epochs.contains(&5)); - - // The pub_x derived from each real slot's signing seed must - // match the published pub_post_set entry. - for prov in &built.real_slot_provenance { - for v_x in [v_me_alice, v_x_bob, v_x_carol].iter() { - if let Some(opened) = crate::crypto::open_wrap_slot( - v_x, &built.slot_binder_nonce, - &built.gating.wrap_slots[prov.slot_index as usize].read_ciphertext, - &built.gating.wrap_slots[prov.slot_index as usize].sign_ciphertext, - ) { - let derived = SigningKey::from_bytes(&opened.priv_x_seed) - .verifying_key().to_bytes(); - assert_eq!(derived, prov.pub_x); - break; - } - } - } - } - - #[test] - fn fof_revocation_wrong_author_rejected() { - let post_id = [0x01; 32]; - let revoked_pub_x = [0x02; 32]; - let (alice_id, alice_seed) = make_persona(50); - let (mallory_id, mallory_seed) = make_persona(51); - - let sig = sign_fof_revocation(&mallory_seed, &post_id, &revoked_pub_x, 1000, 0); - // Mallory signed but claims Alice authored → reject. - assert!(!verify_fof_revocation(&alice_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); - // Self-signed → accept. - assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); - let _ = alice_seed; - } -} diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index 0a753c2..f23335e 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -61,8 +61,6 @@ pub fn build_distribution_post( content: ciphertext_b64, attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, }; let post_id = compute_post_id(&post); let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; @@ -243,8 +241,6 @@ mod tests { content: ciphertext, attachments: vec![], timestamp_ms: 200, - fof_gating: None, - supersedes_post_id: None, }; let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; diff --git a/crates/core/src/import.rs b/crates/core/src/import.rs index 72f218e..ed12d2a 100644 --- a/crates/core/src/import.rs +++ b/crates/core/src/import.rs @@ -62,9 +62,6 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI // No intent recorded — infer from the visibility shape. match vis { PostVisibility::Public => VisibilityIntent::Public, - // FoF Layer 3: FoFClosed pairs with VisibilityIntent::Public. - // The FoF gating handles audience; intent is the structural tag. - PostVisibility::FoFClosed => VisibilityIntent::Public, PostVisibility::Encrypted { recipients } => { // Heuristic: DMs typically wrap to 1-2 people (recipient + self); // Friends posts wrap to every public follow (usually many). @@ -289,8 +286,6 @@ pub async fn import_as_personas( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, - fof_gating: None, - supersedes_post_id: None, }; // Preserve the original visibility intent from the export. @@ -464,8 +459,6 @@ pub async fn import_public_posts( content: ep.content.clone(), attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, - fof_gating: None, - supersedes_post_id: None, }; // Read blob data from archive @@ -684,16 +677,6 @@ pub async fn merge_with_key( skipped += 1; continue; } - PostVisibility::FoFClosed => { - // FoF Layer 3 import: skip for now. The recovered - // post would need its fof_gating + CEK to decrypt, - // and the receiving persona's keyring may not - // include the right V_x. Re-issue via the author's - // device is the supported path. - debug!(post = ep.id, "FoFClosed post — skipping (import not yet supported)"); - skipped += 1; - continue; - } }; // Create new post under our identity @@ -702,8 +685,6 @@ pub async fn merge_with_key( content: plaintext, attachments: attachments.clone(), timestamp_ms: ep.timestamp_ms, - fof_gating: None, - supersedes_post_id: None, }; // Read blob data from archive (may need decryption for encrypted posts) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a4e6d7f..a9cf280 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,7 +7,6 @@ pub mod crypto; pub mod group_key_distribution; pub mod http; pub mod export; -pub mod fof; pub mod identity; pub mod import; pub mod announcement; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 282a576..114ab41 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -2234,11 +2234,6 @@ pub fn should_send_post( .map(|members| members.iter().any(|m| query_list.contains(m))) .unwrap_or(false) } - // FoF Layer 3: FoFClosed posts have no per-recipient identifiers - // on the wire. Match like Public (by author): the post propagates - // through the same CDN diversity path as public content; only - // FoF readers can decrypt. - PostVisibility::FoFClosed => query_list.contains(&post.author), } } @@ -2258,8 +2253,6 @@ mod tests { content: "test".to_string(), attachments: vec![], timestamp_ms: 1000, - fof_gating: None, - supersedes_post_id: None, } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 7c90407..3a1614d 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -63,35 +63,6 @@ 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 { @@ -162,8 +133,6 @@ 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, @@ -715,12 +684,6 @@ 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) } @@ -735,22 +698,12 @@ impl Node { bio: &str, avatar_cid: Option<[u8; 32]>, ) -> anyhow::Result<()> { - // FoF Layer 1: build the vouch-grant batch (if this persona has - // any current vouch targets) + bump the bio_epoch. - let (vouch_grants, bio_epoch) = { - let storage = self.storage.get().await; - let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; - let epoch = storage.next_bio_epoch_for(posting_id)?; - (batch, epoch) - }; let profile_post = crate::profile::build_profile_post( posting_id, posting_secret, display_name, bio, avatar_cid, - vouch_grants, - bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -819,16 +772,12 @@ impl Node { avatar_cid: None, timestamp_ms: pi.created_at, signature, - vouch_grants: None, - bio_epoch: 0, }; let post = Post { author: pi.node_id, content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms: pi.created_at, - fof_gating: None, - supersedes_post_id: None, }; let post_id = crate::content::compute_post_id(&post); { @@ -1015,7 +964,6 @@ impl Node { content, intent, attachment_data, - None, ).await } @@ -1040,187 +988,9 @@ impl Node { content, intent, attachment_data, - None, ).await } - /// FoF Layer 2: create a Mode 2 post (public body, FoF-gated - /// comments). Intent is Public; the FoF gating block is built - /// from the default persona's keyring and embedded in - /// `Post.fof_gating`. The author retains the per-post CEK locally - /// for decrypting their own comments later. - /// - /// Returns `(post_id, post, visibility, cek)`. `visibility` is - /// always Public for Mode 2. - pub async fn create_post_with_fof_comments( - &self, - content: String, - attachment_data: Vec<(Vec, String)>, - ) -> anyhow::Result<(PostId, Post, PostVisibility, [u8; 32])> { - // Build the gating block from the default persona's keyring. - 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 provenance = built.real_slot_provenance.clone(); - let (post_id, post, visibility) = self.create_post_inner( - &self.default_posting_id, - &self.default_posting_secret, - content, - VisibilityIntent::Public, - attachment_data, - Some(built.gating), - ).await?; - - // FoF Layer 4: persist provenance so cascade-revocation can - // resolve "which pub_x's on which of my posts were sealed - // under V_me epoch N" later. - // FoF Layer 5: cache the CEK + slot_binder_nonce for author- - // direct decrypt without trial-unlocking on read. - { - let storage = self.storage.get().await; - for entry in &provenance { - let _ = storage.record_post_slot_provenance( - &self.default_posting_id, &post_id, entry.slot_index, - &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, - ); - } - // Recover slot_binder_nonce via the Post we just built — it - // lives inside fof_gating. - if let Some(gating) = post.fof_gating.as_ref() { - let _ = storage.cache_own_fof_post_cek( - &self.default_posting_id, &post_id, - &cek, &gating.slot_binder_nonce, - ); - } - } - - 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), - }; - - // FoF Layer 5: author-direct fast path. If this device authored - // the post, the CEK was cached at publish time; skip the - // wrap-slot trial entirely. - let (cek, slot_binder_nonce) = if let Some((cek, nonce)) = - storage.lookup_own_fof_post_cek(&post.author, post_id)? - { - (cek, nonce) - } else { - let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? { - Some(u) => u, - None => return Ok(None), - }; - (unlock.cek, gating.slot_binder_nonce) - }; - drop(storage); - - 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, &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; - let provenance = built.real_slot_provenance.clone(); - - // 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), - supersedes_post_id: None, - }; - 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, - )?; - // FoF Layer 4: persist provenance for cascade-revoke. - for entry in &provenance { - let _ = storage.record_post_slot_provenance( - &self.default_posting_id, &post_id, entry.slot_index, - &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, - ); - } - // FoF Layer 5: cache CEK for author-direct decrypt. - let _ = storage.cache_own_fof_post_cek( - &self.default_posting_id, &post_id, &cek, &slot_binder_nonce, - ); - } - - 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, @@ -1228,7 +998,6 @@ impl Node { content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, - fof_gating: Option, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { // Validate attachments if attachment_data.len() > 4 { @@ -1344,8 +1113,6 @@ impl Node { content: final_content, attachments, timestamp_ms: now, - fof_gating, - supersedes_post_id: None, }; let post_id = compute_post_id(&post); @@ -1359,13 +1126,8 @@ impl Node { let _ = storage.pin_blob(&att.cid); } - // Initialize encrypted receipt + comment slots for non-public posts. - // FoFClosed posts use the FoF wrap_slots mechanism for both - // reads and comments — they don't use the legacy receipt/ - // comment slot path. Skip init for FoFClosed. - if !matches!(visibility, PostVisibility::Public) - && !matches!(visibility, PostVisibility::FoFClosed) - { + // Initialize encrypted receipt + comment slots for non-public posts + if !matches!(visibility, PostVisibility::Public) { let participant_count = match &visibility { PostVisibility::Encrypted { recipients } => recipients.len(), PostVisibility::GroupEncrypted { .. } => { @@ -1380,7 +1142,7 @@ impl Node { _ => 2, } } - PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(), + PostVisibility::Public => unreachable!(), }; let receipt_slots: Vec> = (0..participant_count) @@ -1634,15 +1396,6 @@ impl Node { ).ok() }) } - // FoF Layer 3: FoFClosed body decrypt requires - // trial-unlocking via the post's wrap_slots against - // every persona's received-vouch keyring — which is - // an async storage lookup, not available in this - // sync helper. Feed rendering for FoFClosed posts - // goes through a dedicated async path that resolves - // the unlock + decrypts; this helper returns None - // and lets the caller fall back. - PostVisibility::FoFClosed => None, }; (id, post, vis, decrypted) }) @@ -1750,22 +1503,12 @@ impl Node { storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; - // FoF Layer 1: build the vouch-grant batch (if this persona has - // any current vouch targets) + bump bio_epoch. - let (vouch_grants, bio_epoch) = { - let storage = self.storage.get().await; - let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; - let epoch = storage.next_bio_epoch_for(&posting_id)?; - (batch, epoch) - }; let profile_post = crate::profile::build_profile_post( &posting_id, &posting_secret, &display_name, &bio, avatar_cid, - vouch_grants, - bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1894,220 +1637,6 @@ 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(()) - } - - /// FoF Layer 4: pure V_me rotation. Generates a new V_me epoch for - /// the default persona without revoking any vouchee. Republishes - /// the persona's bio post under the new key for every current - /// target. Used for periodic refresh or leak response (combined - /// with `cascade_revoke_v_me_epoch` for old-content cleanup + - /// `key_burn_post` for leaked-key scenarios). - /// - /// Returns the new epoch number. - pub async fn rotate_v_me(&self) -> anyhow::Result { - use rand::RngCore; - let (default_id, display_name, bio, avatar_cid, posting_secret, new_epoch) = { - 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 missing"))?; - 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), - }; - - 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); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; - (default_id, name, bio, avatar, pi.secret_seed, next_epoch) - }; - - // Republish bio so existing vouch targets receive the new key. - self.publish_profile_post_as( - &default_id, &posting_secret, &display_name, &bio, avatar_cid, - ).await?; - Ok(new_epoch) - } - - /// FoF Layer 4: cascade revocation. For every FoF post authored by - /// the default persona where slots were sealed under V_me at - /// `retired_epoch`, publish a per-pub_x revocation diff. Existing - /// stored comments by those pub_x's are cascade-deleted via the - /// standard apply_fof_revocation path. - /// - /// Returns the number of post-level revocations published. - /// Typically called after `rotate_v_me` when the user wants to - /// retire access for vouchees they no longer want commenting on - /// old posts. Optional — by default rotation grandfathers old - /// posts. - pub async fn cascade_revoke_v_me_epoch( - &self, - retired_epoch: u32, - reason_code: u8, - ) -> anyhow::Result { - // Look up all (post_id, pub_x) pairs sealed under (self, retired_epoch). - let pairs = { - let storage = self.storage.get().await; - storage.list_provenance_for_v_x_epoch( - &self.default_posting_id, - &self.default_posting_id, - retired_epoch, - )? - }; - let mut published = 0usize; - for (post_id, _pub_x, slot_index) in pairs { - // Use the existing per-post revocation helper. It signs + - // applies locally + propagates. - if self.revoke_fof_commenter(post_id, slot_index, reason_code).await.is_ok() { - published += 1; - } - } - Ok(published) - } - - /// 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. @@ -2155,15 +1684,6 @@ impl Node { Ok(None) } } - // FoF Layer 3: blob decryption for FoFClosed posts requires - // the CEK recovered via wrap_slots. This sync helper doesn't - // have storage access for the keyring trial-unlock; the - // async caller path goes through get_blob_for_post which - // can perform the unlock. For now return None — blob - // decryption for FoF posts is wired in the receive/render - // slice. (v0 ships with FoF body decryption only; binary - // attachments arrive in a follow-up.) - PostVisibility::FoFClosed => Ok(None), } } @@ -3287,9 +2807,6 @@ impl Node { PostVisibility::GroupEncrypted { .. } => { anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead") } - PostVisibility::FoFClosed => { - anyhow::bail!("cannot revoke individual access on a FoF-gated post via this path; use revoke_fof_commenter (Layer 2) or grant_fof_access (Layer 3)") - } }; let new_recipient_ids: Vec = existing_recipients @@ -3369,8 +2886,6 @@ impl Node { content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, - fof_gating: None, - supersedes_post_id: None, }; let new_post_id = compute_post_id(&new_post); @@ -4829,21 +4344,6 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result { - // FoF Layer 2: if the post carries fof_gating, route through - // the FoF comment path so the comment is encrypted under - // CEK_comments + signed under priv_x. The CDN four-check accept - // rule on receivers will then validate the comment. - let is_fof_gated = { - let storage = self.storage.get().await; - storage.get_post(&post_id) - .ok() - .flatten() - .and_then(|p| p.fof_gating) - .is_some() - }; - if is_fof_gated { - return self.comment_on_fof_post(post_id, content).await; - } self.comment_on_post_inner(post_id, content, None).await } @@ -4891,9 +4391,6 @@ impl Node { signature, deleted_at: None, ref_post_id, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, }; let storage = self.storage.get().await; @@ -5019,286 +4516,6 @@ impl Node { Ok(()) } - /// FoF Layer 2: revoke a specific pub_x from a FoF-gated post the - /// caller authored. Builds a signed FoFRevocation diff, applies it - /// locally (record + cascade delete), and propagates via the - /// standard engagement-diff path. Idempotent. - /// - /// Caller passes the `pub_x_index` (from a stored comment they want - /// to revoke). The pub_x bytes are resolved via the post's - /// pub_post_set; if the post or index is missing, returns Err. - pub async fn revoke_fof_commenter( - &self, - post_id: PostId, - pub_x_index: u32, - reason_code: u8, - ) -> anyhow::Result<()> { - // Resolve pub_x bytes + confirm we authored the post. - let (post_author, posting_secret, revoked_pub_x) = { - let storage = self.storage.get().await; - let post = storage.get_post(&post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))?; - let gating = post.fof_gating.as_ref() - .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; - let pub_x = gating.pub_post_set.get(pub_x_index as usize).copied() - .ok_or_else(|| anyhow::anyhow!("pub_x_index out of bounds"))?; - let identity = storage.get_posting_identity(&post.author)? - .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; - (post.author, identity.secret_seed, pub_x) - }; - - let revoked_at_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let author_sig = crate::fof::sign_fof_revocation( - &posting_secret, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, - ); - - // Apply locally first so the author's UI updates immediately. - { - let storage = self.storage.get().await; - let _ = crate::fof::apply_fof_revocation_locally( - &*storage, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, &author_sig, - ); - } - - // Propagate the diff. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id, - author: post_author, - ops: vec![crate::types::BlobHeaderDiffOp::FoFRevocation { - post_id, - revoked_pub_x, - revoked_at_ms, - reason_code, - author_sig, - }], - timestamp_ms: now, - }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; - - Ok(()) - } - - /// FoF Layer 2: author a comment on a FoF-gated post. Finds the - /// caller's unlock (any held V_x that matches one of the post's - /// slots), encrypts the body under CEK_comments, signs with the - /// per-V_x priv_x, attaches pub_x_index, stores locally, and - /// propagates via the standard engagement-diff path. - /// - /// Returns the constructed InlineComment. Errors if the post - /// isn't FoF-gated, or if no held V_x admits the caller. - pub async fn comment_on_fof_post( - &self, - post_id: PostId, - body: String, - ) -> anyhow::Result { - let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author) = { - let storage = self.storage.get().await; - let post = storage.get_post(&post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))?; - let gating = post.fof_gating.as_ref() - .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; - let slot_binder_nonce = gating.slot_binder_nonce; - let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? - .ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?; - let identity = storage.get_posting_identity(&unlock.persona_id)? - .ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?; - (unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author) - }; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let comment = crate::fof::build_fof_comment( - &post_id, &unlock, &slot_binder_nonce, - &commenter_id, &commenter_secret, &body, None, now, - )?; - - // Store locally. - { - let storage = self.storage.get().await; - storage.store_comment(&comment)?; - } - - // Propagate via engagement-diff path. - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id, - author: post_author, - ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], - timestamp_ms: now, - }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; - - Ok(comment) - } - - /// FoF Layer 2: retroactively widen read+comment access on a - /// FoF-gated post the caller authored by sealing a fresh wrap slot - /// under the given V_x and appending it to the post's gating. - /// Propagates as a `FoFAccessGrant` engagement-diff. - pub async fn grant_fof_access( - &self, - post_id: PostId, - new_v_x: &[u8; 32], - ) -> anyhow::Result<()> { - use ed25519_dalek::SigningKey; - use rand::RngCore; - - // Resolve post + author + cached CEK + slot_binder_nonce. The - // author must be on this device. - let (post_author, posting_secret, cek, slot_binder_nonce) = { - let storage = self.storage.get().await; - let post = storage.get_post(&post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))?; - let gating = post.fof_gating.as_ref() - .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; - let identity = storage.get_posting_identity(&post.author)? - .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; - - // Recover the CEK: try every V_x in the author persona's - // keyring against the post's slots. The author's own slot - // will unwrap and yield CEK. - let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? - .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; - (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) - }; - - // Generate a fresh (priv_x, pub_x) keypair, seal a wrap slot - // under the new V_x with the same CEK + slot_binder_nonce. - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let signing_key = SigningKey::from_bytes(&seed); - let new_pub_x = *signing_key.verifying_key().as_bytes(); - - let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; - let new_wrap_slot = crate::types::WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }; - - let granted_at_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let author_sig = crate::fof::sign_fof_access_grant( - &posting_secret, &post_id, &new_pub_x, &new_wrap_slot, granted_at_ms, - ); - - // Apply locally first. - { - let storage = self.storage.get().await; - let _ = crate::fof::apply_fof_access_grant_locally( - &*storage, &post_id, &new_pub_x, &new_wrap_slot, - ); - } - - // Propagate. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id, - author: post_author, - ops: vec![crate::types::BlobHeaderDiffOp::FoFAccessGrant { - post_id, - new_pub_x, - new_wrap_slot, - granted_at_ms, - author_sig, - }], - timestamp_ms: now, - }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; - - Ok(()) - } - - /// FoF Layer 4: in-place wrap-slot replacement for leaked-V_me - /// scenarios. Re-seals the slot at `slot_index` under `new_v_x` - /// (typically a freshly-rotated V_me), publishes a signed - /// FoFKeyBurn diff. Local stored copy of the post mutates to - /// replace the slot. Post body remains encrypted under the - /// existing CEK (CEK isn't rotated by this op). - pub async fn key_burn_post_slot( - &self, - post_id: PostId, - slot_index: u32, - new_v_x: &[u8; 32], - ) -> anyhow::Result<()> { - use ed25519_dalek::SigningKey; - use rand::RngCore; - - let (post_author, posting_secret, cek, slot_binder_nonce) = { - let storage = self.storage.get().await; - let post = storage.get_post(&post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))?; - let gating = post.fof_gating.as_ref() - .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; - if slot_index as usize >= gating.wrap_slots.len() { - anyhow::bail!("slot_index out of bounds"); - } - let identity = storage.get_posting_identity(&post.author)? - .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; - // Recover CEK by trial-unlocking the author's own slot. - let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? - .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; - (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) - }; - - // Generate fresh per-V_x keypair, seal a new slot under new_v_x. - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let signing_key = SigningKey::from_bytes(&seed); - let new_pub_x = *signing_key.verifying_key().as_bytes(); - - let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; - let new_wrap_slot = crate::types::WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }; - - let burned_at_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let author_sig = crate::fof::sign_fof_key_burn( - &posting_secret, &post_id, slot_index, &new_pub_x, &new_wrap_slot, burned_at_ms, - ); - - // Apply locally for immediate UI update. - { - let storage = self.storage.get().await; - let _ = crate::fof::apply_fof_key_burn_locally( - &*storage, &post_id, slot_index, &new_pub_x, &new_wrap_slot, - burned_at_ms, - ); - } - - // Propagate. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id, - author: post_author, - ops: vec![crate::types::BlobHeaderDiffOp::FoFKeyBurn { - post_id, - slot_index, - new_pub_x, - new_wrap_slot, - burned_at_ms, - author_sig, - }], - timestamp_ms: now, - }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; - Ok(()) - } - /// Get the comment policy for a post. pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; @@ -5391,11 +4608,6 @@ impl Node { Ok(None) } } - // FoF Layer 3: FoFClosed posts don't use the legacy - // receipt/comment slot mechanism — they use the FoF gating's - // CEK_comments. This helper isn't used for FoF posts; - // return None so callers fall back to the FoF-specific path. - PostVisibility::FoFClosed => Ok(None), } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7d584f8..7d1b66b 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -46,14 +46,6 @@ pub fn apply_profile_post_if_applicable( } let content = verify_profile_post(post)?; - // FoF Layer 1: scan any embedded vouch-grant batch BEFORE the - // timestamp short-circuit below. A profile post that arrives older - // than what we've stored (last-writer-wins on display_name/bio) can - // still carry vouch grants we haven't seen — bio_epoch is the actual - // freshness signal for the wrapper batch, distinct from the - // post's display timestamp. - scan_vouch_grants_for_all_personas(s, &post.author, &content)?; - // Only apply if newer than the stored row (last-writer-wins by timestamp). if let Some(existing) = s.get_profile(&post.author)? { if existing.updated_at >= content.timestamp_ms { @@ -76,113 +68,14 @@ pub fn apply_profile_post_if_applicable( Ok(()) } -/// FoF Layer 1: trial-decrypt every wrapper in the post's -/// `vouch_grants` batch against every persona on this device, recording -/// successful unlocks into `vouch_keys_received`. Idempotent via the -/// `(scanner_persona, bio_author, bio_epoch)` scan cache. -/// -/// Follow-gated per the spec: skipped if the bio author is not in -/// `follows`. The manual "check this bio for a vouch for me" gesture -/// (post-Layer-1) will call a separate force-scan entrypoint. -/// -/// Self-authored posts are skipped (we already have our own V_me). -pub fn scan_vouch_grants_for_all_personas( - s: &Storage, - author: &NodeId, - content: &ProfilePostContent, -) -> anyhow::Result<()> { - let Some(batch) = &content.vouch_grants else { return Ok(()); }; - - // Skip if we authored this post. - if s.get_posting_identity(author)?.is_some() { - return Ok(()); - } - - // Follow-gate: only auto-scan bios of accounts we follow. - if !s.is_follow(author)? { - return Ok(()); - } - - let personas = s.list_posting_identities()?; - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - - for persona in &personas { - // Per-persona scan cache: skip if we already trialed this - // (scanner_persona, bio_author, bio_epoch) tuple. - if s.lookup_bio_scan_cache(&persona.node_id, author, content.bio_epoch)?.is_some() { - continue; - } - - // Derive persona's X25519 private scalar to trial-decrypt - // wrappers under the batch ephemeral pubkey. - let persona_x25519_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); - - let mut unlocked: Option = None; - for wrapper_bytes in &batch.wrappers { - if let Some(v_me) = crypto::open_vouch_grant( - &persona_x25519_priv, - &batch.batch_eph_pub, - &batch.bio_pub_nonce, - wrapper_bytes, - ) { - // This wrapper was addressed to this persona. - // Use the post's author as the source post-id field - // (informational only — the cryptographic binder is - // bio_pub_nonce inside the batch). - s.insert_received_vouch_key( - &persona.node_id, - author, - batch.v_x_epoch, - &v_me, - now_ms, - None, - )?; - unlocked = Some(batch.v_x_epoch); - // Continue iterating — a future multi-epoch batch may - // address this persona twice (different epoch wrappers - // for the same persona). Today only one epoch ships per - // batch, but the loop is correct either way. - } - } - - // FoF Layer 5: if a new V_x just landed for this persona, - // sweep the unreadable-posts queue for (persona, author) and - // re-attempt unlock. Posts that were previously not-in-set - // become readable as soon as this V_x lands. - if unlocked.is_some() { - let _ = crate::fof::sweep_unreadable_on_new_v_x(s, &persona.node_id, author); - } - - s.record_bio_scan_result( - &persona.node_id, - author, - content.bio_epoch, - unlocked, - now_ms, - )?; - } - - Ok(()) -} - /// Build a Profile post signed by the posting identity. Caller is /// responsible for storing and propagating it. -/// -/// Optional `vouch_grants` carries the FoF Layer 1 anonymous-wrapper -/// batch distributing the persona's current `V_me` to vouched personas. -/// `bio_epoch` is a monotonic per-persona counter that lets receivers -/// short-circuit re-scanning unchanged bios. pub fn build_profile_post( author: &NodeId, author_secret: &[u8; 32], display_name: &str, bio: &str, avatar_cid: Option<[u8; 32]>, - vouch_grants: Option, - bio_epoch: u32, ) -> Post { let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -195,16 +88,12 @@ pub fn build_profile_post( avatar_cid, timestamp_ms, signature, - vouch_grants, - bio_epoch, }; Post { author: *author, content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, } } @@ -214,110 +103,6 @@ pub fn profile_post_visibility() -> PostVisibility { PostVisibility::Public } -/// FoF Layer 1: build the `VouchGrantBatch` for a persona's next bio -/// publish, drawing the current `V_me` from `vouch_keys_own` and the -/// recipient list from `own_vouch_targets` (current=1 only). -/// -/// Returns `None` when the persona has no current vouch targets — the -/// bio post can be published without a vouch-grant batch in that case. -/// -/// Padding: per FoF Layer 3, the wrapper count is bucketed: power-of-2 -/// up to 256 (minimum bucket 8), then linear +128 steps. Real wrappers -/// + random-bytes dummies are shuffled together. Dummies are 48B random -/// sequences — AEAD-indistinguishable from real wrappers to outsiders. -pub fn build_vouch_grant_batch( - storage: &crate::storage::Storage, - persona_id: &NodeId, -) -> anyhow::Result> { - use rand::RngCore; - use rand::seq::SliceRandom; - - let Some((v_x_epoch, v_me)) = storage.current_own_vouch_key(persona_id)? else { - return Ok(None); - }; - let targets = storage.list_current_vouch_targets(persona_id)?; - if targets.is_empty() { - return Ok(None); - } - - let mut bio_pub_nonce = [0u8; 32]; - rand::rng().fill_bytes(&mut bio_pub_nonce); - let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); - - // Real wrappers. - let mut wrappers: Vec> = Vec::with_capacity(targets.len()); - for (_tid, x25519_pub, _at) in &targets { - let w = crypto::seal_vouch_grant(&eph_priv, x25519_pub, &bio_pub_nonce, &v_me)?; - wrappers.push(w); - } - - // Dummy padding to the next bucket. Min 8; power-of-2 to 256; then - // +128 linear steps. See FoF Layer 3 lead decisions. - let target_count = next_vouch_batch_bucket(wrappers.len()); - let mut rng = rand::rng(); - while wrappers.len() < target_count { - let mut dummy = vec![0u8; 48]; - rng.fill_bytes(&mut dummy); - wrappers.push(dummy); - } - - // Shuffle so real and dummy positions are indistinguishable. - wrappers.shuffle(&mut rng); - - Ok(Some(crate::types::VouchGrantBatch { - batch_eph_pub, - v_x_epoch, - bio_pub_nonce, - wrappers, - })) -} - -/// Bucket-pad a real wrapper count to the next allowed bucket. -/// Minimum bucket is 8 (so a single-target post still publishes 8 -/// wrappers, hiding "this persona has no vouchees" entirely). -/// Power-of-2 up to 256; linear +128 steps above 256. -pub(crate) fn next_vouch_batch_bucket(real: usize) -> usize { - if real <= 8 { return 8; } - if real <= 256 { - // smallest power of 2 >= real - let mut b = 8usize; - while b < real { b *= 2; } - return b; - } - // 384, 512, 640, ... - let above = real - 256; - let steps = (above + 127) / 128; - 256 + steps * 128 -} - -#[cfg(test)] -mod batch_padding_tests { - use super::next_vouch_batch_bucket; - - #[test] - fn buckets_match_spec() { - // Minimum floor. - assert_eq!(next_vouch_batch_bucket(0), 8); - assert_eq!(next_vouch_batch_bucket(1), 8); - assert_eq!(next_vouch_batch_bucket(7), 8); - assert_eq!(next_vouch_batch_bucket(8), 8); - - // Power-of-2 progression. - assert_eq!(next_vouch_batch_bucket(9), 16); - assert_eq!(next_vouch_batch_bucket(16), 16); - assert_eq!(next_vouch_batch_bucket(17), 32); - assert_eq!(next_vouch_batch_bucket(129), 256); - assert_eq!(next_vouch_batch_bucket(256), 256); - - // Linear +128 above 256. - assert_eq!(next_vouch_batch_bucket(257), 384); - assert_eq!(next_vouch_batch_bucket(384), 384); - assert_eq!(next_vouch_batch_bucket(385), 512); - assert_eq!(next_vouch_batch_bucket(500), 512); - assert_eq!(next_vouch_batch_bucket(513), 640); - } -} - /// Compute the `PostId` for a freshly-built profile post. pub fn profile_post_id(post: &Post) -> PostId { crate::content::compute_post_id(post) @@ -345,7 +130,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0); + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); @@ -360,7 +145,7 @@ mod tests { let (sec_b, _pub_b) = make_keypair(2); // Build a post claiming `pub_a` but signing with `sec_b`. - let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0); + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None); let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); assert!(res.is_err()); assert!(s.get_profile(&pub_a).unwrap().is_none()); @@ -372,7 +157,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0); + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None); // Hack the timestamp to make it clearly newer. let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); content.timestamp_ms = 10_000; @@ -382,7 +167,7 @@ mod tests { apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); // Apply an older profile — should be ignored. - let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0); + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None); let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); content_o.timestamp_ms = 5_000; content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); @@ -393,162 +178,4 @@ mod tests { let stored = s.get_profile(&pub_id).unwrap().unwrap(); assert_eq!(stored.display_name, "NewName"); } - - /// End-to-end Layer 1: voucher's bio post carries a VouchGrantBatch - /// addressed to the receiver's persona; receiver auto-scans on - /// apply_profile_post_if_applicable and populates vouch_keys_received. - #[test] - fn vouch_grant_end_to_end_via_bio_post() { - use crate::types::{PostingIdentity, VouchGrantBatch}; - use rand::RngCore; - - let s = temp_storage(); - - // Two personas on this device (the "receiver" device). Alice is - // the only one we're acting as; "bob" is the voucher whose bio - // post arrives. - let (alice_seed, alice_id) = make_keypair(50); - let (bob_seed, bob_id) = make_keypair(60); - - s.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, - secret_seed: alice_seed, - display_name: "Alice".into(), - created_at: 1000, - }).unwrap(); - - // Receiver-device follows the voucher; otherwise auto-scan is - // follow-gated off and would skip. - s.add_follow(&bob_id).unwrap(); - - // Build bob's V_me + the wrapper batch addressed to alice's - // persona X25519 pubkey. - let mut v_me_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_bob); - - let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); - let mut bio_pub_nonce = [0u8; 32]; - rand::rng().fill_bytes(&mut bio_pub_nonce); - let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); - - let real_wrapper = crypto::seal_vouch_grant( - &eph_priv, - &alice_x25519_pub, - &bio_pub_nonce, - &v_me_bob, - ).unwrap(); - - // Mix in some dummy wrappers to confirm the scan finds the real - // one even when most positions fail AEAD. - let mut wrappers = vec![real_wrapper]; - for _ in 0..7 { - let mut dummy = vec![0u8; 48]; - rand::rng().fill_bytes(&mut dummy); - wrappers.push(dummy); - } - - let batch = VouchGrantBatch { - batch_eph_pub, - v_x_epoch: 1, - bio_pub_nonce, - wrappers, - }; - - // Construct bob's bio post with the batch. - let timestamp_ms = 2000; - let display_name = "Bob"; - let bio = "hi"; - let signature = crypto::sign_profile(&bob_seed, display_name, bio, &None, timestamp_ms); - let content = ProfilePostContent { - display_name: display_name.to_string(), - bio: bio.to_string(), - avatar_cid: None, - timestamp_ms, - signature, - vouch_grants: Some(batch), - bio_epoch: 1, - }; - let post = Post { - author: bob_id, - content: serde_json::to_string(&content).unwrap(), - attachments: vec![], - timestamp_ms, - fof_gating: None, - supersedes_post_id: None, - }; - - // Apply. Auto-scan should fire and store the unwrapped V_me. - apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); - - // Alice's keyring should now hold V_bob at epoch 1. - let received = s.list_received_vouch_keys(&alice_id).unwrap(); - assert_eq!(received.len(), 1, "expected one received vouch"); - let (owner, epoch, key) = &received[0]; - assert_eq!(*owner, bob_id); - assert_eq!(*epoch, 1); - assert_eq!(*key, v_me_bob); - - // Scan cache should record the hit so a re-apply is a no-op - // (idempotent + cheap). - let cache = s.lookup_bio_scan_cache(&alice_id, &bob_id, 1).unwrap(); - assert_eq!(cache, Some(Some(1))); - } - - /// Same setup, but receiver-device does NOT follow the voucher. - /// Auto-scan must skip; no vouch keys recorded. - #[test] - fn vouch_grant_skipped_for_non_followed_author() { - use crate::types::{PostingIdentity, VouchGrantBatch}; - use rand::RngCore; - - let s = temp_storage(); - let (alice_seed, alice_id) = make_keypair(70); - let (bob_seed, bob_id) = make_keypair(80); - s.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, - secret_seed: alice_seed, - display_name: "Alice".into(), - created_at: 1000, - }).unwrap(); - // NOT following bob — scan must skip. - - let mut v_me_bob = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_bob); - let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); - let mut bio_pub_nonce = [0u8; 32]; - rand::rng().fill_bytes(&mut bio_pub_nonce); - let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral(); - let wrapper = crypto::seal_vouch_grant( - &eph_priv, &alice_x25519_pub, &bio_pub_nonce, &v_me_bob, - ).unwrap(); - let batch = VouchGrantBatch { - batch_eph_pub, - v_x_epoch: 1, - bio_pub_nonce, - wrappers: vec![wrapper], - }; - let timestamp_ms = 2000; - let signature = crypto::sign_profile(&bob_seed, "Bob", "", &None, timestamp_ms); - let content = ProfilePostContent { - display_name: "Bob".into(), - bio: String::new(), - avatar_cid: None, - timestamp_ms, - signature, - vouch_grants: Some(batch), - bio_epoch: 1, - }; - let post = Post { - author: bob_id, - content: serde_json::to_string(&content).unwrap(), - attachments: vec![], - timestamp_ms, - fof_gating: None, - supersedes_post_id: None, - }; - apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); - - let received = s.list_received_vouch_keys(&alice_id).unwrap(); - assert!(received.is_empty(), "non-followed author must not auto-scan"); - } } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index cee97e8..e5507ab 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -361,11 +361,6 @@ impl Storage { timestamp_ms INTEGER NOT NULL, signature BLOB NOT NULL, ref_post_id BLOB, - -- FoF Layer 2: optional comment fields (NULL on non-FoF) - pub_x_index INTEGER, - group_sig BLOB, - encrypted_payload BLOB, - deleted_at INTEGER, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); @@ -420,140 +415,6 @@ impl Storage { secret_seed BLOB NOT NULL, display_name TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL - ); - -- FoF Layer 1: per-persona V_me history. Rows are append-only - -- on rotation (Layer 4); old epochs retained for unwrapping - -- historical wrap_slots. is_current marks the active outgoing - -- key. key_material is the 32B symmetric V_me bytes. - CREATE TABLE IF NOT EXISTS vouch_keys_own ( - persona_id BLOB NOT NULL, - epoch INTEGER NOT NULL, - key_material BLOB NOT NULL, - created_at_ms INTEGER NOT NULL, - is_current INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (persona_id, epoch) - ); - CREATE INDEX IF NOT EXISTS idx_vouch_keys_own_current - ON vouch_keys_own(persona_id, is_current); - -- FoF Layer 1: per-persona keyring of received vouch keys from - -- others. holder_persona_id is whose keyring this row belongs - -- to; owner_id is the persona who issued the V_x; epoch is the - -- issuer's V_x epoch. Multi-epoch retention per Layer 4. - CREATE TABLE IF NOT EXISTS vouch_keys_received ( - holder_persona_id BLOB NOT NULL, - owner_id BLOB NOT NULL, - epoch INTEGER NOT NULL, - key_material BLOB NOT NULL, - received_at_ms INTEGER NOT NULL, - source_bio_post_id BLOB, - PRIMARY KEY (holder_persona_id, owner_id, epoch) - ); - CREATE INDEX IF NOT EXISTS idx_vouch_keys_received_owner - ON vouch_keys_received(holder_persona_id, owner_id); - -- FoF Layer 1: short-circuit cache for re-scanning bio posts - -- that haven't changed. bio_epoch is the issuer's bio-post - -- revision counter. result=1 means a wrapper unlocked; 0 means - -- nothing for this persona. - CREATE TABLE IF NOT EXISTS vouch_bio_scan_cache ( - scanner_persona_id BLOB NOT NULL, - bio_author_id BLOB NOT NULL, - bio_epoch INTEGER NOT NULL, - result INTEGER NOT NULL, - unlocked_v_x_epoch INTEGER, - scanned_at_ms INTEGER NOT NULL, - PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch) - ); - -- FoF Layer 1: author-local record of who this persona has - -- vouched for. Never on the wire. Drives bio-post wrapper - -- batch assembly. current=1 means the target is in the latest - -- batch; current=0 means they were removed (revoked). - CREATE TABLE IF NOT EXISTS own_vouch_targets ( - voucher_persona_id BLOB NOT NULL, - target_persona_id BLOB NOT NULL, - target_x25519_pub BLOB NOT NULL, - granted_at_ms INTEGER NOT NULL, - current INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (voucher_persona_id, target_persona_id) - ); - -- FoF Layer 2: per-post revocations applied locally as - -- revocation diffs propagate through the CDN. The post's - -- own fof_gating.revocation_list is the t=0 snapshot - -- (usually empty); this table is the live accumulated - -- state. CDN-verify rejects any comment whose - -- pub_post_set[pub_x_index] appears here for this post. - CREATE TABLE IF NOT EXISTS fof_revocations ( - post_id BLOB NOT NULL, - revoked_pub_x BLOB NOT NULL, - revoked_at_ms INTEGER NOT NULL, - reason_code INTEGER NOT NULL DEFAULT 0, - author_sig BLOB NOT NULL, - PRIMARY KEY (post_id, revoked_pub_x) - ); - CREATE INDEX IF NOT EXISTS idx_fof_revocations_post - ON fof_revocations(post_id); - -- FoF Layer 4: author-local map of which V_x sealed which - -- slot on which of MY posts. Never on the wire. Used at - -- cascade-revocation time to find pub_x's that need to be - -- revoked when a V_me epoch is retired. - CREATE TABLE IF NOT EXISTS own_post_slot_provenance ( - author_persona_id BLOB NOT NULL, - post_id BLOB NOT NULL, - slot_index INTEGER NOT NULL, - sealed_under_v_x_owner BLOB NOT NULL, - sealed_under_v_x_epoch INTEGER NOT NULL, - pub_x BLOB NOT NULL, - PRIMARY KEY (author_persona_id, post_id, slot_index) - ); - CREATE INDEX IF NOT EXISTS idx_own_post_slot_provenance_v_x - ON own_post_slot_provenance(author_persona_id, sealed_under_v_x_owner, sealed_under_v_x_epoch); - -- FoF Layer 5: per-(reader_persona, author) winning-V_x cache. - -- First successful unlock from an author is remembered; subsequent - -- posts from the same author try the cached V_x first. Cuts the - -- hot-path trial-decrypt cost to ~1 HMAC + 1 AEAD attempt. - CREATE TABLE IF NOT EXISTS vouch_unlock_cache ( - reader_persona_id BLOB NOT NULL, - author_id BLOB NOT NULL, - winning_v_x_owner BLOB NOT NULL, - winning_v_x_epoch INTEGER NOT NULL, - last_hit_ms INTEGER NOT NULL, - hit_count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (reader_persona_id, author_id) - ); - -- FoF Layer 5: queue of FoF posts no held V_x currently unlocks. - -- Swept on V_x arrival; on success the post moves to - -- vouch_unlock_cache + the row is removed. - CREATE TABLE IF NOT EXISTS vouch_unreadable_posts ( - reader_persona_id BLOB NOT NULL, - post_id BLOB NOT NULL, - author_id BLOB NOT NULL, - first_seen_ms INTEGER NOT NULL, - last_attempt_ms INTEGER NOT NULL, - PRIMARY KEY (reader_persona_id, post_id) - ); - CREATE INDEX IF NOT EXISTS idx_vouch_unreadable_author - ON vouch_unreadable_posts(reader_persona_id, author_id); - -- FoF Layer 5: author-direct fast path. Authors cache the CEK - -- + slot_binder_nonce for posts they authored so they can - -- decrypt without trial-unlocking via wrap_slots. Populated at - -- post-publish time. - CREATE TABLE IF NOT EXISTS own_fof_post_ceks ( - author_persona_id BLOB NOT NULL, - post_id BLOB NOT NULL, - cek BLOB NOT NULL, - slot_binder_nonce BLOB NOT NULL, - PRIMARY KEY (author_persona_id, post_id) - ); - -- FoF Layer 4 hardening: per-slot key-burn monotonic - -- timestamp. Receivers refuse to apply key-burns with an - -- older timestamp than the most recent already applied at - -- that slot — prevents replay of older signed diffs from - -- reverting newer state. - CREATE TABLE IF NOT EXISTS fof_key_burns ( - post_id BLOB NOT NULL, - slot_index INTEGER NOT NULL, - burned_at_ms INTEGER NOT NULL, - new_pub_x BLOB NOT NULL, - PRIMARY KEY (post_id, slot_index) );", )?; Ok(()) @@ -928,32 +789,6 @@ impl Storage { // 0.6.2-beta: seed post_recipients index from existing encrypted posts. self.seed_post_recipients_from_posts()?; - // FoF Layer 2: add comment columns for pub_x_index / group_sig / - // encrypted_payload. Old DBs have NULL → deserializes to None. - let has_comment_pub_x = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='pub_x_index'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_comment_pub_x == 0 { - self.conn.execute_batch( - "ALTER TABLE comments ADD COLUMN pub_x_index INTEGER; - ALTER TABLE comments ADD COLUMN group_sig BLOB; - ALTER TABLE comments ADD COLUMN encrypted_payload BLOB;" - )?; - } - - // FoF Layer 2: post.fof_gating is serialized as JSON in a new - // column so we can rehydrate the gating block on receive paths - // (CDN verify needs pub_post_set / revocation_list). Stored - // alongside the existing post fields. - let has_post_fof = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('posts') WHERE name='fof_gating_json'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_post_fof == 0 { - self.conn.execute_batch( - "ALTER TABLE posts ADD COLUMN fof_gating_json TEXT;" - )?; - } - Ok(()) } @@ -973,13 +808,8 @@ impl Storage { ) -> anyhow::Result { let attachments_json = serde_json::to_string(&post.attachments)?; let visibility_json = serde_json::to_string(visibility)?; - let fof_json = match &post.fof_gating { - Some(g) => Some(serde_json::to_string(g)?), - None => None, - }; let inserted = self.conn.execute( - "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, fof_gating_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ id.as_slice(), post.author.as_slice(), @@ -987,7 +817,6 @@ impl Storage { attachments_json, post.timestamp_ms as i64, visibility_json, - fof_json, ], )?; if inserted > 0 { @@ -997,33 +826,19 @@ impl Storage { } pub fn get_post(&self, id: &PostId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT author, content, attachments, timestamp_ms, fof_gating_json - FROM posts WHERE id = ?1", - )?; + let mut stmt = self + .conn + .prepare("SELECT author, content, attachments, timestamp_ms FROM posts WHERE id = ?1")?; let mut rows = stmt.query(params![id.as_slice()])?; if let Some(row) = rows.next()? { let attachments: Vec = serde_json::from_str( &row.get::<_, String>(2)? ).unwrap_or_default(); - let fof_json: Option = row.get(4)?; - let fof_gating = fof_json - .and_then(|s| serde_json::from_str::(&s).ok()); Ok(Some(Post { author: blob_to_nodeid(row.get(0)?)?, content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, - fof_gating, - // FoF Layer 4: supersedes_post_id is not persisted as a - // dedicated column today; it would arrive in the JSON - // form via fof_gating_json on re-issued posts. For now, - // get_post returns None and re-issue UX surfaces it - // when present in the in-memory Post (Layer 4 re-issue - // helper sets it inline). A follow-up can add a - // dedicated column if/when receivers need to render the - // supersedes pointer in feeds. - supersedes_post_id: None, })) } else { Ok(None) @@ -1035,8 +850,7 @@ impl Storage { id: &PostId, ) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, content, attachments, timestamp_ms, visibility, fof_gating_json - FROM posts WHERE id = ?1", + "SELECT author, content, attachments, timestamp_ms, visibility FROM posts WHERE id = ?1", )?; let mut rows = stmt.query(params![id.as_slice()])?; if let Some(row) = rows.next()? { @@ -1045,17 +859,12 @@ impl Storage { let vis_json: String = row.get(4)?; let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default(); - let fof_json: Option = row.get(5)?; - let fof_gating = fof_json - .and_then(|s| serde_json::from_str::(&s).ok()); Ok(Some(( Post { author: blob_to_nodeid(row.get(0)?)?, content: row.get(1)?, attachments, timestamp_ms: row.get::<_, i64>(3)? as u64, - fof_gating, - supersedes_post_id: None, }, visibility, ))) @@ -1149,8 +958,6 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, - fof_gating: None, - supersedes_post_id: None, }, visibility, )); @@ -1189,8 +996,6 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, - fof_gating: None, - supersedes_post_id: None, }, visibility, )); @@ -1353,8 +1158,6 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, - fof_gating: None, - supersedes_post_id: None, }, visibility, )); @@ -1390,8 +1193,6 @@ impl Storage { content, attachments, timestamp_ms: timestamp_ms as u64, - fof_gating: None, - supersedes_post_id: None, }, visibility, )); @@ -1425,14 +1226,6 @@ impl Storage { Ok(()) } - /// Cheap membership check against the `follows` table. - pub fn is_follow(&self, node_id: &NodeId) -> anyhow::Result { - let n: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM follows WHERE node_id = ?1", - )?.query_row(params![node_id.as_slice()], |row| row.get(0))?; - Ok(n > 0) - } - pub fn remove_follow(&self, node_id: &NodeId) -> anyhow::Result<()> { self.conn.execute( "DELETE FROM follows WHERE node_id = ?1", @@ -2984,14 +2777,8 @@ impl Storage { let attachments_json = serde_json::to_string(&post.attachments)?; let visibility_json = serde_json::to_string(visibility)?; let intent_json = serde_json::to_string(intent)?; - let fof_json = match &post.fof_gating { - Some(g) => Some(serde_json::to_string(g)?), - None => None, - }; let inserted = self.conn.execute( - "INSERT OR IGNORE INTO posts - (id, author, content, attachments, timestamp_ms, visibility, visibility_intent, fof_gating_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT OR IGNORE INTO posts (id, author, content, attachments, timestamp_ms, visibility, visibility_intent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ id.as_slice(), post.author.as_slice(), @@ -3000,7 +2787,6 @@ impl Storage { post.timestamp_ms as i64, visibility_json, intent_json, - fof_json, ], )?; if inserted > 0 { @@ -3078,8 +2864,6 @@ impl Storage { content: row.get(2)?, attachments, timestamp_ms: row.get::<_, i64>(4)? as u64, - fof_gating: None, - supersedes_post_id: None, }, visibility, )); @@ -4623,10 +4407,6 @@ impl Storage { } Ok(()) } - // FoF Layer 3: FoFClosed posts have no per-recipient - // identifiers on the wire (audience is implicit in the - // wrap_slots). No index to populate. - PostVisibility::FoFClosed => Ok(()), } } @@ -4806,826 +4586,6 @@ impl Storage { Ok(n as u64) } - // --- FoF Layer 1: Vouch keys (own + received) --- - - /// Insert a new V_me epoch for a persona. Marks it current; older - /// epochs are flipped to non-current. Append-only — old epochs are - /// never deleted by rotation (see Layer 4). - pub fn insert_own_vouch_key( - &self, - persona_id: &NodeId, - epoch: u32, - key_material: &[u8; 32], - created_at_ms: u64, - ) -> anyhow::Result<()> { - let tx = self.conn.unchecked_transaction()?; - tx.execute( - "UPDATE vouch_keys_own SET is_current = 0 WHERE persona_id = ?1", - params![persona_id.as_slice()], - )?; - tx.execute( - "INSERT OR REPLACE INTO vouch_keys_own - (persona_id, epoch, key_material, created_at_ms, is_current) - VALUES (?1, ?2, ?3, ?4, 1)", - params![ - persona_id.as_slice(), - epoch as i64, - key_material.as_slice(), - created_at_ms as i64, - ], - )?; - tx.commit()?; - Ok(()) - } - - /// Return the persona's current V_me as `(epoch, key)`, or None if not set. - pub fn current_own_vouch_key( - &self, - persona_id: &NodeId, - ) -> anyhow::Result> { - let result = self.conn.query_row( - "SELECT epoch, key_material FROM vouch_keys_own - WHERE persona_id = ?1 AND is_current = 1", - params![persona_id.as_slice()], - |row| { - let epoch: i64 = row.get(0)?; - let key: Vec = row.get(1)?; - Ok((epoch, key)) - }, - ); - match result { - Ok((epoch, key_bytes)) => { - let key: [u8; 32] = key_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; - Ok(Some((epoch as u32, key))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Return all V_me epochs for a persona (current + retained past). - /// Sorted newest-first. Used at unwrap time (try newest first) and - /// when a sender needs to publish multi-epoch grants. - pub fn list_own_vouch_keys( - &self, - persona_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT epoch, key_material FROM vouch_keys_own - WHERE persona_id = ?1 ORDER BY epoch DESC", - )?; - let rows = stmt.query_map(params![persona_id.as_slice()], |row| { - let epoch: i64 = row.get(0)?; - let key: Vec = row.get(1)?; - Ok((epoch, key)) - })?; - let mut out = Vec::new(); - for r in rows { - let (epoch, key_bytes) = r?; - let key: [u8; 32] = key_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid vouch key length"))?; - out.push((epoch as u32, key)); - } - Ok(out) - } - - /// Insert a received vouch key into a persona's keyring. Idempotent - /// on `(holder, owner, epoch)`. - pub fn insert_received_vouch_key( - &self, - holder_persona_id: &NodeId, - owner_id: &NodeId, - epoch: u32, - key_material: &[u8; 32], - received_at_ms: u64, - source_bio_post_id: Option<&[u8; 32]>, - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR IGNORE INTO vouch_keys_received - (holder_persona_id, owner_id, epoch, key_material, received_at_ms, source_bio_post_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - holder_persona_id.as_slice(), - owner_id.as_slice(), - epoch as i64, - key_material.as_slice(), - received_at_ms as i64, - source_bio_post_id.map(|b| b.as_slice()), - ], - )?; - Ok(()) - } - - /// Return the full received-vouch keyring for a persona. Each row is - /// `(owner_id, epoch, key_material)`. Trial-unwrap iterates the result. - pub fn list_received_vouch_keys( - &self, - holder_persona_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT owner_id, epoch, key_material FROM vouch_keys_received - WHERE holder_persona_id = ?1 - ORDER BY owner_id, epoch DESC", - )?; - let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { - let owner: Vec = row.get(0)?; - let epoch: i64 = row.get(1)?; - let key: Vec = row.get(2)?; - Ok((owner, epoch, key)) - })?; - let mut out = Vec::new(); - for r in rows { - let (owner_bytes, epoch, key_bytes) = r?; - let owner: NodeId = owner_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid owner_id in vouch_keys_received"))?; - let key: [u8; 32] = key_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid key in vouch_keys_received"))?; - out.push((owner, epoch as u32, key)); - } - Ok(out) - } - - /// List distinct owners that have vouched for a persona (for UI - /// "Who has vouched for me"). Latest epoch per owner. - pub fn list_vouchers_for( - &self, - holder_persona_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT owner_id, MAX(epoch), MAX(received_at_ms) - FROM vouch_keys_received - WHERE holder_persona_id = ?1 - GROUP BY owner_id", - )?; - let rows = stmt.query_map(params![holder_persona_id.as_slice()], |row| { - let owner: Vec = row.get(0)?; - let epoch: i64 = row.get(1)?; - let at: i64 = row.get(2)?; - Ok((owner, epoch, at)) - })?; - let mut out = Vec::new(); - for r in rows { - let (owner_bytes, epoch, at) = r?; - let owner: NodeId = owner_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid owner_id"))?; - out.push((owner, epoch as u32, at as u64)); - } - Ok(out) - } - - /// Lookup a scan-cache entry. Returns Some(unlocked_epoch) if the - /// cached result was a hit (Some(None) means the row exists as a miss). - /// Returns None if no cache row exists (scan needed). - pub fn lookup_bio_scan_cache( - &self, - scanner_persona_id: &NodeId, - bio_author_id: &NodeId, - bio_epoch: u32, - ) -> anyhow::Result>> { - let result = self.conn.query_row( - "SELECT result, unlocked_v_x_epoch FROM vouch_bio_scan_cache - WHERE scanner_persona_id = ?1 AND bio_author_id = ?2 AND bio_epoch = ?3", - params![ - scanner_persona_id.as_slice(), - bio_author_id.as_slice(), - bio_epoch as i64, - ], - |row| { - let result: i64 = row.get(0)?; - let unlocked: Option = row.get(1)?; - Ok((result, unlocked)) - }, - ); - match result { - Ok((res, unlocked)) => { - if res == 1 { - Ok(Some(unlocked.map(|e| e as u32))) - } else { - Ok(Some(None)) - } - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Record a scan-cache hit (result=1) or miss (result=0). - pub fn record_bio_scan_result( - &self, - scanner_persona_id: &NodeId, - bio_author_id: &NodeId, - bio_epoch: u32, - unlocked_v_x_epoch: Option, - scanned_at_ms: u64, - ) -> anyhow::Result<()> { - let result_flag: i64 = if unlocked_v_x_epoch.is_some() { 1 } else { 0 }; - self.conn.execute( - "INSERT OR REPLACE INTO vouch_bio_scan_cache - (scanner_persona_id, bio_author_id, bio_epoch, result, unlocked_v_x_epoch, scanned_at_ms) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - scanner_persona_id.as_slice(), - bio_author_id.as_slice(), - bio_epoch as i64, - result_flag, - unlocked_v_x_epoch.map(|e| e as i64), - scanned_at_ms as i64, - ], - )?; - Ok(()) - } - - /// Upsert an outbound vouch target for a persona. `current=1` means - /// it'll be wrapped into the next bio-post batch. - pub fn upsert_vouch_target( - &self, - voucher_persona_id: &NodeId, - target_persona_id: &NodeId, - target_x25519_pub: &[u8; 32], - granted_at_ms: u64, - current: bool, - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT INTO own_vouch_targets - (voucher_persona_id, target_persona_id, target_x25519_pub, granted_at_ms, current) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(voucher_persona_id, target_persona_id) DO UPDATE SET - target_x25519_pub = excluded.target_x25519_pub, - current = excluded.current", - params![ - voucher_persona_id.as_slice(), - target_persona_id.as_slice(), - target_x25519_pub.as_slice(), - granted_at_ms as i64, - current as i64, - ], - )?; - Ok(()) - } - - /// List current outbound vouch targets for a persona. - pub fn list_current_vouch_targets( - &self, - voucher_persona_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT target_persona_id, target_x25519_pub, granted_at_ms - FROM own_vouch_targets - WHERE voucher_persona_id = ?1 AND current = 1 - ORDER BY granted_at_ms ASC", - )?; - let rows = stmt.query_map(params![voucher_persona_id.as_slice()], |row| { - let tid: Vec = row.get(0)?; - let xpub: Vec = row.get(1)?; - let at: i64 = row.get(2)?; - Ok((tid, xpub, at)) - })?; - let mut out = Vec::new(); - for r in rows { - let (tid_bytes, xpub_bytes, at) = r?; - let tid: NodeId = tid_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid target_persona_id"))?; - let xpub: [u8; 32] = xpub_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid target_x25519_pub"))?; - out.push((tid, xpub, at as u64)); - } - Ok(out) - } - - /// Mark a vouch target as no longer current (soft revoke; row retained - /// so the audit trail / cascade-pickup is preserved). - pub fn revoke_vouch_target( - &self, - voucher_persona_id: &NodeId, - target_persona_id: &NodeId, - ) -> anyhow::Result<()> { - self.conn.execute( - "UPDATE own_vouch_targets SET current = 0 - WHERE voucher_persona_id = ?1 AND target_persona_id = ?2", - params![ - voucher_persona_id.as_slice(), - target_persona_id.as_slice(), - ], - )?; - Ok(()) - } - - // --- FoF Layer 5: unlock cache + retry queue --- - - /// Look up the cached winning V_x for a `(reader_persona, author)` - /// pair, if any. Returns `(owner, epoch)` of the V_x that last - /// successfully unlocked. Hot-path optimization for repeated reads - /// from the same author. - pub fn lookup_unlock_cache( - &self, - reader_persona_id: &NodeId, - author_id: &NodeId, - ) -> anyhow::Result> { - let result = self.conn.query_row( - "SELECT winning_v_x_owner, winning_v_x_epoch - FROM vouch_unlock_cache - WHERE reader_persona_id = ?1 AND author_id = ?2", - params![reader_persona_id.as_slice(), author_id.as_slice()], - |row| { - let owner: Vec = row.get(0)?; - let epoch: i64 = row.get(1)?; - Ok((owner, epoch)) - }, - ); - match result { - Ok((owner_bytes, epoch)) => { - let owner: NodeId = owner_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid winning_v_x_owner"))?; - Ok(Some((owner, epoch as u32))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Record a successful unlock in the cache. Bumps hit_count when - /// the cached entry matches; otherwise replaces it. - pub fn record_unlock_hit( - &self, - reader_persona_id: &NodeId, - author_id: &NodeId, - winning_v_x_owner: &NodeId, - winning_v_x_epoch: u32, - now_ms: u64, - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT INTO vouch_unlock_cache - (reader_persona_id, author_id, winning_v_x_owner, - winning_v_x_epoch, last_hit_ms, hit_count) - VALUES (?1, ?2, ?3, ?4, ?5, 1) - ON CONFLICT(reader_persona_id, author_id) DO UPDATE SET - winning_v_x_owner = excluded.winning_v_x_owner, - winning_v_x_epoch = excluded.winning_v_x_epoch, - last_hit_ms = excluded.last_hit_ms, - hit_count = CASE - WHEN winning_v_x_owner = excluded.winning_v_x_owner - AND winning_v_x_epoch = excluded.winning_v_x_epoch - THEN hit_count + 1 - ELSE 1 - END", - params![ - reader_persona_id.as_slice(), - author_id.as_slice(), - winning_v_x_owner.as_slice(), - winning_v_x_epoch as i64, - now_ms as i64, - ], - )?; - Ok(()) - } - - /// Per-persona cap on `vouch_unreadable_posts` queue size. Prevents - /// a spam attacker from filling the queue with O(N) FoF posts we - /// can't unlock — each subsequent V_x arrival would sweep the - /// entire queue. At 4096 entries × ~3.8 AEAD attempts per post per - /// V_x arrival, sweep cost is bounded to milliseconds. - const MAX_UNREADABLE_PER_PERSONA: i64 = 4096; - - /// Test-only accessor for the cap constant. - #[cfg(test)] - #[doc(hidden)] - pub fn max_unreadable_per_persona_for_test() -> i64 { - Self::MAX_UNREADABLE_PER_PERSONA - } - - /// Mark a post as unreadable by `reader_persona`. Swept later when - /// a new V_x arrives in the persona's keyring. Bounded per-persona - /// to prevent attacker-driven queue growth. - pub fn record_unreadable_post( - &self, - reader_persona_id: &NodeId, - post_id: &PostId, - author_id: &NodeId, - now_ms: u64, - ) -> anyhow::Result<()> { - // Bound check. If the persona already has the cap, drop the - // new entry silently. The post is still propagated via CDN to - // peers; it just doesn't enter THIS persona's retry queue. - let existing: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM vouch_unreadable_posts - WHERE reader_persona_id = ?1", - )?.query_row(params![reader_persona_id.as_slice()], |row| row.get(0))?; - if existing >= Self::MAX_UNREADABLE_PER_PERSONA { - // Allow re-touching an existing row's last_attempt_ms, but - // refuse to INSERT a new one. - self.conn.execute( - "UPDATE vouch_unreadable_posts - SET last_attempt_ms = ?3 - WHERE reader_persona_id = ?1 AND post_id = ?2", - params![ - reader_persona_id.as_slice(), - post_id.as_slice(), - now_ms as i64, - ], - )?; - return Ok(()); - } - self.conn.execute( - "INSERT INTO vouch_unreadable_posts - (reader_persona_id, post_id, author_id, first_seen_ms, last_attempt_ms) - VALUES (?1, ?2, ?3, ?4, ?4) - ON CONFLICT(reader_persona_id, post_id) DO UPDATE SET - last_attempt_ms = excluded.last_attempt_ms", - params![ - reader_persona_id.as_slice(), - post_id.as_slice(), - author_id.as_slice(), - now_ms as i64, - ], - )?; - Ok(()) - } - - /// List unreadable posts queued for a `(reader_persona, author)` - /// pair. Used for narrow retries; sweep on V_x arrival uses - /// `list_all_unreadable_posts` since the new V_x may unlock posts - /// authored by anyone (not just the V_x's issuer). - pub fn list_unreadable_posts_for_author( - &self, - reader_persona_id: &NodeId, - author_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT post_id FROM vouch_unreadable_posts - WHERE reader_persona_id = ?1 AND author_id = ?2", - )?; - let rows = stmt.query_map(params![ - reader_persona_id.as_slice(), - author_id.as_slice(), - ], |row| { - let b: Vec = row.get(0)?; - Ok(b) - })?; - let mut out = Vec::new(); - for r in rows { - let b = r?; - let pid: PostId = b.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid post_id in unreadable"))?; - out.push(pid); - } - Ok(out) - } - - /// List ALL unreadable posts for a reader persona. Used by the - /// V_x-arrival sweep — the new V_x can unlock posts by any author - /// (the V_x's owner could be a chain-link in the author's keyring, - /// not the author themselves). - pub fn list_all_unreadable_posts( - &self, - reader_persona_id: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT post_id FROM vouch_unreadable_posts - WHERE reader_persona_id = ?1", - )?; - let rows = stmt.query_map(params![reader_persona_id.as_slice()], |row| { - let b: Vec = row.get(0)?; - Ok(b) - })?; - let mut out = Vec::new(); - for r in rows { - let b = r?; - let pid: PostId = b.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid post_id in unreadable"))?; - out.push(pid); - } - Ok(out) - } - - /// Remove a post from the unreadable queue (called after a - /// successful unlock). - pub fn clear_unreadable_post( - &self, - reader_persona_id: &NodeId, - post_id: &PostId, - ) -> anyhow::Result<()> { - self.conn.execute( - "DELETE FROM vouch_unreadable_posts - WHERE reader_persona_id = ?1 AND post_id = ?2", - params![reader_persona_id.as_slice(), post_id.as_slice()], - )?; - Ok(()) - } - - /// FoF Layer 5: cache the post's CEK + slot_binder_nonce for - /// author-direct decrypt later. Populated at publish time so - /// authors skip the wrap-slot trial entirely when reading their - /// own posts. - pub fn cache_own_fof_post_cek( - &self, - author_persona_id: &NodeId, - post_id: &PostId, - cek: &[u8; 32], - slot_binder_nonce: &[u8; 32], - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR REPLACE INTO own_fof_post_ceks - (author_persona_id, post_id, cek, slot_binder_nonce) - VALUES (?1, ?2, ?3, ?4)", - params![ - author_persona_id.as_slice(), - post_id.as_slice(), - cek.as_slice(), - slot_binder_nonce.as_slice(), - ], - )?; - Ok(()) - } - - /// FoF Layer 5: look up the cached CEK + slot_binder_nonce for an - /// author's own post. Returns `None` for posts not authored on this - /// device (or pre-Layer-5 posts whose CEK wasn't cached). - pub fn lookup_own_fof_post_cek( - &self, - author_persona_id: &NodeId, - post_id: &PostId, - ) -> anyhow::Result> { - let result = self.conn.query_row( - "SELECT cek, slot_binder_nonce FROM own_fof_post_ceks - WHERE author_persona_id = ?1 AND post_id = ?2", - params![author_persona_id.as_slice(), post_id.as_slice()], - |row| { - let cek: Vec = row.get(0)?; - let nonce: Vec = row.get(1)?; - Ok((cek, nonce)) - }, - ); - match result { - Ok((cek_bytes, nonce_bytes)) => { - let cek: [u8; 32] = cek_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid cached cek"))?; - let nonce: [u8; 32] = nonce_bytes.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid cached slot_binder_nonce"))?; - Ok(Some((cek, nonce))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// FoF Layer 4: record which V_x sealed which slot on one of the - /// author's posts. Called at post-publish time. Used at - /// cascade-revocation time to find the pub_x's that need revoking - /// when a V_me epoch is retired. - pub fn record_post_slot_provenance( - &self, - author_persona_id: &NodeId, - post_id: &PostId, - slot_index: u32, - sealed_under_v_x_owner: &NodeId, - sealed_under_v_x_epoch: u32, - pub_x: &[u8; 32], - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR REPLACE INTO own_post_slot_provenance - (author_persona_id, post_id, slot_index, - sealed_under_v_x_owner, sealed_under_v_x_epoch, pub_x) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - author_persona_id.as_slice(), - post_id.as_slice(), - slot_index as i64, - sealed_under_v_x_owner.as_slice(), - sealed_under_v_x_epoch as i64, - pub_x.as_slice(), - ], - )?; - Ok(()) - } - - /// FoF Layer 4: list (post_id, pub_x) pairs where the slot was - /// sealed under the given V_x owner+epoch. Used by cascade - /// revocation to identify pub_x's to revoke when retiring an epoch. - pub fn list_provenance_for_v_x_epoch( - &self, - author_persona_id: &NodeId, - owner: &NodeId, - epoch: u32, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT post_id, pub_x, slot_index FROM own_post_slot_provenance - WHERE author_persona_id = ?1 - AND sealed_under_v_x_owner = ?2 - AND sealed_under_v_x_epoch = ?3", - )?; - let rows = stmt.query_map(params![ - author_persona_id.as_slice(), - owner.as_slice(), - epoch as i64, - ], |row| { - let pid: Vec = row.get(0)?; - let pub_x: Vec = row.get(1)?; - let idx: i64 = row.get(2)?; - Ok((pid, pub_x, idx)) - })?; - let mut out = Vec::new(); - for r in rows { - let (pid, pub_x, idx) = r?; - let pid: PostId = pid.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid post_id in provenance"))?; - let pub_x: [u8; 32] = pub_x.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid pub_x in provenance"))?; - out.push((pid, pub_x, idx as u32)); - } - Ok(out) - } - - /// FoF Layer 4: replace a wrap_slot + pub_x at `slot_index` in a - /// stored post's fof_gating. Local-only mutation; PostId - /// (in the `id` column) is unaffected. Monotonic guard: refuses - /// to apply if a more-recent key-burn at this slot has already - /// been recorded — prevents an attacker from replaying an older - /// signed key-burn to revert a more recent one. - /// Returns true on success, false on bounds error / not-found / - /// stale-timestamp rejection. - pub fn replace_fof_slot( - &self, - post_id: &PostId, - slot_index: u32, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - burned_at_ms: u64, - ) -> anyhow::Result { - let Some(mut post) = self.get_post(post_id)? else { return Ok(false); }; - let Some(mut gating) = post.fof_gating.take() else { return Ok(false); }; - let idx = slot_index as usize; - if idx >= gating.pub_post_set.len() || idx >= gating.wrap_slots.len() { - return Ok(false); - } - // Monotonic guard: reject burns older than the most recent we've - // applied at this slot. Without this, an attacker replaying an - // older signed key-burn diff could revert a newer one. - let last_burn: Option = self.conn.query_row( - "SELECT MAX(burned_at_ms) FROM fof_key_burns - WHERE post_id = ?1 AND slot_index = ?2", - params![post_id.as_slice(), slot_index as i64], - |row| row.get(0), - ).ok(); - if let Some(Some(last_ts)) = last_burn.map(|v| if v == 0 { None } else { Some(v) }) { - if (burned_at_ms as i64) <= last_ts { - return Ok(false); - } - } - gating.pub_post_set[idx] = *new_pub_x; - gating.wrap_slots[idx] = new_wrap_slot.clone(); - let gating_json = serde_json::to_string(&gating)?; - let tx = self.conn.unchecked_transaction()?; - tx.execute( - "UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2", - params![gating_json, post_id.as_slice()], - )?; - // Record the burn for monotonicity on the next attempt. - tx.execute( - "INSERT INTO fof_key_burns - (post_id, slot_index, burned_at_ms, new_pub_x) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(post_id, slot_index) DO UPDATE SET - burned_at_ms = MAX(burned_at_ms, excluded.burned_at_ms), - new_pub_x = CASE - WHEN excluded.burned_at_ms > burned_at_ms - THEN excluded.new_pub_x ELSE new_pub_x END", - params![ - post_id.as_slice(), - slot_index as i64, - burned_at_ms as i64, - new_pub_x.as_slice(), - ], - )?; - tx.commit()?; - Ok(true) - } - - /// FoF Layer 2: append a new (pub_x, wrap_slot) entry to a stored - /// post's fof_gating. Local-only mutation; PostId (in the `id` - /// column) is unaffected. Idempotent on `(post_id, new_pub_x)`. - pub fn append_fof_access_grant( - &self, - post_id: &PostId, - new_pub_x: &[u8; 32], - new_wrap_slot: &crate::types::WrapSlot, - ) -> anyhow::Result { - let Some(mut post) = self.get_post(post_id)? else { return Ok(false); }; - let Some(mut gating) = post.fof_gating.take() else { return Ok(false); }; - if gating.pub_post_set.iter().any(|p| p == new_pub_x) { - return Ok(false); // already present - } - gating.pub_post_set.push(*new_pub_x); - gating.wrap_slots.push(new_wrap_slot.clone()); - let gating_json = serde_json::to_string(&gating)?; - self.conn.execute( - "UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2", - params![gating_json, post_id.as_slice()], - )?; - Ok(true) - } - - /// FoF Layer 2: record a post-level revocation locally. Idempotent - /// on `(post_id, revoked_pub_x)`. Subsequent incoming comments - /// where `pub_post_set[pub_x_index] == revoked_pub_x` are rejected - /// at the CDN verify step (see `is_fof_pub_x_revoked`). - /// - /// Retroactive delete of already-stored comments under the revoked - /// pub_x is handled by `delete_fof_comments_by_pub_x`, which the - /// receive path calls after applying the revocation (it has the - /// post + pub_post_set context required to resolve pub_x_index → - /// pub_x bytes). - pub fn add_fof_revocation( - &self, - post_id: &PostId, - revoked_pub_x: &[u8; 32], - revoked_at_ms: u64, - reason_code: u8, - author_sig: &[u8], - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR IGNORE INTO fof_revocations - (post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - post_id.as_slice(), - revoked_pub_x.as_slice(), - revoked_at_ms as i64, - reason_code as i64, - author_sig, - ], - )?; - Ok(()) - } - - /// FoF Layer 2: delete locally-stored comments on a post whose - /// `pub_x_index` matches the given index. Returns the number of - /// rows deleted. Called by the receive path after applying a - /// revocation (the index → pub_x_bytes resolution happens in the - /// caller via `pub_post_set`). - pub fn delete_fof_comments_by_pub_x_index( - &self, - post_id: &PostId, - pub_x_index: u32, - ) -> anyhow::Result { - let n = self.conn.execute( - "DELETE FROM comments - WHERE post_id = ?1 AND pub_x_index = ?2", - params![post_id.as_slice(), pub_x_index as i64], - )?; - Ok(n) - } - - /// FoF Layer 2: is the given pub_x revoked for this post? - pub fn is_fof_pub_x_revoked( - &self, - post_id: &PostId, - pub_x: &[u8; 32], - ) -> anyhow::Result { - let n: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM fof_revocations - WHERE post_id = ?1 AND revoked_pub_x = ?2", - )?.query_row( - params![post_id.as_slice(), pub_x.as_slice()], - |row| row.get(0), - )?; - Ok(n > 0) - } - - /// FoF Layer 2: list all revoked pub_x's for a post (for the CDN - /// four-check; cheaper than a per-comment query when verifying many - /// comments in a row). - pub fn list_fof_revocations(&self, post_id: &PostId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT revoked_pub_x FROM fof_revocations WHERE post_id = ?1", - )?; - let rows = stmt.query_map(params![post_id.as_slice()], |row| { - let b: Vec = row.get(0)?; - Ok(b) - })?; - let mut out = Vec::new(); - for r in rows { - let b = r?; - let arr: [u8; 32] = b.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid revoked_pub_x in storage"))?; - out.push(arr); - } - Ok(out) - } - - /// Increment and return the next bio-publish epoch for a persona. - /// Counter is monotonic; used by receivers' scan cache to short-circuit - /// re-scanning unchanged bios. Stored in `settings` keyed by persona. - pub fn next_bio_epoch_for(&self, persona_id: &NodeId) -> anyhow::Result { - let key = format!("bio_epoch_{}", hex::encode(persona_id)); - let current: u32 = self.get_setting(&key)? - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - let next = current + 1; - self.set_setting(&key, &next.to_string())?; - Ok(next) - } - // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed @@ -5893,17 +4853,12 @@ impl Storage { /// deleted_at tombstone, store it so the tombstone propagates. pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { self.conn.execute( - "INSERT INTO comments - (author, post_id, content, timestamp_ms, signature, deleted_at, - ref_post_id, pub_x_index, group_sig, encrypted_payload) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, - ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id), - pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index), - group_sig = COALESCE(excluded.group_sig, group_sig), - encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)", + ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -5912,9 +4867,6 @@ impl Storage { comment.signature, comment.deleted_at.map(|v| v as i64), comment.ref_post_id.as_ref().map(|r| r.as_slice()), - comment.pub_x_index.map(|i| i as i64), - comment.group_sig.as_ref().map(|b| b.as_slice()), - comment.encrypted_payload.as_ref().map(|b| b.as_slice()), ], )?; Ok(()) @@ -5941,8 +4893,7 @@ impl Storage { /// Get live (non-tombstoned) comments for a post. Used for UI display. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id, - pub_x_index, group_sig, encrypted_payload + "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -5952,14 +4903,11 @@ impl Storage { let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; let ref_post: Option> = row.get(5)?; - let pxi: Option = row.get(6)?; - let gsig: Option> = row.get(7)?; - let epl: Option> = row.get(8)?; - Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl)) + Ok((author, pid, content, ts, sig, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -5974,9 +4922,6 @@ impl Storage { signature: sig, deleted_at: None, ref_post_id, - pub_x_index: pxi.map(|v| v as u32), - group_sig: gsig, - encrypted_payload: epl, }); } Ok(result) @@ -6016,9 +4961,6 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, }); } Ok(result) @@ -6778,8 +5720,6 @@ mod tests { content: format!("post at {}", ts), attachments: vec![], timestamp_ms: ts, - fof_gating: None, - supersedes_post_id: None, }; let id = blake3::hash(&serde_json::to_vec(&post).unwrap()); s.store_post(id.as_bytes(), &post).unwrap(); @@ -7477,9 +6417,6 @@ mod tests { signature: vec![0u8; 64], deleted_at: None, ref_post_id: None, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, }).unwrap(); s.store_comment(&InlineComment { @@ -7490,9 +6427,6 @@ mod tests { signature: vec![1u8; 64], deleted_at: None, ref_post_id: None, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -7518,9 +6452,6 @@ mod tests { signature: vec![9u8; 64], deleted_at: None, ref_post_id: Some(ref_post), - pub_x_index: None, - group_sig: None, - encrypted_payload: None, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index aca1ea1..9102c6b 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -39,22 +39,6 @@ pub struct Post { pub attachments: Vec, /// Unix timestamp in milliseconds pub timestamp_ms: u64, - /// FoF Layer 2: author-signed snapshot of the comment-gating - /// state at publish time. Carries wrap_slots, pub_post_set, and the - /// slot_binder_nonce. `None` on posts without FoF comment gating. - /// Covered by `PostId = BLAKE3(Post)` so any forgery is detectable. - /// Revocations and access-grants arrive later as engagement diffs - /// against the local BlobHeader copy; this field is the snapshot at - /// t=0, not the live mutable state. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fof_gating: Option, - /// FoF Layer 4: optional pointer to a post this one supersedes. - /// Used by the "re-issue with narrower access" path (advanced - /// rotation). Readers may display "this is a re-issued version - /// of an earlier post" + offer to view the original if still - /// cached. Covered by PostId since it's part of the signed Post. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub supersedes_post_id: Option, } /// A reference to a media blob attached to a post @@ -225,16 +209,6 @@ pub enum PostVisibility { /// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16) wrapped_cek: Vec, }, - /// FoF Layer 3 (Mode 1): post body is encrypted under the CEK - /// carried in the post's `fof_gating.wrap_slots`. Tag variant only — - /// the actual gating data (slot_binder_nonce / pub_post_set / - /// wrap_slots) lives in `Post.fof_gating` so Mode 1 and Mode 2 - /// share a single home for the FoF state. - /// - /// Invariant: when visibility is FoFClosed, `Post.fof_gating` must - /// be Some. Posts with FoFClosed + None gating are rejected at - /// receive time. - FoFClosed, } impl Default for PostVisibility { @@ -300,50 +274,6 @@ 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, - /// Random 32B nonce binding this batch's wrappers to this specific - /// bio publish. Plays the role the spec calls "bio_post_id" in the - /// HKDF info string — distinct from the actual `PostId` (which is - /// `BLAKE3(post)` and would be circular here). Recipient-free per - /// HPKE key-privacy requirements. - pub bio_pub_nonce: [u8; 32], - /// 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. @@ -944,9 +874,6 @@ pub struct InlineComment { pub post_id: PostId, /// Either the full comment text (short comments) or a short preview of /// the referenced post (when `ref_post_id` is set). - /// - /// On FoF-policy posts this field is empty — the body lives encrypted - /// in `encrypted_payload`. Non-FoF readers see no text at all. pub content: String, /// When the comment was created (ms) pub timestamp_ms: u64, @@ -961,23 +888,6 @@ pub struct InlineComment { /// for the expanded view. #[serde(default)] pub ref_post_id: Option, - /// FoF Layer 2: index into the parent post's `pub_post_set` - /// identifying which voucher-chain signed this comment. `None` on - /// non-FoF comments. CDN propagation nodes verify `group_sig` - /// against `pub_post_set[pub_x_index]` before forwarding. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pub_x_index: Option, - /// FoF Layer 2: 64-byte ed25519 signature under priv_x over - /// `(encrypted_payload || parent_post_id || pub_x_index)`. Verified - /// at CDN-level against `pub_post_set[pub_x_index]`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group_sig: Option>, - /// FoF Layer 2: ChaCha20-Poly1305 ciphertext under CEK_comments - /// (derived from CEK via HKDF). Plaintext is the JSON-encoded - /// comment body + optional vouch_mac + optional parent_comment_id. - /// Non-FoF observers see only ciphertext + sigs — body is opaque. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub encrypted_payload: Option>, } /// Permission level for comments on a post @@ -990,13 +900,6 @@ pub enum CommentPermission { FollowersOnly, /// Comments disabled None, - /// FoF Layer 2: commenter must hold one of the V_x keys in the - /// author's keyring (own V_me + every V_x they received). The author - /// publishes pub_post_set + wrap_slots in the post; commenters trial- - /// decrypt to unlock priv_x for signing. CDN nodes verify the - /// comment's group_sig + pub_x_index before forwarding — kills the - /// bandwidth-DoS attack a single admitted FoF member could mount. - FriendsOfFriends, } impl Default for CommentPermission { @@ -1040,65 +943,6 @@ impl Default for ModerationMode { } } -/// FoF Layer 2: per-V_x wrap slot in a post header. Dual-derived so -/// one successful AEAD-open yields both the read CEK and the per-V_x -/// signing seed. Real slots and dummy padding slots are byte-identical -/// (98 bytes each); receivers identify "their" slot by successful -/// AEAD decryption, not by position. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct WrapSlot { - /// 2-byte HMAC prefix. Receivers precompute one per held V_x; the - /// scan iterates only slots whose prefilter matches. - pub prefilter_tag: [u8; 2], - /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "read"); 48B - /// (32B sealed CEK + 16B tag). - pub read_ciphertext: Vec, - /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B - /// (32B sealed priv_x ed25519 seed + 16B tag). - pub sign_ciphertext: Vec, -} - -/// FoF Layer 2: author-signed revocation entry. When a post-holder -/// receives a valid revocation diff, it deletes all locally-stored -/// comments signed by `revoked_pub_x` AND removes the entry from its -/// local pub_post_set, then forwards the diff. Retroactive cleanup. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RevocationEntry { - /// The pub_x being revoked. Must be in the post's pub_post_set - /// at the time the diff is processed. - pub revoked_pub_x: [u8; 32], - /// ms since epoch. - pub revoked_at_ms: u64, - /// Opaque to CDN; used by author UI to display the reason. - pub reason_code: u8, - /// 64-byte ed25519 signature by the post author over - /// (post_id || revoked_pub_x || revoked_at_ms || reason_code). - pub author_sig: Vec, -} - -/// FoF Layer 2: the author-published gating block embedded in a -/// FoF-comment-policy post. Carries the wrap slots + the matching -/// pub_post_set + the slot_binder_nonce. The `revocation_list` is -/// initially empty; revocation diffs append over the post's lifetime. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct FoFCommentGating { - /// Random 32B nonce. Plays the spec's "post_id in HKDF info" role - /// without circularity (PostId = BLAKE3(post) depends on this field). - pub slot_binder_nonce: [u8; 32], - /// All admitted pub_x's, 1:1 with `wrap_slots` (including dummies). - /// Order is randomized at publish; access-grants append at the tail - /// (Layer 3 resolved decision — pub_x_index stability matters more - /// than the small tail-positional-recency leak). - pub pub_post_set: Vec<[u8; 32]>, - /// Real wrap slots + dummy slots, shuffled at publish. 1:1 with - /// `pub_post_set`. - pub wrap_slots: Vec, - /// Initially empty. Receivers accumulate revocations as diffs - /// arrive; the on-wire t=0 snapshot is empty. - #[serde(default)] - pub revocation_list: Vec, -} - /// Author-controlled engagement policy for a post #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommentPolicy { @@ -1137,50 +981,6 @@ pub enum BlobHeaderDiffOp { WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec }, /// Add new encrypted comment slots (each 256 bytes) AddCommentSlots { post_id: PostId, count: u32, slots: Vec> }, - /// FoF Layer 2: author revokes a pub_x from a FoF-gated post. - /// Propagation nodes verify author_sig, drop locally-stored - /// comments by the revoked pub_x, record the revocation, and - /// forward. Retroactive + idempotent. - FoFRevocation { - post_id: PostId, - revoked_pub_x: [u8; 32], - revoked_at_ms: u64, - reason_code: u8, - /// 64-byte ed25519 sig by post author over - /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code). - author_sig: Vec, - }, - /// FoF Layer 2: author retroactively widens read access on a - /// FoF-gated post by appending a new (pub_x, wrap_slot) pair. The - /// newly-vouched persona can now decrypt and comment on the post - /// without a full re-issue. Append-only at the tail per Layer 3. - FoFAccessGrant { - post_id: PostId, - new_pub_x: [u8; 32], - new_wrap_slot: WrapSlot, - granted_at_ms: u64, - /// 64-byte ed25519 sig by post author over - /// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le). - author_sig: Vec, - }, - /// FoF Layer 4: in-place wrap_slot replacement for leaked-V_me - /// scenarios. The author re-seals the slot at `slot_index` under a - /// fresh V_x (or a different held V_x), invalidating the leaked - /// key's read access to this specific post. The corresponding - /// pub_x in pub_post_set is also replaced so future comments must - /// sign under the new keypair. Comments signed under the OLD - /// pub_x at this slot are NOT auto-deleted by this op — use a - /// revocation diff alongside if comment cleanup is desired. - FoFKeyBurn { - post_id: PostId, - slot_index: u32, - new_pub_x: [u8; 32], - new_wrap_slot: WrapSlot, - burned_at_ms: u64, - /// 64-byte ed25519 sig by post author over - /// (post_id || slot_index_le || new_pub_x || canonical(new_wrap_slot) || burned_at_ms_le). - author_sig: Vec, - }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown, diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index d7bc90c..17975ed 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.6.2" edition = "2021" [lib] diff --git a/crates/tauri-app/gen/schemas/acl-manifests.json b/crates/tauri-app/gen/schemas/acl-manifests.json index f5a0dcf..ff9ca58 100644 --- a/crates/tauri-app/gen/schemas/acl-manifests.json +++ b/crates/tauri-app/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"android-fs":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin","permissions":["allow-noop"]},"permissions":{"allow-noop":{"identifier":"allow-noop","description":"Enables the noop command.","commands":{"allow":["noop"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 6246241..44e3c93 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -256,10 +256,6 @@ 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 } => { @@ -350,10 +346,6 @@ 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, } } @@ -918,7 +910,6 @@ 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 } => { @@ -1098,187 +1089,6 @@ 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()) -} - -// --- FoF Layer 2: comment-gated post + commenting --- - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct FoFPostCreatedDto { - post_id: String, -} - -#[tauri::command] -async fn create_post_with_fof_comments( - state: State<'_, AppNode>, - content: String, -) -> Result { - let node = get_node(&state).await; - let (post_id, _post, _vis, _cek) = node - .create_post_with_fof_comments(content, vec![]) - .await - .map_err(|e| e.to_string())?; - Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) -} - -#[tauri::command] -async fn comment_on_fof_post( - state: State<'_, AppNode>, - post_id_hex: String, - body: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32] - node.comment_on_fof_post(pid, body).await - .map(|_| ()) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn revoke_fof_commenter( - state: State<'_, AppNode>, - post_id_hex: String, - pub_x_index: u32, - reason_code: u8, -) -> Result<(), String> { - let node = get_node(&state).await; - let pid = parse_node_id(&post_id_hex)?; - node.revoke_fof_commenter(pid, pub_x_index, reason_code).await - .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()) -} - -// FoF Layer 4: V_me lifecycle + cascade + key-burn. - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct VmeRotatedDto { - new_epoch: u32, -} - -#[tauri::command] -async fn rotate_v_me(state: State<'_, AppNode>) -> Result { - let node = get_node(&state).await; - let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?; - Ok(VmeRotatedDto { new_epoch }) -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct CascadeRevokeResultDto { - posts_revoked: usize, -} - -#[tauri::command] -async fn cascade_revoke_v_me_epoch( - state: State<'_, AppNode>, - retired_epoch: u32, - reason_code: u8, -) -> Result { - let node = get_node(&state).await; - let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code) - .await - .map_err(|e| e.to_string())?; - Ok(CascadeRevokeResultDto { posts_revoked: n }) -} - -#[tauri::command] -async fn key_burn_post_slot( - state: State<'_, AppNode>, - post_id_hex: String, - slot_index: u32, - new_v_x_hex: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let pid = parse_node_id(&post_id_hex)?; - let new_v_x_bytes = hex::decode(&new_v_x_hex) - .map_err(|e| format!("invalid new_v_x hex: {}", e))?; - let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into() - .map_err(|_| "new_v_x must be 32 bytes".to_string())?; - node.key_burn_post_slot(pid, slot_index, &new_v_x).await - .map_err(|e| e.to_string()) -} - #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; @@ -3293,18 +3103,6 @@ pub fn run() { ignore_peer, unignore_peer, list_ignored_peers, - vouch_for_peer, - revoke_vouch_for_peer, - list_vouches_given, - list_vouches_received, - create_post_with_fof_comments, - comment_on_fof_post, - revoke_fof_commenter, - create_post_fof_closed, - read_fof_closed_body, - rotate_v_me, - cascade_revoke_v_me_epoch, - key_burn_post_slot, list_circles, create_circle, delete_circle, diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index f3aaa93..19dc5b3 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.0", + "version": "0.6.2", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/docs/fof-spec/README.md b/docs/fof-spec/README.md index 3343a0c..02c1946 100644 --- a/docs/fof-spec/README.md +++ b/docs/fof-spec/README.md @@ -42,12 +42,12 @@ No centrally-computed membership list. Reach is a function of the wrap-slot set Build and ship bottom-up. Each layer is independently shippable and exercised before moving to the next. -1. **[Layer 1](layer-1-vouch-primitive.md) — Vouch primitive.** `V_x` keys, per-persona keyring storage, epoch tag, HPKE-sealed anonymous-wrapper distribution via the voucher's bio post, scan-on-follow + scan cache, minimal UI. No FoF-gated posts yet. +1. **[Layer 1](layer-1-vouch-primitive.md) — Vouch primitive.** `V_x` keys, per-persona keyring storage, epoch tag, distribution/exchange mechanism, minimal UI. No posts yet. 2. **[Layer 2](layer-2-mode2-fof-comments.md) — Mode 2: public posts with FoF-gated comments.** Easier implementation path; reuses existing public-post CDN path; extends `CommentPolicy` with a new `GroupMembersOfFoF` variant. 3. **[Layer 3](layer-3-mode1-fof-closed.md) — Mode 1: `FOF_CLOSED` posts.** New `PostVisibility::FoFClosed` variant. Wrap slots, anonymous prefilter tag. Receive-path integration. 4. **[Layer 4](layer-4-keypair-rotation.md) — Per-post keypair rotation.** Graceful `(priv_post', pub_post')` rotation record re-wrapped to current FoF set. Old comments still verifiable under old `pub_post`; new comments require new. 5. **[Layer 5](layer-5-prefilter-and-cache.md) — Unlock cache + prefilter optimization.** Author-direct fast path, winning-V_x-per-author cache, unreadable-posts retry table, re-try-on-new-V_x trigger. Performance-critical at realistic keyring sizes (400–500 keys × 400–500 slots). -6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Superseded by Layer 4. File retained as a record of alternatives considered. +6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Deferred; may not be in v1. Drafted as a stub for design review. --- @@ -68,9 +68,7 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be - **Vouch key (`V_x`)**: a symmetric key owned by persona `x`, distributed by `x` to everyone `x` vouches for. `V_me` refers to the current persona's own vouch key. - **Keyring**: for a given persona, the set of vouch keys held = `{V_me}` ∪ `{V_x : x vouched for me}`. - **Wrap slot**: an anonymous ciphertext in a post header, carrying the post's private material encrypted under some `V_x`. Readers trial-decrypt slots whose 2-byte prefilter tag matches an owned key. -- **pub_x / priv_x**: per-`V_x` ed25519 keypair (Layer 2). `priv_x` is wrapped in the sign-slot of each `V_x`; `pub_x` is published in the post's `pub_post_set`. Comments reference a `pub_x_index` so propagation nodes can verify the comment signature without unwrapping. Replaces the earlier single `pub_post/priv_post` per-post keypair. -- **pub_post_set**: list of all `pub_x` for a post's FoF set, in randomized order. Inline in post header. Comments reference entries by index. -- **revocation_list**: author-appended signed entries that tell CDN propagation nodes to drop comments under a named `pub_x`. Stops compromised voucher-chains at the propagation layer. +- **pub_post / priv_post**: per-post ephemeral ed25519 keypair. `priv_post` is wrapped in the post's wrap slots; `pub_post` is in the header plaintext and used for group signature verification on comments. - **Identity key**: persona's long-term ed25519 key, used to sign content as that persona. Distinct from `priv_post`. (In current ItsGoin terms: this is the persona's posting key. Worth naming-alignment review when specifying.) - **Vouch MAC**: `HMAC(V_x, post_id || comment_hash)` — 16B truncated. Identifies which `V_x` a commenter holds. Inside encrypted payload (Mode 1) or alongside plaintext (Mode 2). Used by author strict-mode to verify the commenter is reachable via a known vouch chain. @@ -81,11 +79,9 @@ Build and ship bottom-up. Each layer is independently shippable and exercised be - **`Circle` + `GroupEncrypted`** remain as-is for named explicit-membership groups. FoF is a separate visibility class, not a replacement. - **`PostVisibility`** gains one new variant (`FoFClosed`, from Layer 3). Mode 2 reuses `PostVisibility::Public` with extended `CommentPolicy`. - **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating). -- **`InlineComment`** gets `pub_x_index`, `group_sig`, `identity_sig`, encrypted `ciphertext`, and inner `vouch_mac` (inside the ciphertext). Back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`. -- **Propagation-node accept rule** for comments on FoF posts: valid `pub_x_index` + not in `revocation_list` + `group_sig` verifies + `identity_sig` verifies. Any failure → drop without forwarding. Makes bandwidth-amplification DoS infeasible. +- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`). - **`control::receive_post`** gets new verify-gate branches for `FoFClosed` posts (author_sig + wrap_slots well-formedness) and FoF comments (group_sig verifies against `pub_post` from the referenced parent post). - **Multi-persona**: keyrings are per-persona. Unlock attempts iterate personas; the persona that successfully unlocks is recorded and drives comment-authorship defaults. See Layer 3 for detail. -- **Bio post (`VisibilityIntent::Profile`)**: Layer 1 adds an optional `vouch_grants` field carrying an HPKE-sealed per-recipient wrapper batch. Existing bio-post CDN propagation carries vouch distribution — no new control-message type. See Layer 1 for wrapper format and scan policy. --- diff --git a/docs/fof-spec/layer-1-vouch-primitive.md b/docs/fof-spec/layer-1-vouch-primitive.md index 5d87ebe..c0561a0 100644 --- a/docs/fof-spec/layer-1-vouch-primitive.md +++ b/docs/fof-spec/layer-1-vouch-primitive.md @@ -1,6 +1,6 @@ # Layer 1 — Vouch Primitive -**Scope**: Introduce `V_x` vouch keys, per-persona keyring storage, and a distribution mechanism via anonymous per-recipient wrappers in the issuer's bio post. No post-gating yet — this layer ships first so the primitive is in place and UI-exercised before any post encryption depends on it. +**Scope**: Introduce `V_x` vouch keys, per-persona keyring storage, and a distribution/exchange mechanism. No post-gating yet — this layer ships first so the primitive is in place and UI-exercised before any post encryption depends on it. --- @@ -10,27 +10,19 @@ After Layer 1 ships: - Each persona owns a current `V_me` symmetric key. New personas auto-generate one at creation. - Each persona has a keyring of received vouch keys: `{V_x : x has vouched for me}`. -- Vouches are distributed via **anonymous wrappers appended to the voucher's bio post**, not via DM. No recipient IDs are visible on the wire. -- Clients auto-scan new/updated bio posts of people they follow; successfully unwrapped `V_x` keys are silently added to the receiving persona's keyring. -- Users can view who has vouched for them (from keyring) and who they've vouched for (from their own bio-post state). +- Users can view who they've vouched for, who has vouched for them, and revoke/rotate `V_me`. +- Wire protocol can transfer `V_me` from voucher to vouchee inside an existing encrypted channel. - No post encryption or comment gating depends on Layer 1 yet — that arrives in Layer 2/3. --- ## Lead decisions -- **`V_me` is symmetric.** 32B CSPRNG-generated. Every vouchee holds the same `V_me`. This is the property that makes FoF reach emergent (wrap under `V_x`, anyone `x` vouched for decrypts). -- **Distribution is unilateral.** Alice vouching for Bob = Alice includes an anonymous wrapper containing `V_alice` addressed to Bob's persona pubkey in her bio post. No handshake, no acknowledgment. -- **Distribution channel is the bio post, not DM.** Bio posts already propagate via the CDN to followers. Recipient anonymity is preserved by HPKE key privacy (see below). No new control-message type is needed. Scan-on-fetch replaces push-notification. -- **Per-wrapper scheme is HPKE (RFC 9180).** Per-recipient ciphertext is HPKE-sealed under the recipient's X25519 persona key. One ephemeral pubkey per bio-post batch, shared across all wrappers in that batch. Each wrapper is 48B on the wire (32B sealed `V_me` + 16B AEAD tag). -- **HKDF `info` MUST be recipient-free.** `info = "itsgoin/vouch-grant/v1/" || bio_post_id`. Including any recipient identifier in `info` breaks key privacy and is forbidden. -- **No prefilter tag on Layer 1 wrappers.** Unlike FoF post wrap slots (Layers 2–3, which use HMAC prefilter over `V_x`), vouch grants have no prior shared secret. Readers pay a full X25519 scalar-mult per wrapper per persona. Cost is acceptable at realistic vouch-set sizes (<40ms for 600 trials). -- **Scan is follow-gated by default.** Clients auto-scan bio posts of followed personas. For non-followed personas, scanning is a manual "check this person's bio for a vouch" gesture. Rationale: vouches that would be relevant to a reader overwhelmingly come from people the reader follows. -- **Wrapper count padded to fixed buckets (64 / 128 / 256 / 512).** Dummy wrappers are random bytes shaped identically to real wrappers. Prevents observers from reading vouch-set size off the bio post. -- **Wrapper order shuffled on every publish.** Prevents positional inference of which vouchee slot changed between bio-post revisions. -- **Epoch tag is part of the key.** Each `V_x` has associated `(owner_id, epoch)` so receivers can distinguish fresh from stale when the voucher rotates. +- **`V_me` is symmetric, not asymmetric.** Every vouchee holds the same `V_me`. This is the property that makes FoF reach emergent (wrap under `V_x`, anyone `x` vouched for decrypts). +- **Distribution is unilateral.** Alice vouching for Bob = Alice gives Bob a copy of `V_alice`. No request/accept handshake. Bob can immediately read FoF posts that include `V_alice` as a wrap slot. +- **Revocation = rotate `V_me`.** There is no per-vouchee revocation. To un-vouch someone, the persona generates a new `V_me'` and re-distributes to every remaining vouchee. Layer 6 stub. +- **Epoch tag is part of the key.** Every `V_x` has an associated `(owner_id, epoch)` so receivers can tell fresh from stale when multiple copies arrive (e.g., if voucher rotated). - **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting. -- **`V_me` rotation IS the persona-wide revocation primitive.** To remove a vouchee, generate `V_me_new` and distribute via the next bio-post batch to every current vouchee EXCEPT the revoked one. The revoked person retains `V_me_old`. Old posts sealed under `V_me_old` stay accessible to anyone who still holds `V_me_old` (grandfathered by default). See [Layer 4](layer-4-keypair-rotation.md) for the full lifecycle, optional cascade, and key-burn primitive. --- @@ -40,163 +32,68 @@ After Layer 1 ships: Per-persona, stores the persona's own `V_me` history (current + recent-past for graceful rotation). +TBD — OPUS: exact column types, key size in bytes, how many past epochs to retain. + ``` vouch_keys_own( - persona_id BLOB, - epoch INTEGER, - key_material BLOB(32), - created_at_ms INTEGER, - is_current INTEGER, -- 1 for active, 0 for retained past + persona_id BLOB, + epoch INTEGER, + key_material BLOB, -- TBD — OPUS: symmetric key bytes (32B for a 256-bit key?) + created_at_ms INTEGER, + is_current INTEGER, -- 1 for the active V_me, 0 for retained past epochs PRIMARY KEY (persona_id, epoch) ) ``` ### `vouch_keys_received` table -Per-persona keyring — vouch keys successfully unwrapped from others' bio posts. +Per-persona, stores vouch keys received from other personas (one row per `(owner_id, epoch)` currently held). ``` vouch_keys_received( - holder_persona_id BLOB, -- whose keyring this entry belongs to - owner_id BLOB, -- the persona who issued V_x + holder_persona_id BLOB, -- whose keyring this entry belongs to + owner_id BLOB, -- the persona who owns (issued) this V_x epoch INTEGER, - key_material BLOB(32), + key_material BLOB, received_at_ms INTEGER, - source_bio_post_id BLOB, -- provenance (which bio post we unwrapped from) PRIMARY KEY (holder_persona_id, owner_id, epoch) ) ``` -### `vouch_bio_scan_cache` table - -Skips re-scanning bio posts that haven't changed since last attempt. - -``` -vouch_bio_scan_cache( - scanner_persona_id BLOB, - bio_author_id BLOB, - bio_epoch INTEGER, -- bio-post revision counter - result INTEGER, -- 0 = no wrapper unlocked; 1 = unlocked - unlocked_v_x_epoch INTEGER, -- NULL if result=0 - scanned_at_ms INTEGER, - PRIMARY KEY (scanner_persona_id, bio_author_id, bio_epoch) -) -``` - -On an unchanged bio, clients short-circuit via this cache. On a bio-post update, clients trial only the *new wrappers* in the diff (not the full batch), bounded by bio_epoch increment. - -### Outbound vouches - -No separate `vouches_issued` table. The bio post itself IS the authoritative record of whom the persona has vouched for. UI "people I've vouched for" is derived from local author-side state (the recipient pubkey list used at bio-post assembly time), stored in a simple `own_vouch_targets` table: - -``` -own_vouch_targets( - voucher_persona_id BLOB, - target_persona_id BLOB, - target_x25519_pub BLOB(32), - granted_at_ms INTEGER, - current INTEGER, -- 1 = in latest bio-post batch, 0 = removed - PRIMARY KEY (voucher_persona_id, target_persona_id) -) -``` - -This is local-only; never transmitted. The wire carries only anonymous wrappers. +Readers compute their keyring as `SELECT key_material FROM vouch_keys_received WHERE holder_persona_id = ? UNION SELECT key_material FROM vouch_keys_own WHERE persona_id = ? AND is_current = 1`. --- -## Wrapper format +## Key generation -Bio post carries a `VouchGrantBatch`: +TBD — OPUS: specify the primitive. Candidates: + +- Random 32B from CSPRNG (simplest; `V_me` is pure symmetric secret) +- HKDF-derived from persona identity key + epoch counter (allows deterministic re-derivation; couples key rotation to identity key exposure) + +Lead leaning: **random 32B CSPRNG, stored encrypted at rest alongside identity key material**. Identity-key coupling offers no defensive benefit given the keyring is already indexed per-persona in the same DB. + +--- + +## Wire format for vouch distribution + +A `VouchGrant` message carries a copy of the voucher's current `V_me` from voucher to vouchee. + +TBD — OPUS: exact byte layout. Target shape: ``` -VouchGrantBatch { - batch_eph_pub: [u8; 32], // shared ephemeral X25519 pubkey for this batch - v_x_epoch: u32, // epoch of V_me being distributed in this batch - wrappers: Vec, // padded to next bucket in {64, 128, 256, 512} -} - -Wrapper { - ciphertext: [u8; 32], // HPKE-sealed V_me (32B key) - tag: [u8; 16], // AEAD auth tag +VouchGrant { + voucher_persona_id: NodeId, -- sender's persona + epoch: u32, + key_material: [u8; 32], -- V_voucher at this epoch + issued_at_ms: u64, + sig: [u8; 64], -- voucher's identity-key signature over the above } ``` -Per-recipient wrapper construction (RFC 9180 HPKE sealing, single-shot mode): +Delivery: wrap `VouchGrant` inside the existing `Direct` (encrypted-to-one-recipient) post primitive. Do not introduce a new top-level control message. Content-type byte distinguishes vouch grants from regular DMs. Recipient's receive-path decodes, verifies signature, inserts into `vouch_keys_received`. -``` -shared_secret = X25519(batch_eph_priv, recipient_x25519_pub) -key, nonce = HKDF-Expand( - HKDF-Extract(salt="", ikm=shared_secret), - info = "itsgoin/vouch-grant/v1/" || bio_post_id, - L = key_len + nonce_len - ) -(ciphertext, tag) = AEAD-Seal(key, nonce, aad="", plaintext=V_me) -``` - -All recipients share the same `batch_eph_pub`; each gets a distinct wrapper derived from ECDH between that ephemeral and the recipient's persona X25519 pubkey. - -Dummy wrappers: 32B random bytes + 16B random bytes. Shape-identical to real wrappers. Indistinguishable to any party lacking the voucher's target list. - -**TBD — OPUS (confirm)**: AEAD choice for HPKE. Default RFC 9180 suite `DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20Poly1305` matches existing ItsGoin crypto usage; confirm no reason to prefer AES-GCM. - ---- - -## Bio-post integration - -ItsGoin already has bio posts (control post with `VisibilityIntent::Profile`). Layer 1 adds an optional `vouch_grant_batch` section to the profile-post payload: - -```rust -struct ProfilePostPayload { - // ... existing fields (display_name, bio, avatar_cid, etc.) ... - - #[serde(default, skip_serializing_if = "Option::is_none")] - vouch_grants: Option, - - bio_epoch: u32, // monotonic per-persona; incremented on every bio-post revision -} -``` - -Adding/removing a vouchee is a bio-post update: new batch, new `batch_eph_pub`, wrappers re-shuffled, `bio_epoch` incremented, republish. Propagates via the standard bio-post CDN path. - -**Incremental grants via bio-post comments** (deferred): Scott's variant of appending additional wrappers as author-only comments on the bio post (to avoid full republish on every small change) is a future optimization. v1 ships with full republish per change; simpler mental model and keeps the wire contract symmetric. Revisit if bio-post bandwidth becomes a concern. - ---- - -## Reader / scanner behavior - -### On follow (new) -1. Fetch the followed persona's latest bio post. -2. If `vouch_grants.is_some()` AND `(scanner_persona, bio_author, bio_epoch)` not in scan cache: - - For each persona `P` in the reader's persona set: - - For each wrapper in the batch: - - Derive `shared_secret = X25519(P.x25519_priv, batch_eph_pub)`. - - Derive `key, nonce = HKDF(shared_secret, info="itsgoin/vouch-grant/v1/" || bio_post_id, ...)`. - - Attempt `AEAD-Open(wrapper.ciphertext, wrapper.tag, key, nonce)`. - - On success: extract `V_me`, insert into `vouch_keys_received` as `(holder=P, owner=bio_author, epoch=v_x_epoch)`. Record scan-cache hit. -3. If no wrapper unlocked across any persona: record scan-cache miss. Done. - -### On bio-post update (existing follow) -Same as above, but trial only the wrappers that are new relative to the prior bio_epoch. Since wrappers are shuffled on every publish, "new wrappers" = all wrappers on every update. **TBD**: whether to persist the exact wrapper-ciphertext set per bio_epoch so deltas can be computed, or just rescan the full batch (pay the cost, skip the bookkeeping). Lead leaning: **rescan full batch**; the cost (~40ms × number of followed personas publishing updates) is tolerable and the bookkeeping adds state-sync complexity. - -### On manual "check bio" gesture -Treat as new-follow scan. User-invoked. Useful when a non-followed persona has vouched for the user. - -### Multi-persona trial order -Personas iterated in stable sorted order (by persona_id). First unlock wins; stop scanning wrappers once any persona succeeds on any wrapper (the wrapper was addressed to that persona specifically — other wrappers in the batch are for other recipients). - -**TBD — OPUS**: confirm early-exit is correct. One caveat: the voucher may grant to MULTIPLE of the reader's personas (e.g., "I vouch for your work-persona AND your private-persona"). In that case we want to unlock all matching wrappers, not just the first. Lead leaning: do not early-exit on persona; continue scanning with remaining personas so all applicable grants are received. - ---- - -## Cost analysis - -Per wrapper per persona: 1 X25519 scalar mult + 1 HKDF + 1 AEAD-Open attempt. ~60µs on ARM mobile; faster on desktop. - -Typical bio update: 200 wrappers × 3 personas = 600 trials ≈ 36ms. Invisible to user. - -Padded bucket at 512 × 3 personas = 1536 trials ≈ 90ms. Still acceptable for a one-time ingest per bio update. - -Scan-cache prevents re-pay on unchanged bios. +TBD — OPUS: decide whether vouch grants should use a reserved visibility intent (`VisibilityIntent::VouchGrant`) for filtering out of the Messages tab, or whether they ride under `Direct` and are suppressed client-side by content-type. --- @@ -204,47 +101,30 @@ Scan-cache prevents re-pay on unchanged bios. Minimum viable surface for Layer 1 ship: -- **Persona screen**: "Vouch for someone" action. Picker of contacts. Adds their persona to `own_vouch_targets`; republishes bio post with new batch on save. +- **Persona screen**: "Vouch for someone" button. Picker of contacts. Hands them a `V_me`. - **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`). -- **Persona screen**: "People I've vouched for" list (reads `own_vouch_targets` where `current = 1`). -- **Settings**: "Rotate my vouch key" → generates new `V_me` epoch in `vouch_keys_own` (prior epoch retained, marked `is_current = 0`). Optionally offers to issue the new key to existing vouchees minus any marked-revoked. Defaults to single-epoch bio-post batch; advanced multi-epoch toggle available for cases where vouchees on device-wipe need multi-epoch re-bootstrap. See [Layer 4](layer-4-keypair-rotation.md). -- **Post detail**: manual "Check this person's bio for a vouch for me" button (non-followed author case). +- **Persona screen**: "People I've vouched for" list (local-only; TBD — OPUS on whether to track this explicitly or derive from DM-sent history — see open question below). +- **Settings**: "Rotate my vouch key" button → generates new epoch, queues re-distribution to tracked vouchees. Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet. --- -## Privacy properties (Layer 1 scope) - -- **Recipient anonymity.** HPKE key privacy: wrapper ciphertext reveals nothing about the recipient's pubkey. Only trial decryption identifies recipients. -- **No identifiers on the wire.** No recipient NodeIds, no persona IDs, no derived recipient tags in the bio post. -- **Set-size opacity.** Bucket padding hides actual vouch-set size to within 2× granularity. -- **Rotation unobservability.** Shuffled wrapper order per publish prevents positional inference of which slot changed. - -Timing leak is acknowledged: a bio-post update with a changed vouch set leaks "someone new was vouched for today" to observers who see the update. Batching (daily cadence instead of immediate) would blur this; out of scope for v1. - ---- - ## Open questions -- **Batching cadence.** Immediate (republish on every vouch-add) vs daily-batched (aggregate changes). Lead leaning: immediate in v1; observe whether timing leak is a concern in practice. -- **Dummy-wrapper count strategy.** Always pad to next bucket, or pad to a fixed persona-local target size (e.g., "always 128")? Fixed target hides growth trajectory but wastes bandwidth early on. Lead leaning: next-bucket padding. -- **`V_me` in identity export.** Losing `V_me` means every FoF post gated under it becomes permanently unreadable for every vouchee. Lead leaning: include `vouch_keys_own` (current + retained epochs) AND `vouch_keys_received` in persona export bundles. -- **Rescan triggering.** Scan on fetch of the bio post is natural. Should we also opportunistically rescan on keyring-change events (when the persona acquires a new X25519 keypair — unlikely but possible during import)? Lead leaning: yes, rescan-on-keyring-change, cheap. -- **Bio post size.** 512 wrappers × 48B = 24KB of vouch-grant payload. Plus ephemeral pubkey, epoch, headers. Negligible relative to profile-post overhead including avatars. No concern. -- **Does the scanner need to verify the voucher's identity sig over the batch?** Bio posts are already signed by the author's identity key, so the `VouchGrantBatch` inherits that signature. Forgery requires identity-key compromise (out of FoF scope). +- **Do we track outbound vouches explicitly?** Option A: local `vouches_issued(recipient_persona_id, epoch_at_grant)` table. Option B: derive from DM-sent history searching for `VouchGrant` payloads. A is simpler + survives DM history loss; B avoids a redundant record. Lead leaning: A. +- **Re-distribution on rotation.** When persona rotates `V_me`, do we auto-queue `VouchGrant` DMs to every tracked vouchee, or require user-initiated re-vouch? Lead leaning: auto-queue with a confirmation summary ("rotate will re-send vouch to N people"). +- **Key size.** 256-bit symmetric key is standard and matches our ChaCha20-Poly1305 usage. Confirm with Opus there's no reason to prefer 192 or 128 bits. +- **Epoch granularity.** Monotonic counter per persona, or wall-clock-based? Counter is simpler; wall-clock aids debugging. Lead leaning: counter. +- **Should `V_me` ever be exported in the persona backup bundle?** Losing `V_me` means every FoF post gated under it becomes permanently unreadable for every vouchee. Lead leaning: yes, include in identity export. --- ## Ship criteria for Layer 1 - All personas auto-generate `V_me` at creation. -- Bio-post publish path can embed a `VouchGrantBatch`. -- `own_vouch_targets` table tracks who the persona has vouched for locally. -- `vouch_keys_received` populated via auto-scan on bio-post fetch; gated to followed personas + manual gesture. -- `vouch_bio_scan_cache` prevents re-scanning unchanged bios. -- UI: vouch someone, list given vouches, list received vouches, rotate `V_me`. +- Users can vouch and receive vouches end-to-end via DM-wrapped `VouchGrant`. +- UI shows received and issued vouches per persona. +- `V_me` rotation works; re-distribution to tracked vouchees is demonstrated. - No change to post visibility / comment behavior. -- HPKE wrapper construction matches RFC 9180 with recipient-free `info`. -- Bucket-padding + per-publish wrapper shuffle implemented. -- Integration test: two personas on two devices. A vouches for B. B auto-scans A's bio post after follow. B's `vouch_keys_received` contains `V_alice` at the correct epoch. B un-follows A then manually scans: same result. A rotates `V_me`, republishes. B auto-rescans, gets new epoch. +- Integration test: two personas on two devices, Alice vouches for Bob, Bob's `vouch_keys_received` contains `V_alice` with correct signature. diff --git a/docs/fof-spec/layer-2-mode2-fof-comments.md b/docs/fof-spec/layer-2-mode2-fof-comments.md index 00c7cfe..eac2e96 100644 --- a/docs/fof-spec/layer-2-mode2-fof-comments.md +++ b/docs/fof-spec/layer-2-mode2-fof-comments.md @@ -1,46 +1,28 @@ -# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments (CDN-Verified) +# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments -**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post are encrypted, signed under a per-voucher-chain keypair, and **verified at the CDN/propagation layer** before forwarding. +**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post must prove FoF-reachability to the author before being accepted. -This layer also defines the shared wrap-slot + `pub_post_set` structure reused by Layer 3 (Mode 1). The slot design here is the canonical form; Layer 3 inherits it and adds body encryption on top. - ---- - -## Why CDN-level verification - -Naïve single-keypair design (initial skeleton) had an attacker-in-FoF-set problem: any admitted FoF member can sign junk with the shared `priv_post` and amplify bandwidth across the mesh before the author's render-time filter catches it. `vouch_mac` attribution helps the author trace abuse but doesn't stop propagation — an attacker who refuses to include `vouch_mac` simply produces CDN-valid junk at will. - -**Fix**: each `V_x` gets its own `(pub_x, priv_x)` keypair. The post header publishes the full `pub_post_set` of all admitted `pub_x`. Comments declare which `pub_x` signed. Propagation nodes verify the signature against the named pubkey before forwarding. Propagation nodes also honor an author-published revocation list, stopping a compromised chain at the CDN. - -Cost: `pub_x_index` is a per-post pseudonym for the voucher-chain — leaks "these N comments came through the same chain" to observers. Bounded to a single post (new post → new mapping order). Acceptable tradeoff for propagation-level DoS resistance. +This layer ships before Mode 1 because it reuses the existing public-post path and only adds a verification gate on comments. No new `PostVisibility` variant needed. --- ## Goal -- Author creates a public post with `comment_policy = FriendsOfFriends`. -- Body is plaintext in the CDN (unchanged public-post path). -- Comments are ChaCha20-Poly1305 encrypted under a CEK that only FoF members can unwrap; non-FoF observers cannot read comment bodies at all (stronger than skeleton draft). -- Every comment carries `pub_x_index + group_sig + identity_sig`. Propagation nodes verify all three before forwarding. -- Author can append to a signed `revocation_list` that propagation nodes honor on next sync. -- Inner `vouch_mac` is retained for author-side attribution and intra-circle accountability. +- An author creates a public post with `comment_policy = FriendsOfFriends`. +- Body is encrypted to no one — plaintext in the CDN, same as a normal public post today. +- Comments on the post include a proof artifact that lets readers (and the author in strict mode) verify the commenter is reachable through the author's FoF graph. +- Non-FoF readers can still READ the post. They cannot post an accepted comment. --- ## Lead decisions -- **Per-`V_x` signing keypair `(pub_x, priv_x)` is per-post.** Author generates a fresh `(pub_x, priv_x)` for each slot when assembling a post, wraps `priv_x` in that slot's sign-part. No coordination with Layer 1. Per-post rotation (Layer 4) just re-generates all keypairs and re-wraps. Not stable across posts. -- **`pub_post_set` is inline in the post header.** List of all admitted `pub_x` for this post's FoF set, random-order per publish. Comments reference entries by index. -- **Dual-derivation wrap slots.** Each slot yields BOTH a shared `CEK` (read) and a per-`V_x` `priv_x` (sign). One wrap slot unwrapped = reader gets both capabilities. -- **Comments are encrypted.** `CEK_comments = HKDF(CEK, "comments")`. Non-FoF observers see only ciphertext + signatures on comments. Read-gating is a side effect of the same slot unwrap. -- **CDN propagation verifies before forwarding.** Four-check accept rule: valid `pub_x_index`, not in `revocation_list`, `group_sig` validates, `identity_sig` validates. Any failure → drop, do not propagate. -- **Revocation is retroactive.** Revocation diff is author-signed, appends a revoked `pub_x` entry. Every file-holder that receives the diff **deletes all comments on this post signed by that `pub_x`** from local storage, then forwards the diff. Comments in flight before the diff arrived get deleted when it catches up. Stronger than "stop forwarding" — prior garbage also goes away. -- **Bucketed slot-count padding.** Deterministic buckets throughout. Up to 256 real slots: next power of 2 (8, 16, 32, 64, 128, 256). Above 256: next +128-step bucket (384, 512, 640, 768, …). Author publishes `next_bucket(real_count)` slots; dummy slots fill the gap. Dummy `pub_x` entries are included in `pub_post_set` 1:1 so `pub_post_set.len() == wrap_slots.len()`; dummy pubkeys are 32B random bytes (no one holds the matching `priv_x`, so `group_sig` verify against a dummy always fails — benign). -- **Post-hoc read-access grant via author comment.** Author can widen the read-set of an already-published post by publishing an author-signed special comment that **appends at the tail** a new `WrapSlot` + `pub_post_set` entry for a newly-vouched persona. `pub_x_index` values in already-stored comments stay valid (positions 0..N-1 unchanged). Cost: observers see "tail entries are the most recent grants" — small positional-recency leak; accepted tradeoff for index stability. No rotation, no republish; retroactive inclusion. See "Access-grant author comment" below. -- **`vouch_mac` retained.** Inside ciphertext. Enables author render-time and intra-circle attribution complementary to CDN-level `pub_x_index` attribution. -- **Author has their own `pub_me/priv_me`.** Treated as one of the entries in `pub_post_set`. Author signs their own comments through the same path. -- **`V_me` handed to vouchees already implies `priv_me` handoff.** No — correction: `priv_x` is wrapped in the per-`V_x` sign-slot of every post, not handed out at vouch time. Only people who can unwrap the slot (= people holding `V_x`) receive `priv_x`. This is what makes per-post rotation (Layer 4) a coherent revocation surface. -- **v1 ships at Ed25519 sizes with inline `pub_post_set`.** PQ migration (ML-DSA-65 at ~2KB/pubkey) requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs. Design must not preclude — spec shape above is algorithm-agnostic. +- **Mode 2 does not gate readership.** The post body is genuinely public. Only comments are gated. This is intentional — it preserves CDN shardability for the body and gives authors a lightweight "comments to my circle, body to the world" mode. +- **`pub_post` is included in the public post header.** Every FoF-eligible post (both modes) has a per-post ephemeral ed25519 keypair. `pub_post` in the header, `priv_post` in wrap slots (Mode 1) or directly in a control record (Mode 2 — TBD below). +- **Comments carry a `group_sig` signed with `priv_post`.** Any device that can unwrap `priv_post` (i.e., holds a matching `V_x`) can sign. `group_sig` verification against `pub_post` is the cryptographic proof of FoF-reachability. Verifiable by any observer holding the public post. +- **Optional `vouch_mac` for strict mode.** `HMAC(V_x, post_id || comment_hash)` identifies *which* voucher's chain a commenter holds. Author can run in strict mode and reject comments whose `vouch_mac` doesn't match a `V_x` the author has a record of distributing. Non-strict mode accepts any valid `group_sig`. +- **Comment still signed by commenter's identity key as well.** `group_sig` proves FoF-membership; identity sig binds the comment to a specific persona for display / abuse reporting / author-side blocklist. +- **Non-FoF devices can still render the post.** Read path is unchanged — comments missing `group_sig` (or failing verification) are filtered out in the feed renderer rather than hard-rejected in storage. This leaves forensic traces and is cheaper than re-verifying in the renderer vs at ingest. TBD open question below. --- @@ -57,251 +39,89 @@ pub enum CommentPolicy { } ``` -### Post header additions (for `comment_policy = FriendsOfFriends`) +### Post header additions (for posts with `comment_policy = FriendsOfFriends`) ```rust struct PostHeader { // ... existing fields ... - pub_post_set: Vec<[u8; 32]>, // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len() - wrap_slots: Vec, // real slots + rand(32..=128) dummy slots, shuffled - revocation_list: Vec, // initially empty; appended via signed diffs - author_sig: [u8; 64], // ed25519 sig over the header + // NEW for Mode 2 comment gating: + pub_post: Option<[u8; 32]>, // ed25519 public key of per-post ephemeral keypair + wrap_slots: Vec, // wraps priv_post under each V_x in author's keyring + + // TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses) } ``` -### `WrapSlot` byte layout (shared with Layer 3) - -```rust -struct WrapSlot { - prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B] - read_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "read") - sign_slot: SlotPart, // AEAD under key = KDF(V_x, post_id, "sign") -} - -struct SlotPart { - nonce: [u8; 12], - ciphertext: Vec, // read: 32B CEK; sign: 32B priv_x seed - tag: [u8; 16], -} -``` - -Read-slot plaintext: 32B CEK. -Sign-slot plaintext: 32B `priv_x` ed25519 seed. - -AAD for both AEAD invocations: `post_id` (prevents slot-reuse across posts). - -**TBD — OPUS**: confirm ChaCha20-Poly1305 for slot AEAD; confirm AAD choice. - -### `RevocationEntry` - -```rust -struct RevocationEntry { - revoked_pub_x: [u8; 32], // the pub_x to drop from acceptance - revoked_at_ms: u64, - reason_code: u8, // opaque to CDN; for author-side UI - author_sig: [u8; 64], // author identity-key sig over (post_id || revoked_pub_x || revoked_at_ms || reason_code) -} -``` +**Note**: Mode 2 posts carry `wrap_slots` even though the body is public, because commenters need `priv_post` to sign. The wrap slot set IS the FoF membership definition. ### Extend `InlineComment` ```rust struct InlineComment { - // CDN-visible: - parent_post_id: PostId, - ciphertext: Vec, // AEAD(CEK_comments, nonce, plaintext, aad=parent_post_id) - nonce: [u8; 12], - aead_tag: [u8; 16], - pub_x_index: u32, // index into parent post's pub_post_set - group_sig: [u8; 64], // ed25519 under priv_x over (ciphertext || parent_post_id || pub_x_index) - commenter_id: NodeId, // commenter's long-term identity pubkey - identity_sig: [u8; 64], // ed25519 under commenter's identity key over same tuple + // ... existing fields ... - // Plaintext inside ciphertext (FoF-readable): - // comment_body - // vouch_mac: [u8; 16] // HMAC(V_x, post_id || comment_hash)[:16B] - // parent_comment_id: Option + #[serde(default, skip_serializing_if = "Option::is_none")] + group_sig: Option<[u8; 64]>, // ed25519 signature over comment_hash, verifies against parent post's pub_post + + #[serde(default, skip_serializing_if = "Option::is_none")] + vouch_mac: Option<[u8; 16]>, // HMAC(V_x, post_id || comment_hash), truncated } ``` -Back-compat: older clients / non-FoF posts use the existing `InlineComment` shape unchanged. New fields appear only for comments on FoF-policy posts. - -### `CEK_comments` derivation - -``` -CEK_comments = HKDF-Expand( - HKDF-Extract(salt=post_id, ikm=CEK), - info = "itsgoin/fof-comments/v1", - L = 32 -) -``` - -**TBD — OPUS**: confirm domain separation. One CEK per post, one derived comments-CEK per post. All comments on the post share the same `CEK_comments` (nonce uniqueness per comment via random nonce). +Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts. --- -## Comment creation (FoF commenter) +## Comment creation (author of the comment) -1. Commenter fetches parent post. Reads `pub_post_set`, `wrap_slots`, `revocation_list`. -2. For each `V_x` in reader's keyring, match prefilter tag against slot list. On match, attempt read-slot and sign-slot AEAD-open. -3. On successful unwrap: now holds `CEK`, `priv_x`. Derive `CEK_comments = HKDF(CEK, ...)`. -4. Build comment: - - Encrypt `(body || vouch_mac || parent_comment_id)` under `CEK_comments` with random nonce. - - Sign `(ciphertext || parent_post_id || pub_x_index)` with `priv_x` → `group_sig`. - - Sign the same tuple with commenter's identity key → `identity_sig`. - - Determine `pub_x_index` by finding `pub_x` in `pub_post_set` (where `pub_x = ed25519_pub(priv_x_seed)`). -5. Check `pub_x ∉ revocation_list`. If revoked: UI tells commenter they can no longer comment on this post; abort. -6. Publish via normal comment-propagation path. +1. Commenter fetches parent post. Reads `pub_post` from header. +2. Commenter iterates their keyring. For each `V_x` held, computes `HMAC(V_x, post_id)[:2B]`. Compares against each `WrapSlot.prefilter_tag`. On match, trial-decrypts the slot to get `priv_post`. +3. If any slot unwraps successfully: commenter now holds `priv_post`. Signs `comment_hash` to produce `group_sig`. Computes `vouch_mac = HMAC(V_x_winner, post_id || comment_hash)`. Attaches both to comment. +4. Publishes comment through normal comment-propagation path. -If no slot unwraps: commenter is not in the author's FoF set. Client-side: hide the comment box. +If NO slot unwraps: commenter is not in the author's FoF set. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt. --- -## Propagation-node accept rule +## Comment verification (reader side) -For every incoming `InlineComment` targeting a FoF-policy post: +At feed render time, for every comment on a `FriendsOfFriends`-policy post: -1. **Valid index**: `pub_x_index < pub_post_set.len()`. Else drop. -2. **Not revoked**: `pub_post_set[pub_x_index] ∉ revocation_list`. Else drop. -3. **`group_sig` valid**: ed25519 verify `group_sig` over `(ciphertext || parent_post_id || pub_x_index)` against `pub_post_set[pub_x_index]`. Else drop. -4. **`identity_sig` valid**: ed25519 verify `identity_sig` over the same tuple against `commenter_id`. Else drop. +1. If `group_sig` is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: **filter out**. +2. Verify `group_sig` against parent post's `pub_post` over `comment_hash`. If fail → filter out. +3. Verify identity sig of comment as normal. If fail → filter out. -On drop: do not store, do not forward. No error response to sender (avoid oracle). +At author side (strict mode, optional): -Rate-limit per `commenter_id` and per `pub_x_index` (operational knob, not in spec). +4. Recompute expected `vouch_mac` candidates from the set of `V_x` the author has distributed. If `comment.vouch_mac` doesn't match any → discard at ingest (don't store, don't propagate). --- -## Reader side (FoF) +## Propagation -1. For each visible comment, use `CEK_comments` to decrypt ciphertext. -2. Verify `vouch_mac` matches a `V_x` the reader (or author in strict mode) can compute against. Optional render-time check; CDN already validated signatures. -3. Render comment body, parent threading via `parent_comment_id`. - -Reader side (non-FoF): sees comment ciphertext + signature fields only. Body unreadable. UI can show "N FoF comments (not visible to you)" or suppress entirely. - ---- - -## Revocation flow - -1. Author's client detects abuse (via `vouch_mac` attribution, or repeated `pub_x_index` with bad behavior). -2. Author builds `RevocationEntry { revoked_pub_x, timestamp, reason_code, author_sig }`, publishes as a header-diff on the post (same propagation primitive as engagement diffs). -3. On receiving a revocation diff, every file-holder of the affected post: - - **Deletes** all locally-stored comments on this post where `pub_x_index` points to the revoked `pub_x`. - - Appends the entry to its local copy of `post.revocation_list`. - - Forwards the revocation diff to neighbors. - - Rejects any subsequent incoming comments matching the revoked `pub_x` at step (2) of the accept rule. -4. Idempotent: re-receiving a revocation diff is a no-op (the entry is already present; deletion has already happened). -5. Retroactive: comments that propagated before the diff existed get deleted as the diff catches up to each holder. There's a propagation-latency window where deleted comments may still be visible on yet-to-receive holders, but the garbage is bounded and self-cleaning. -6. If the attacker's voucher-chain is broadly compromised, author can escalate to full `V_x` rotation (Layer 1), which affects every post using that chain — coarse but definitive. - ---- - -## Access-grant author comment (post-hoc widening) - -When the author vouches for a new persona AFTER publishing a FoF post, they may want that persona to retroactively gain read access. Full republish would rewrite the post ID and lose engagement history. Instead, the author publishes a special access-grant comment. - -### `AccessGrantComment` (a distinguished `InlineComment` variant) - -```rust -struct AccessGrantComment { - parent_post_id: PostId, - new_pub_x: [u8; 32], - new_wrap_slot: WrapSlot, // read + sign parts for the new V_x - granted_at_ms: u64, - commenter_id: NodeId, // must match post.author - identity_sig: [u8; 64], // author identity-key sig over (parent_post_id || new_pub_x || new_wrap_slot || granted_at_ms) -} -``` - -### Propagation-node handling - -- Accept iff `commenter_id == post.author` AND `identity_sig` validates against `post.author`. No `group_sig` / `pub_x_index` required — this is an author-signed header extension disguised as a comment. -- On accept: append `new_pub_x` to local `pub_post_set`, append `new_wrap_slot` to local `wrap_slots`. Forward the access-grant to neighbors. -- Subsequent comments from the newly-admitted persona reference the extended-set index (base_set.len() + grant_index). - -### Reader-side handling - -- New persona receives the access-grant via normal comment propagation. Unwraps `new_wrap_slot` with their `V_x` → gets `CEK` + `priv_x_new`. Can now read the body's comments and author their own. -- Existing readers see the access-grant as an informational entry (optional UI: "Author granted access to a new friend"). - -### Open question - -- **Does the UI expose this as a discrete action?** "Share this post with my new vouchee Bob" — yes, natural. Lead leaning: surface as a per-post affordance in author's post-detail view. -- **Does the access-grant also carry a signing capability for the new persona?** Yes — the `WrapSlot`'s sign-part wraps a fresh `priv_x_new`. New persona can comment going forward. -- **Does revocation apply to access-grant `pub_x` entries?** Yes, uniformly. Author can revoke a post-hoc-granted chain same as an originally-granted chain. - ---- - -## Size budget - -**v1 (Ed25519)** - -| Per-vouchee slot cost | Bytes | -|---|---| -| `pub_x` | 32 | -| `read_slot` (32B CEK + 12B nonce + 16B tag) | 60 | -| `sign_slot` (32B priv_x + 12B nonce + 16B tag) | 60 | -| `prefilter_tag` | 2 | -| **Subtotal per slot** | **~154** | - -At 500 real vouchees → next bucket above 256 is 512 → 512 slots × ~154B ≈ 79KB. At 200 real vouchees → bucket 256 ≈ 39KB. At 9 real vouchees → bucket 16 ≈ 2.5KB. Worst-case in-bucket overhead: just-under-boundary case (e.g., 257 real → 384 bucket → 33% padded; 129 real → 256 bucket → 49% padded; well below pure power-of-2 doubling). Acceptable for a header carried once per post. - -Per-comment CDN overhead (vs shared-keypair baseline): +64B (`group_sig`) + 4B (`pub_x_index`) + 64B (`identity_sig` — already in baseline) ≈ 68B additional. Negligible. - -**PQ future (ML-DSA-65)** - -`pub_x` ~1952B, sig ~3300B. Inline `pub_post_set` at 500 vouchees = ~976KB header. Not viable. - -Plan: Merkle-commit `pub_post_set`. Header carries 32B root. Each comment carries its `pub_x + inclusion_proof` (~288B at n=500) + the ~3.3KB sig. Header stays O(1); per-comment grows O(log n). - -Decision: **defer Merkle variant to PQ migration**. v1 ships inline. - ---- - -## Privacy tradeoff (accepted) - -`pub_x_index` leaks per-post voucher-chain pseudonym to CDN + public observers. "All comments signed under slot 42 came through the same chain" — within a single post. Cross-post, the index-to-chain mapping re-randomizes (new `pub_post_set` order on each publish), so cross-post correlation is broken. - -The reason this is acceptable: the alternative is no propagation-level verification, which means any one admitted FoF member can DoS the mesh. Per-post pseudonym is the minimum viable disclosure for CDN-level filtering. - -**Vouch-set size:** bucketed padding throughout. Observer learns the bucket; position within the bucket is fully hidden. Buckets are 8, 16, 32, 64, 128, 256 (power-of-2 sub-256) then 384, 512, 640, 768, … (+128 steps above 256). The same author's bucket is stable across posts unless they cross a boundary — so observers see "this author has between X and Y real vouchees" with no way to converge tighter from multiple posts. - -**Non-FoF reader UX:** non-FoF readers see the public body + "Comments are private" affordance. Comment count is not shown (no engagement leak). Optional: a "Request access via DM" button that sends the author a note; author can respond by publishing an access-grant author comment (above) that retroactively admits the requester. +No changes to comment propagation path. Comments still flow via existing `BlobHeaderDiff` / engagement-diff mechanism. Verification is done at display time (reader) and optionally at ingest (author-strict). --- ## Open questions -- **Reader-only clients can skip the sign-slot.** They can short-circuit AEAD on sign-slot and save some work. Worth implementing? Lead leaning: yes, minor perf win; `read` slot succeeding already proves FoF-admission. -- **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec. -- **Author's copy of CEK.** Author generated CEK at post creation; does author store it locally keyed by post_id, or unwrap via their own slot like any reader? Lead leaning: local-cache at creation; unwrap-fallback if cache lost. -- **Access-grant dedup.** If the author accidentally publishes the same access-grant twice (or two devices race), propagation nodes must handle it idempotently. Keying by `(post_id, new_pub_x)` — duplicate = no-op. -- **Revocation of a `pub_x` that has no comments yet.** Fine: future comments under that `pub_x` will fail the accept rule, and the deletion step is a no-op. Confirmed harmless. -- **GC of `revocation_list`.** Grows unbounded across a post's lifetime. Realistically capped by vouch-set size. No GC needed in v1. - -## Resolved - -- **`(pub_x, priv_x)` lifecycle**: **per-post** (Scott confirmed). Author regenerates for each post's slot assembly. Matches the anonymous-slot model; no Layer 1 coordination. -- **`pub_post_set` vs `wrap_slots` alignment**: **1:1 with dummy pubkeys** (Scott's direction). Both padded together with `rand(32..=128)` dummies so observers can't infer vouch count, and can't infer a floor across multiple posts either. -- **Author's own entry**: **yes** (Scott confirmed). Author has their own `pub_me/priv_me` entry in `pub_post_set`; own comments pass the same accept rule. -- **Non-FoF comment visibility**: front-end shows "Comments are private" when no key unlocks. Optional "Request access via DM" affordance. Count NOT shown. Author can respond to a request by publishing an access-grant author comment. -- **Revocation semantics**: **retroactive delete + forward** (Scott's correction). File-holders delete comments signed by the revoked `pub_x` on arrival of the diff, then propagate. +- **Filter-out vs reject-at-storage for bad `group_sig`.** Filter-out preserves forensic trail and avoids double-verify cost. Reject-at-storage saves disk and sync bandwidth. Lead leaning: filter-out at render, reject-at-storage only on author-side strict mode. +- **`priv_post` distribution for Mode 2.** In Mode 1, `priv_post` is inside `wrap_slots`. In Mode 2, body is plaintext, but commenters still need `priv_post`. Options: (A) `wrap_slots` still present in Mode 2 headers, carrying only `priv_post`; (B) separate `comment_priv_key` control record distributed out of band. Lead leaning: A (uniform structure with Mode 1). +- **Wrap slot prefilter tag.** 2B = 65536 buckets, false-positive 1/65536. For Mode 2 this also defines the commenter-eligibility filter. TBD — OPUS: confirm 2B is enough for realistic keyring sizes. +- **Author's own comments.** Author has `priv_post` directly (generated it). Do they self-vouch-mac against `V_me`? Leaning: yes, so strict mode uniformly verifies all comments. +- **Displaying vouch path to the author.** Mode 2 strict mode knows which `V_x` a commenter arrived through. Should the author's UI show "comment arrived via your vouch to Alice" or keep it opaque? Lead leaning: opaque by default; optional power-user setting. +- **Rate-limiting / spam.** A malicious FoF member can flood comments. `vouch_mac` identifies the chain, so author can block a chain. Out-of-scope for Layer 2 ship (tracked separately). --- ## Ship criteria for Layer 2 -- `CommentPolicy::FriendsOfFriends` end-to-end (storage, protocol, UI picker). -- Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with `post_id` AAD. -- `pub_post_set` inline in header, 1:1 with `wrap_slots`, real + dummy entries. -- `pub_x_index` on comments. -- CEK_comments derived via HKDF; all comment bodies encrypted. -- Propagation nodes enforce four-check accept rule before forwarding. -- Revocation diff format + CDN honor path (retroactive delete + forward). -- Access-grant author comment mechanism for post-hoc read widening. -- Per-post ephemeral-keypair generation. -- Bucketed dummy padding on both `wrap_slots` and `pub_post_set` (power-of-2 up to 256, then +128 steps above), shuffled together. -- Back-compat: old clients can still render existing non-FoF posts; old comments on new FoF posts are rejected at CDN (missing required fields). -- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2 FoF. B comments (accepted, propagates). C comments (accepted via B's chain). D (unrelated) signs junk with fake `pub_x_index` pointing at a real entry (rejected at first-hop CDN: `group_sig` fails). D signs junk with `pub_x_index` pointing at a dummy (rejected: `group_sig` fails against a dummy random pubkey). A revokes B's `pub_x`: B's already-propagated comments are deleted on each file-holder as the diff sweeps through; B's subsequent comments rejected at first hop. A vouches for E after the post is live and publishes access-grant author comment; E can now read + comment retroactively. +- `CommentPolicy::FriendsOfFriends` variant exists end-to-end (storage, protocol, UI picker). +- Authors can create public posts with FoF-gated comments. +- `pub_post` / `priv_post` / `wrap_slots` generated on post creation, wrapped under author's full keyring. +- Commenters: client-side check of FoF eligibility before offering comment box; `group_sig` + `vouch_mac` attached on send. +- Readers: filter-out comments failing `group_sig` verification. +- Author strict-mode: optional ingress rejection on unknown `vouch_mac`. +- Back-compat: old clients see FoF posts as readable (body is public) but can't comment (missing `priv_post`); old comments on new posts are filtered at render. +- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot. diff --git a/docs/fof-spec/layer-3-mode1-fof-closed.md b/docs/fof-spec/layer-3-mode1-fof-closed.md index 944692f..6b5d8c0 100644 --- a/docs/fof-spec/layer-3-mode1-fof-closed.md +++ b/docs/fof-spec/layer-3-mode1-fof-closed.md @@ -1,10 +1,8 @@ # Layer 3 — Mode 1: `FOF_CLOSED` Posts -**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted under CEK; readership emerges from keyring intersection with `wrap_slots`. +**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`. -The wrap-slot structure, `pub_post_set`, per-`V_x` signing keypair, and CDN-level verification are all defined in [Layer 2](layer-2-mode2-fof-comments.md) (the canonical form). Layer 3 inherits Layer 2's structures unchanged — the Mode 1 vs Mode 2 distinction reduces to: **body encrypted under CEK (Mode 1) vs body plaintext (Mode 2)**. Wrap-slot read-part still carries the CEK in both modes; in Mode 2 the CEK is used only to derive `CEK_comments`, in Mode 1 it is used for both body and comments. - -The legacy `pub_post`/`priv_post` field names that appear in some sections below are retained for readability; semantically read them as the canonical per-V_x `(pub_x, priv_x)` from Layer 2. +Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`). --- @@ -22,12 +20,11 @@ The legacy `pub_post`/`priv_post` field names that appear in some sections below ## Lead decisions - **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds. -- **One wrap slot per unique `V_x`.** Dedup at the `V_x` byte level — if multiple personas hold the same `V_x`, include one slot. Friends-only post: one slot under `V_me`. FoF post: own `V_me` + every distinct `V_x` the author holds. Custom: subset chosen by author (deferred to v2 power-user UI; v1 ships three presets only: Public / Friends-only / Friends-of-Friends). -- **Bucketed slot-count padding.** Deterministic bucket boundaries throughout — observers learn the bucket, not the position within it. Buckets: 8, 16, 32, 64, 128, 256 (power-of-2 from a minimum of 8 up to 256), then 384, 512, 640, 768, … (linear +128 steps above 256). Author publishes `next_bucket(real_count)` slots with random dummies filling the gap. Minimum bucket of 8 means a brand-new persona's first post still publishes 8 slots — no "this persona has no vouchees" signal. Power-of-2 sub-256 keeps small-author overhead bounded; +128 steps above 256 avoid the 2× waste of pure power-of-2 at scale. Dummy slots are byte-identical to real ones, AEAD-fails on any `V_x`. Dummy entries also added to `pub_post_set` 1:1 (see Layer 2). -- **Bucketed body-size padding.** Same shape applied to body ciphertext bytes: power-of-2 buckets up to 256KB (1KB, 2KB, 4KB, …, 256KB), then 256KB-step buckets above (512KB, 768KB, 1024KB, …). 256KB above is the future storage chunk size — once large enough to chunk, padding aligns to chunk boundaries naturally. -- **Each slot carries both CEK and priv_x (Layer 2 dual-derivation).** Layer 2's `WrapSlot` dual-derivation (read → CEK, sign → priv_x) is the canonical form. Mode 1 simply also uses the CEK to encrypt the body, where Mode 2 leaves the body plaintext. +- **One wrap slot per `V_x` in the author's keyring.** For a Friends-only post, one slot under `V_me`. For FoF, N+1 slots (one per `V_x` the author holds + own `V_me`). For Custom, subset chosen by author. +- **Slot count padded to power-of-2.** Prevents observers from counting vouchers the author has. TBD — OPUS: confirm padding up to next power of 2 with random dummy slots (non-decryptable ciphertext indistinguishable from real slots). +- **Each slot carries both CEK and priv_post.** Wrapped together as a single plaintext. One successful unwrap gives reader everything they need to read body + sign comments. - **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average. -- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. Re-shuffled on every header revision (including access-grant appends from Layer 2 — TBD whether append-only ordering is acceptable, or whether the entire set is re-shuffled at each grant). +- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher. --- @@ -122,27 +119,21 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po ## Open questions +- **Slot size uniformity.** Real slots and dummy padding slots must be byte-identical-sized. Confirmed. TBD — OPUS: should we also pad the body length to a bucket to avoid length-based classification? - **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable. - **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue. - **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak. - -## Resolved (2026-04-24) - -- **Slot count padding**: bucketed throughout. Power-of-2 buckets from 8 to 256 (minimum bucket is 8 — singleton/tiny-set posts pad up to 8 to avoid leaking "new persona with no vouchees"), then linear +128 buckets (384, 512, 640, …). Body-size padding follows the same shape with 256KB as the power-of-2 ceiling and 256KB linear steps above. -- **Access-grant ordering**: **append at tail** (Scott's call). New entries land at the end of `pub_post_set` + `wrap_slots`. `pub_x_index` values in already-stored comments stay valid. Small positional-recency leak (tail = recent grants) is the accepted cost. -- **Custom mode UI**: deferred. v1 ships only the three presets (Public / Friends-only / FoF). Power-user custom-subset UI is v2. -- **Slot deduplication**: dedup at the `V_x` byte level. One slot per unique key. -- **Body length padding**: yes — pad to next power of 2 up to 256KB, then 256KB chunks above. +- **Custom mode slot selection.** Does the author UI let them pick specific vouchers, specific groups of vouchers, or only "all held + own" vs "own only"? Lead leaning: initial UI = only the three preset levels (Friends-only / FoF / Public); custom ships as power-user option later. +- **Deduplication of `V_x` across personas.** If multiple personas hold the same `V_x`, do we include one slot or one-per-persona? Lead leaning: dedup at the `V_x` bytes level; one slot per unique key. --- ## Ship criteria for Layer 3 - `PostVisibility::FoFClosed` exists end-to-end. -- Author creation path generates per-post keypairs, wraps CEK+priv_x under each unique `V_x` (deduped), and pads to the next slot bucket: power-of-2 up to 256, then +128 steps above. -- Body-size padded to next body bucket: power-of-2 up to 256KB, then 256KB steps above. +- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2. - Reader decryption path iterates personas × keyring with prefilter tag. - `receive_post` accepts FoFClosed ciphertext without decrypting. -- UI surface: post composer has three presets — Public / Friends-only / Friends-of-Friends. Custom subset is v2. +- UI surface: post composer has Public / Friends-only / FoF / Custom picker. - Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt. - Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible). diff --git a/docs/fof-spec/layer-4-keypair-rotation.md b/docs/fof-spec/layer-4-keypair-rotation.md index 7399487..68f5339 100644 --- a/docs/fof-spec/layer-4-keypair-rotation.md +++ b/docs/fof-spec/layer-4-keypair-rotation.md @@ -1,144 +1,122 @@ -# Layer 4 — Rotation, Revocation, and Key Lifecycle +# Layer 4 — Per-Post Keypair Rotation -**Scope**: How an author narrows access on a published post, and how a persona rotates their own `V_me`. Most of the mechanism already exists in Layers 1–2. Layer 4's job is to lock in the policy and add the local-only provenance table that makes selective cascades possible. +**Scope**: Graceful rotation of `(priv_post, pub_post)` when the author's FoF set changes (new vouches granted, `V_me` rotated, or a vouchee removed). Old comments remain verifiable under the old `pub_post`; new comments require the new `pub_post`. --- ## Goal -- An author can narrow comment authority on a published post via Layer 2 revocation (default, cheap). -- An author can narrow read access by republishing the post with a new key and narrower wrap_slots (advanced, network-heavy, opt-in per-post). -- A persona rotates `V_me` to remove a vouchee. The new `V_me` is issued to all non-revoked vouchees via the next bio-post wrapper batch. The revoked vouchee retains the old `V_me`. -- Old posts (sealed under any era of `V_me`) stay readable by their original audience — no automatic rewrite. The CDN does not auto-cascade comment deletions on rotation. -- Authors retain *per-post discretion* to cascade comment revocations onto old posts when they want to, by publishing per-pub_x revocation diffs against pub_x's they know were sealed under the old `V_me`. -- An optional **key-burn** primitive lets an author scrub a specific `V_me` from a specific post's wrap_slots in-place, for the narrow case of a leaked `V_me`. +- Author can update the FoF set of an existing post without deleting / recreating it. +- A `PostKeyRotation` record, signed by author identity key, carries a new `(priv_post', pub_post')` wrapped under the current keyring. +- Existing comments under old `pub_post` stay cryptographically valid. +- New comments must sign under `priv_post'`. +- Readers who were admitted under the OLD set but not the NEW one retain read access to the body (CEK didn't change) but can no longer produce accepted comments. --- ## Lead decisions -- **Default = Layer 2 revocation.** Narrowing comment authority on a single published post: author signs a `RevocationEntry` for a specific `pub_x`, propagation nodes delete locally-stored comments by that signer, remove the entry from `pub_post_set`, and forward. No new wire primitive. -- **Advanced = full re-issue.** Author publishes a fresh post with new `(CEK, pub_post_set, wrap_slots)` under a narrower V_x set, optionally with a `supersedes_post_id` link for engagement continuity. Old post stays at its old `post_id` or is locally deleted. Heavy: re-encrypts body, rebuilds engagement context, costs bandwidth. Used only when narrowing READ access matters more than retaining the post in its current form. -- **`V_me` rotation IS the persona-wide revocation primitive.** No separate "kill V_me" wire message. To remove a vouchee, the persona generates a new `V_me_new` and distributes it to every current vouchee except the revoked one via the next bio-post batch (Layer 1 mechanism, unchanged). The revoked vouchee's only key remains `V_me_old`. -- **Receiver keeps the chain (Option A).** When a persona receives `V_B_new` from Party B, they append it to `vouch_keys_received` rather than overwrite. They now hold `{V_B_old, V_B_new}` (and any earlier epochs). On any wrap_slot unwrap attempt, the client trials every epoch in the chain. UX: the "current" key for outgoing operations is the latest received; older epochs are archived but kept for reading historical content. -- **Old posts are grandfathered by default.** `V_me` rotation does NOT automatically trigger CDN comment deletion. CDN's revocation primitive operates on `pub_x`, not `V_me` — and CDN is V_me-blind. After rotation, the revoked vouchee retains the old `V_me`, retains read access to posts with V_me_old slots, and retains comment authority on those posts unless the author explicitly publishes per-pub_x revocations. -- **Per-post cascade is opt-in by the author.** The author can choose to cascade a V_me rotation onto a specific old post by publishing a `RevocationEntry` for the pub_x's that were sealed under the old V_me. The author knows this mapping locally (see `own_post_slot_provenance` below); the CDN does not. Cascade can be all posts (batch action) or a chosen subset. -- **Key burn is optional, narrow scope.** For the case where `V_me_old` has *leaked* (not just rotated for turnover), the author may publish a signed header-diff on a specific post that swaps the V_old wrap_slot for a V_new wrap_slot in-place. Same propagation primitive as revocation and access-grant. Scrubs V_old from the CDN copy of that post. Future observers acquiring leaked V_old can no longer fresh-decrypt the body. Locally-cached plaintext on existing readers' devices is unrecoverable by any wire mechanism (out of scope). -- **`vouch_keys_own` retains multi-epoch rows.** Old `V_me` epochs are never deleted automatically. May be deleted by explicit user action with a prominent warning ("this prevents future re-keys / cascades on any post sealed under this epoch"). +- **Body CEK is NOT rotated.** Only the signing keypair rotates. Rotation's purpose is to narrow (or widen) the *commenter* set going forward, not revoke read access to the body. Read access of already-distributed content is non-recoverable by design — if an author needs that, they delete the post. +- **Rotation is append-only.** Rotation records accumulate; `pub_post_n` is derived from the latest rotation record. Old `pub_post` values are retained for verifying already-posted comments. +- **Rotation is optional.** Simple case is a post with one immutable `pub_post`. Layer 4 adds the escape hatch; most posts never rotate. +- **Author-signed.** Only the post author (identity key) can rotate. Prevents an admitted commenter from rotating others out. --- -## What goes away from the original skeleton +## Data model -The skeleton's `PostKeyRotation` record (with `rotation_index`, `pub_post_index` on comments, time-bucketed signature verification) is removed entirely. Its job is done by: - -- Layer 2's `RevocationEntry` for the comment-narrowing case. -- Standard post-publish + optional `supersedes_post_id` for the re-issue case. -- In-place wrap_slot swap as a new, optional key-burn diff (below). - -What goes away: -- `PostKeyRotation` record type. -- `rotation_index` / `pub_post_index` field on comments. -- Time-bucketed cross-rotation signature verification. - -What stays: -- `RevocationEntry` from Layer 2. -- Standard post-publish path. -- `vouch_keys_own` and `vouch_keys_received` from Layer 1, both with multi-epoch retention. - ---- - -## Data model additions - -### `own_post_slot_provenance` (local only, never on wire) - -The author needs to know which slot in each of their posts was sealed under which V_x, so they can selectively cascade a V_me rotation onto old posts. This is author-local state, not transmitted. - -``` -own_post_slot_provenance( - author_persona_id BLOB, - post_id BLOB, - slot_index INTEGER, -- index into pub_post_set / wrap_slots - sealed_under_v_x_owner BLOB, -- which persona issued the V_x used (== author_persona_id for the author's own V_me slots) - sealed_under_v_x_epoch INTEGER, - pub_x BLOB(32), -- the pub_x in the post's pub_post_set for this slot - PRIMARY KEY (author_persona_id, post_id, slot_index) -) -``` - -Populated at post-publish time. Used at cascade time: `SELECT pub_x FROM own_post_slot_provenance WHERE author_persona_id = ? AND sealed_under_v_x_owner = ? AND sealed_under_v_x_epoch = ?` returns the pub_x list to revoke. - -### Optional `supersedes_post_id` field on `PostHeader` +### `PostKeyRotation` record ```rust -struct PostHeader { +struct PostKeyRotation { + post_id: PostId, + rotation_index: u32, // monotonic; 0 = original keypair (implicit), 1 = first rotation, etc. + new_pub_post: [u8; 32], + new_wrap_slots: Vec, // wraps new priv_post under current keyring (same as post creation) + superseded_at: u64, // ms; rotation timestamp + sig: [u8; 64], // author identity-key signature over the above +} +``` + +Persisted as a sidecar to the post. TBD — OPUS: whether this lives in its own table (`post_key_rotations`) or as a serialized column on the post. + +### Comment verification after rotation + +Each comment's `group_sig` is verified against a specific `pub_post`. Determination rule: + +- If `comment.created_at < rotation_1.superseded_at` → verify against `pub_post_0` (original). +- If `rotation_n.superseded_at ≤ comment.created_at < rotation_{n+1}.superseded_at` → verify against `rotation_n.new_pub_post`. +- If `comment.created_at ≥ latest_rotation.superseded_at` → verify against latest `new_pub_post`. + +TBD — OPUS: time-based bucketing requires trusting `comment.created_at`. Alternative: comment carries an explicit `pub_post_index` field pointing at which keypair generation it's signed under. Lead leaning: **explicit index field in comment**, avoids clock-skew ambiguity. + +### Extend `InlineComment` (from Layer 2) + +```rust +struct InlineComment { // ... existing fields ... - - #[serde(default, skip_serializing_if = "Option::is_none")] - supersedes_post_id: Option, + #[serde(default)] + pub_post_index: u32, // 0 for original keypair, n for rotation n + group_sig: ..., + vouch_mac: ..., } ``` -Used by the advanced re-issue path. Author identity sig covers it. Readers may display "this is a re-issued version of an earlier post." +--- -### `KeyBurnDiff` (header-diff type) +## Rotation flow (author side) -```rust -struct KeyBurnDiff { - post_id: PostId, - slot_index: u32, // which slot to swap - new_wrap_slot: WrapSlot, // sealed under V_x_new (typically author's V_me_new) - new_pub_x: [u8; 32], // corresponding pub_x for pub_post_set replacement - sealed_at_ms: u64, - author_sig: [u8; 64], // author identity-key sig over the above + parent_post_id -} -``` - -Propagation: same path as revocation diffs. File-holders apply by replacing `wrap_slots[slot_index]` and `pub_post_set[slot_index]` in their local copy of the post header. Forward to neighbors. Idempotent (re-applying with the same slot_index + same new pub_x is a no-op). +1. Author changes FoF-relevant state (new vouch granted, someone un-vouched, `V_me` rotated). +2. Author decides to re-gate a specific post's comments: UI action "rotate comment keys for this post." +3. Generate new `(priv_post', pub_post')`. +4. Re-wrap `priv_post'` under the author's CURRENT keyring (the same algorithm as initial post creation, Layer 3). +5. Build `PostKeyRotation` record, sign, publish. +6. Rotation record propagates via normal CDN (it's a diff on the post, same mechanism as engagement diffs). --- -## Author UX surfaces +## Reader/commenter side -- **"Remove this commenter from this post"** → `RevocationEntry` for that specific `pub_x`. Standard Layer 2 path. -- **"Rotate my vouch key"** (Settings) → generates a new `V_me` epoch in `vouch_keys_own`, sets current. Old epoch retained. Then offers: "Issue the new key to your existing vouchees?" → if yes, queues a fresh bio-post batch wrapping V_me_new for all current targets except those marked revoked. Standard Layer 1 mechanism. -- **"Cascade this rotation onto my old posts"** (offered after a rotation if it was triggered by a revoke action) → batch action that queries `own_post_slot_provenance` for the rotated-out epoch and publishes per-pub_x revocations on each affected post. Costs N RevocationEntries; can be done in background. Optional. Per-post selection allowed. -- **"Re-issue this post with narrower access"** (advanced) → opens compose with body pre-filled and `supersedes_post_id` set. New audience pickable. Old post optionally deleted on publish. -- **"Burn this leaked key from a post"** (rare) → publishes a `KeyBurnDiff` swapping the V_me_old slot for a V_me_new slot on a specific post. Offered when the user marks `V_me_old` as leaked. Can be batched across all the author's posts via the provenance table. +- On receiving a `PostKeyRotation` record, readers store it keyed by `(post_id, rotation_index)`. +- At comment-creation time: look up the **latest** rotation record for the parent post; trial-decrypt the new slots; if success, use `priv_post'` to sign, set `pub_post_index` to latest index. +- At comment-verification time: look up the rotation referenced by `comment.pub_post_index`; verify against that `pub_post`. + +If a reader can unwrap `rotation_n.wrap_slots` but NOT `rotation_{n+1}.wrap_slots`, they've been rotated out between n and n+1. They can still READ the body (CEK unchanged) and verify historical comments; they cannot author new comments after rotation n+1. --- -## Cascade decision tree (for the author) +## Propagation -| Scenario | Default action | Optional escalation | -|---|---|---| -| Vouchee unfollowed, casual cleanup | Rotate V_me. Old posts grandfathered. | None. | -| Vouchee misbehaving on one specific post | Layer 2 revocation on that post's `pub_x`. | None. | -| Vouchee misbehaving across many posts | Rotate V_me. Cascade revocations onto every post they ever had access to. | Optional follow-up: key-burn if their continued read access is unacceptable. | -| V_me_old leaked | Rotate V_me. Cascade revocations onto all affected posts. | Key-burn V_me_old slots on every affected post. | +Rotation records are signed post-deltas. They reuse the existing `BlobHeaderDiff` propagation mechanism — the post header gains the rotation's `pub_post` and `wrap_slots`; readers pull updated posts through the normal CDN. + +TBD — OPUS: whether rotation records are part of the post's BlobHeader diff (natural fit) or a separate post-referencing record (cleaner separation but more protocol surface). + +--- + +## Edge cases + +- **Multiple rotations in short succession.** Monotonic index + explicit `pub_post_index` on comments disambiguates. No clock dependency. +- **Comment authored against stale rotation.** If a commenter creates a comment under rotation n, but by the time it propagates, rotation n+1 exists — the comment is still valid against rotation n. Readers verify against the rotation the comment declares. +- **Attacker forges rotation.** Rotation is signed by author identity key. Forgery == identity key compromise, which is outside FoF scope. +- **Reader never sees rotation record but sees new comments.** Until the rotation record arrives, new comments appear "unverified." Filter-out at render leaves them as pending until rotation arrives. Standard eventually-consistent behavior. --- ## Open questions -- **Bio-post batch contents during rotation.** Default: wrap only the new V_me epoch in the next batch. Advanced UI option: wrap multiple epochs for vouchees who lost their device and need to re-bootstrap. Lead leaning: default to new-only; advanced toggle for multi-epoch. -- **`supersedes_post_id` binding strength.** The post-header `author_sig` already covers the field. Sufficient, or do we want a reciprocal "this post has been superseded" diff on the old post for symmetric discoverability? Lead leaning: one-way link is sufficient; old post being deleted or not is independent. -- **Key-burn vs. body re-encryption.** Key-burn swaps wrap_slots but keeps the body ciphertext (still encrypted under the same CEK). A reader who unwrapped via V_me_old still has CEK cached locally; their local plaintext copy is unaffected. Key-burn only prevents *fresh* decryption of the wire ciphertext. Is that sufficient, or should key-burn also imply CEK rotation? CEK rotation = re-encrypting the body = essentially full re-issue. Lead leaning: key-burn does NOT rotate CEK; it's specifically the "scrub V_old from headers" primitive. Full re-issue remains available if CEK rotation is wanted. -- **`own_post_slot_provenance` export.** Lost on device wipe means the author can't cascade-revoke after a fresh install. Lead leaning: include in identity export bundle. -- **Garbage-collecting `vouch_keys_own` ancient epochs.** Never auto-GC. User-explicit only, with warning. +- **Rotation on `V_me` rotation.** When a persona rotates their own `V_me`, do we auto-rotate every one of their open FoFClosed posts? Cost is O(posts × eligible_keys). Lead leaning: **no auto-rotation**; user opts in per-post. Posts without rotation continue to accept comments from the old keyring (which still holds the old `V_me`). +- **Garbage-collecting old rotation records.** They're needed to verify historical comments. Never GC'd? Or keep only most recent N? Lead leaning: keep all; historical comments don't re-verify often and the data is cheap. +- **UI for rotation.** "Update who can comment" button on post. Simple. No scheduling / batch rotation UI in v1. +- **Rotation without keyring change.** Could be used to kick out a specific commenter by rotating and manually excluding their winning `V_x`. But winning `V_x` isn't known to the author (wrap slots are anonymous). Practical effect: authors can widen or narrow the whole set, not surgically exclude one person, without additional support. --- ## Ship criteria for Layer 4 -- `vouch_keys_own` retains multi-epoch rows without auto-deletion on rotation. -- `vouch_keys_received` retains multi-epoch rows; trial-unwrap iterates the chain per voucher. -- `own_post_slot_provenance` table populated at every post-publish. -- Author UI: "Rotate my vouch key" with optional follow-up "Issue to existing vouchees." -- Author UI: "Cascade revocations onto my old posts" as a post-rotation action. -- Author UI: "Re-issue this post with narrower access" (advanced). -- Author UI: "Burn leaked key" as a rare/explicit action. -- `KeyBurnDiff` propagation: same path as revocation diffs; idempotent application. -- `supersedes_post_id` field on `PostHeader` is wire-defined and back-compat (default None). -- No `PostKeyRotation` record exists. -- Integration test: A vouches for B and C. A posts FoF post P sealed under V_a (and other V_x's). B and C read + comment. A rotates V_a → V_a' to remove C; issues V_a' to B only. C still holds V_a; A's new posts (sealed only under V_a') invisible to C, visible to B. C can still read P (V_a-sealed slot still in P's wrap_slots); A optionally cascades a revocation on P that removes C's pub_x and deletes C's comments on P. A optionally key-burns V_a from P, swapping the V_a slot for a V_a' slot — C can no longer fresh-decrypt P from the wire (already-cached plaintext on C's device unaffected, out of scope). +- `PostKeyRotation` record type exists end-to-end. +- Author UI action: "Update who can comment on this post." +- Rotation records propagate via CDN. +- Comment signing uses latest rotation's `priv_post`; `pub_post_index` attached. +- Comment verification routes to the correct `pub_post` via index. +- Back-compat: posts without rotation records are handled as `pub_post_index = 0` uniformly. +- Integration test: A posts FoFClosed. B comments (admitted). A vouches for C, rotates. C comments (admitted). Old B-comment still verifies; new B-comment still verifies (B still in keyring); new D-comment (never admitted) rejected. diff --git a/docs/fof-spec/layer-5-prefilter-and-cache.md b/docs/fof-spec/layer-5-prefilter-and-cache.md index 947fe09..6194a2d 100644 --- a/docs/fof-spec/layer-5-prefilter-and-cache.md +++ b/docs/fof-spec/layer-5-prefilter-and-cache.md @@ -18,7 +18,7 @@ This layer is load-bearing, not optional. Without it, a modest keyring × slot m - **Cache the winning `(persona, V_x)` per author.** First time persona `P` decrypts an FoFClosed post from author `A` using `V_x`, remember the tuple. Next post from `A`: try that `(P, V_x)` first. Author almost always re-wraps under the same set. - **Track unreadable posts.** If no key currently held unwraps a post, insert into `vouch_unreadable_posts` for later retry. Clearing this set is cheap and necessary — a newly-received `V_x` potentially unlocks an arbitrary number of old posts. -- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds the post's CEK + every `priv_x` directly from creation (author-local cache, keyed by `post_id`). No wrap-slot iteration needed. +- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds `priv_post` implicitly (author-local cache of per-post keypairs). No wrap-slot iteration needed. - **Prefilter tag precompute per-post, not per-feed-fetch.** At ingest time (once per post received), compute the reader's full set of `HMAC(V_x, post_id)[:2B]` tags and note which slots have matching prefilter values. Cache that index. Avoids recomputing HMACs on every re-render. --- @@ -76,7 +76,7 @@ TBD — OPUS: whether this table is worth it. Alternative: keep `wrap_slots` as On incoming FoFClosed post from author `A`: -1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull CEK (and any needed `priv_x`) from local author cache keyed by `post_id`. Done. +1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull `priv_post` from local author cache. Done. 2. **Cache lookup**: Query `vouch_unlock_cache` for `(any_persona, A)`. For each cached winning `V_x`: - Compute `prefilter_tag = HMAC(V_x, post_id)[:2B]`. - Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open. diff --git a/docs/fof-spec/layer-6-revocation.md b/docs/fof-spec/layer-6-revocation.md index 8104294..cea5e37 100644 --- a/docs/fof-spec/layer-6-revocation.md +++ b/docs/fof-spec/layer-6-revocation.md @@ -1,8 +1,8 @@ # Layer 6 — Revocation & Rotation Cascades -**Status**: **Superseded by [Layer 4](layer-4-keypair-rotation.md) (2026-05-13).** Layer 4 settled the revocation and cascade design: V_me rotation as the persona-wide revocation primitive, receiver-chain storage, grandfather-by-default with author-opt-in per-post cascades, and the optional `KeyBurnDiff` primitive for leaked-V_me scenarios. Nothing in Layer 6 below is load-bearing; this file is retained as a record of the alternatives that were considered before Layer 4 was written. +**Status**: Stub. May not be in v1. Drafted for design review only. -**Original scope (now resolved by Layer 4)**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched. +**Scope**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched. --- diff --git a/frontend/app.js b/frontend/app.js index 1840b8e..e01fe3b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -486,18 +486,11 @@ 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 { @@ -586,29 +579,6 @@ 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 `
@@ -823,9 +793,6 @@ 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; @@ -980,7 +947,6 @@ 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'; @@ -1667,10 +1633,8 @@ 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); @@ -1690,9 +1654,6 @@ async function openBioModal(nodeId, preloadedName) { ${following ? `` : ``} - ${isVouched - ? `` - : ``} ${isIgnored ? `` @@ -1739,27 +1700,6 @@ 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}

`; } @@ -2663,40 +2603,8 @@ async function doPost() { } } - const commentPerm = document.getElementById('comment-perm-select').value; - const reactPerm = document.getElementById('react-perm-select').value; - let result; - if (commentPerm === 'friends_of_friends') { - // FoF Layer 2: body is still public (Mode 2) but the post - // carries a fof_gating block built from the author's - // keyring. Routed through a dedicated command because the - // gating block is signed at publish time (can't be added - // via SetPolicy after the fact). - if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF posts with attachments or non-default persona not yet supported.'); - postBtn.disabled = false; - return; - } - const created = await invoke('create_post_with_fof_comments', { - 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) { + if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { const bytes = new Uint8Array(f.data); @@ -2710,9 +2618,9 @@ async function doPost() { result = await invoke('create_post', params); } - // Set engagement policy if non-default (FoF posts also publish - // the policy diff so receivers route the comment-receive path - // through the FoF four-check verify gate). + // Set engagement policy if non-default + const commentPerm = document.getElementById('comment-perm-select').value; + const reactPerm = document.getElementById('react-perm-select').value; if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) { try { await invoke('set_comment_policy', { @@ -3154,16 +3062,7 @@ document.querySelectorAll('.tab').forEach(tab => { loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); } - if (target === 'settings') { - loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); - loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); loadVouches(); - // FoF Layer 4: rotate button wires once per settings open. - const rotateBtn = document.getElementById('rotate-v-me-btn'); - if (rotateBtn && !rotateBtn._wired) { - rotateBtn.addEventListener('click', rotateVMe); - rotateBtn._wired = true; - } - } + if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); } }); }); }); @@ -3719,79 +3618,6 @@ async function loadIgnored() { } } -async function rotateVMe() { - const btn = document.getElementById('rotate-v-me-btn'); - const status = document.getElementById('rotate-v-me-status'); - if (!btn) return; - if (!confirm('Rotate your vouch key? A new key will be issued to all current vouchees via your next bio post. Old posts remain readable to anyone who held the old key — cascade-revoke separately if you want to cut off old-content access.')) return; - btn.disabled = true; - status.textContent = 'Rotating…'; - try { - const { newEpoch } = await invoke('rotate_v_me'); - status.textContent = `Rotated → epoch ${newEpoch}`; - toast(`Vouch key rotated to epoch ${newEpoch}`); - loadVouches(); - } catch (e) { - status.textContent = ''; - toast('Error: ' + e); - } finally { - btn.disabled = false; - } -} - -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 e415fd8..87d9c83 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -104,8 +104,6 @@