Compare commits
34 commits
1fdf9a94cc
...
d46fcb4ef4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d46fcb4ef4 | ||
|
|
4ec3a80b6c | ||
|
|
aa190db375 | ||
|
|
12a305889e | ||
|
|
ce710a6596 | ||
|
|
fdbf97f2d7 | ||
|
|
c2f2203331 | ||
|
|
c0de21d37b | ||
|
|
66b78041fc | ||
|
|
856f386231 | ||
|
|
10de3f6108 | ||
|
|
96118d7ce8 | ||
|
|
6a76adef8f | ||
|
|
583033e065 | ||
|
|
63ff5ad6eb | ||
|
|
00522f4c4b | ||
|
|
673f9e2261 | ||
|
|
bdcd2142cd | ||
|
|
0f5147a31c | ||
|
|
74fec3b1fb | ||
|
|
34c5b60686 | ||
|
|
d1afcec26a | ||
|
|
3ee5c30ad2 | ||
|
|
bc008c5049 | ||
|
|
8a53d83306 | ||
|
|
d7ce2f734c | ||
|
|
73b1e24f9a | ||
|
|
971766cb3c | ||
|
|
4123e032cb | ||
|
|
9040d70bf6 | ||
|
|
3ee20736aa | ||
|
|
a79cab049f | ||
|
|
553fbd3a20 | ||
|
|
b8b38a6f03 |
34 changed files with 6275 additions and 254 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsgoin-cli"
|
name = "itsgoin-cli"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hex",
|
"hex",
|
||||||
|
|
@ -2744,7 +2744,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsgoin-core"
|
name = "itsgoin-core"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2767,7 +2767,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-cli"
|
name = "itsgoin-cli"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -905,6 +905,7 @@ async fn print_post(
|
||||||
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
||||||
format!(" [group-encrypted, epoch {}]", epoch)
|
format!(" [group-encrypted, epoch {}]", epoch)
|
||||||
}
|
}
|
||||||
|
itsgoin_core::types::PostVisibility::FoFClosed => " [fof-closed]".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ts = post.timestamp_ms / 1000;
|
let ts = post.timestamp_ms / 1000;
|
||||||
|
|
@ -917,6 +918,10 @@ async fn print_post(
|
||||||
Some(text) => text.to_string(),
|
Some(text) => text.to_string(),
|
||||||
None => "(encrypted)".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!("---");
|
println!("---");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-core"
|
name = "itsgoin-core"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,8 @@ pub fn build_announcement_post(
|
||||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6171,6 +6171,53 @@ impl ConnectionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::types::CommentPermission::Public => {}
|
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(
|
if !crate::crypto::verify_comment_signature(
|
||||||
&comment.author,
|
&comment.author,
|
||||||
|
|
@ -6201,6 +6248,63 @@ impl ConnectionManager {
|
||||||
let _ = storage.set_comment_policy(&payload.post_id, new_policy);
|
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 } => {
|
BlobHeaderDiffOp::ThreadSplit { new_post_id } => {
|
||||||
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
|
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
|
||||||
post_id: *new_post_id,
|
post_id: *new_post_id,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ mod tests {
|
||||||
content: "hello world".to_string(),
|
content: "hello world".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let id1 = compute_post_id(&post);
|
let id1 = compute_post_id(&post);
|
||||||
let id2 = compute_post_id(&post);
|
let id2 = compute_post_id(&post);
|
||||||
|
|
@ -36,12 +38,16 @@ mod tests {
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let post2 = Post {
|
let post2 = Post {
|
||||||
author: [1u8; 32],
|
author: [1u8; 32],
|
||||||
content: "world".to_string(),
|
content: "world".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
|
assert_ne!(compute_post_id(&post1), compute_post_id(&post2));
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +59,8 @@ mod tests {
|
||||||
content: "test".to_string(),
|
content: "test".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let id = compute_post_id(&post);
|
let id = compute_post_id(&post);
|
||||||
assert!(verify_post_id(&id, &post));
|
assert!(verify_post_id(&id, &post));
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,15 @@ 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 {
|
let stored = if let Some(intent) = intent {
|
||||||
s.store_post_with_intent(id, post, visibility, intent)?
|
s.store_post_with_intent(id, post, visibility, intent)?
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -155,6 +164,8 @@ pub fn build_delete_control_post(
|
||||||
content: serde_json::to_string(&op).unwrap_or_default(),
|
content: serde_json::to_string(&op).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +193,8 @@ pub fn build_visibility_control_post(
|
||||||
content: serde_json::to_string(&op).unwrap_or_default(),
|
content: serde_json::to_string(&op).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +225,8 @@ mod tests {
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let post_id = crate::content::compute_post_id(&post);
|
let post_id = crate::content::compute_post_id(&post);
|
||||||
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
||||||
|
|
@ -240,6 +255,8 @@ mod tests {
|
||||||
content: "hello".to_string(),
|
content: "hello".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let post_id = crate::content::compute_post_id(&post);
|
let post_id = crate::content::compute_post_id(&post);
|
||||||
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,23 @@ use crate::types::{GroupEpoch, GroupId, GroupMemberKey, NodeId, PostId, WrappedK
|
||||||
|
|
||||||
const CEK_WRAP_CONTEXT: &str = "itsgoin/cek-wrap/v1";
|
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.
|
/// 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] {
|
pub fn ed25519_seed_to_x25519_private(seed: &[u8; 32]) -> [u8; 32] {
|
||||||
let signing_key = SigningKey::from_bytes(seed);
|
let signing_key = SigningKey::from_bytes(seed);
|
||||||
|
|
@ -193,6 +210,260 @@ pub fn unwrap_group_cek(
|
||||||
Ok(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<Vec<u8>> {
|
||||||
|
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<u8>, // 48 bytes
|
||||||
|
pub sign_ciphertext: Vec<u8>, // 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<SealedWrapSlot> {
|
||||||
|
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<OpenedWrapSlot> {
|
||||||
|
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.
|
/// Encrypt a post with a provided CEK, wrapping for recipients.
|
||||||
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
|
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
|
||||||
pub fn encrypt_post_with_cek(
|
pub fn encrypt_post_with_cek(
|
||||||
|
|
@ -1347,4 +1618,150 @@ mod tests {
|
||||||
// Different calls produce different noise (with very high probability)
|
// Different calls produce different noise (with very high probability)
|
||||||
assert_ne!(random_slot_noise(64), random_slot_noise(64));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1965
crates/core/src/fof.rs
Normal file
1965
crates/core/src/fof.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -61,6 +61,8 @@ pub fn build_distribution_post(
|
||||||
content: ciphertext_b64,
|
content: ciphertext_b64,
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let post_id = compute_post_id(&post);
|
let post_id = compute_post_id(&post);
|
||||||
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
|
let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
|
||||||
|
|
@ -241,6 +243,8 @@ mod tests {
|
||||||
content: ciphertext,
|
content: ciphertext,
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 200,
|
timestamp_ms: 200,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };
|
let forged_vis = PostVisibility::Encrypted { recipients: wrapped };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ fn parse_exported_intent(raw: Option<&str>, vis: &PostVisibility) -> VisibilityI
|
||||||
// No intent recorded — infer from the visibility shape.
|
// No intent recorded — infer from the visibility shape.
|
||||||
match vis {
|
match vis {
|
||||||
PostVisibility::Public => VisibilityIntent::Public,
|
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 } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
// Heuristic: DMs typically wrap to 1-2 people (recipient + self);
|
||||||
// Friends posts wrap to every public follow (usually many).
|
// Friends posts wrap to every public follow (usually many).
|
||||||
|
|
@ -286,6 +289,8 @@ pub async fn import_as_personas(
|
||||||
content: ep.content.clone(),
|
content: ep.content.clone(),
|
||||||
attachments: attachments.clone(),
|
attachments: attachments.clone(),
|
||||||
timestamp_ms: ep.timestamp_ms,
|
timestamp_ms: ep.timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preserve the original visibility intent from the export.
|
// Preserve the original visibility intent from the export.
|
||||||
|
|
@ -459,6 +464,8 @@ pub async fn import_public_posts(
|
||||||
content: ep.content.clone(),
|
content: ep.content.clone(),
|
||||||
attachments: attachments.clone(),
|
attachments: attachments.clone(),
|
||||||
timestamp_ms: ep.timestamp_ms,
|
timestamp_ms: ep.timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read blob data from archive
|
// Read blob data from archive
|
||||||
|
|
@ -677,6 +684,16 @@ pub async fn merge_with_key(
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
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
|
// Create new post under our identity
|
||||||
|
|
@ -685,6 +702,8 @@ pub async fn merge_with_key(
|
||||||
content: plaintext,
|
content: plaintext,
|
||||||
attachments: attachments.clone(),
|
attachments: attachments.clone(),
|
||||||
timestamp_ms: ep.timestamp_ms,
|
timestamp_ms: ep.timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read blob data from archive (may need decryption for encrypted posts)
|
// Read blob data from archive (may need decryption for encrypted posts)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod crypto;
|
||||||
pub mod group_key_distribution;
|
pub mod group_key_distribution;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod fof;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod announcement;
|
pub mod announcement;
|
||||||
|
|
|
||||||
|
|
@ -2234,6 +2234,11 @@ pub fn should_send_post(
|
||||||
.map(|members| members.iter().any(|m| query_list.contains(m)))
|
.map(|members| members.iter().any(|m| query_list.contains(m)))
|
||||||
.unwrap_or(false)
|
.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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2253,6 +2258,8 @@ mod tests {
|
||||||
content: "test".to_string(),
|
content: "test".to_string(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,35 @@ pub struct Node {
|
||||||
budget_last_reset_ms: Arc<AtomicU64>,
|
budget_last_reset_ms: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl Node {
|
||||||
/// Create or open a node in the given data directory (Desktop profile)
|
/// Create or open a node in the given data directory (Desktop profile)
|
||||||
pub async fn open(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
|
pub async fn open(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
|
||||||
|
|
@ -133,6 +162,8 @@ impl Node {
|
||||||
created_at: now,
|
created_at: now,
|
||||||
})?;
|
})?;
|
||||||
s.set_default_posting_id(&nid)?;
|
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
|
// Mark this as the disposable auto-gen persona from the
|
||||||
// fresh-install flow. If the user subsequently imports, we
|
// fresh-install flow. If the user subsequently imports, we
|
||||||
// prune this id iff it's still pristine (no name, no posts,
|
// prune this id iff it's still pristine (no name, no posts,
|
||||||
|
|
@ -684,6 +715,12 @@ impl Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FoF Layer 1: every persona owns its own V_me (symmetric 32B key).
|
||||||
|
// Auto-generate epoch 1 at creation. Stored in vouch_keys_own with
|
||||||
|
// is_current=1. Per Layer 4, rotations append new epochs; this row
|
||||||
|
// is never deleted automatically.
|
||||||
|
ensure_initial_v_me(&self.storage, &node_id, now).await?;
|
||||||
|
|
||||||
Ok(identity)
|
Ok(identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -698,12 +735,22 @@ impl Node {
|
||||||
bio: &str,
|
bio: &str,
|
||||||
avatar_cid: Option<[u8; 32]>,
|
avatar_cid: Option<[u8; 32]>,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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(
|
let profile_post = crate::profile::build_profile_post(
|
||||||
posting_id,
|
posting_id,
|
||||||
posting_secret,
|
posting_secret,
|
||||||
display_name,
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_cid,
|
avatar_cid,
|
||||||
|
vouch_grants,
|
||||||
|
bio_epoch,
|
||||||
);
|
);
|
||||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||||
let timestamp_ms = profile_post.timestamp_ms;
|
let timestamp_ms = profile_post.timestamp_ms;
|
||||||
|
|
@ -772,12 +819,16 @@ impl Node {
|
||||||
avatar_cid: None,
|
avatar_cid: None,
|
||||||
timestamp_ms: pi.created_at,
|
timestamp_ms: pi.created_at,
|
||||||
signature,
|
signature,
|
||||||
|
vouch_grants: None,
|
||||||
|
bio_epoch: 0,
|
||||||
};
|
};
|
||||||
let post = Post {
|
let post = Post {
|
||||||
author: pi.node_id,
|
author: pi.node_id,
|
||||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms: pi.created_at,
|
timestamp_ms: pi.created_at,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let post_id = crate::content::compute_post_id(&post);
|
let post_id = crate::content::compute_post_id(&post);
|
||||||
{
|
{
|
||||||
|
|
@ -964,6 +1015,7 @@ impl Node {
|
||||||
content,
|
content,
|
||||||
intent,
|
intent,
|
||||||
attachment_data,
|
attachment_data,
|
||||||
|
None,
|
||||||
).await
|
).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -988,9 +1040,187 @@ impl Node {
|
||||||
content,
|
content,
|
||||||
intent,
|
intent,
|
||||||
attachment_data,
|
attachment_data,
|
||||||
|
None,
|
||||||
).await
|
).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<u8>, 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<Option<String>> {
|
||||||
|
use base64::Engine;
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
let (post, visibility) = match storage.get_post_with_visibility(post_id)? {
|
||||||
|
Some(pv) => pv,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
if !matches!(visibility, PostVisibility::FoFClosed) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let gating = match post.fof_gating.as_ref() {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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(
|
async fn create_post_inner(
|
||||||
&self,
|
&self,
|
||||||
posting_id: &NodeId,
|
posting_id: &NodeId,
|
||||||
|
|
@ -998,6 +1228,7 @@ impl Node {
|
||||||
content: String,
|
content: String,
|
||||||
intent: VisibilityIntent,
|
intent: VisibilityIntent,
|
||||||
attachment_data: Vec<(Vec<u8>, String)>,
|
attachment_data: Vec<(Vec<u8>, String)>,
|
||||||
|
fof_gating: Option<crate::types::FoFCommentGating>,
|
||||||
) -> anyhow::Result<(PostId, Post, PostVisibility)> {
|
) -> anyhow::Result<(PostId, Post, PostVisibility)> {
|
||||||
// Validate attachments
|
// Validate attachments
|
||||||
if attachment_data.len() > 4 {
|
if attachment_data.len() > 4 {
|
||||||
|
|
@ -1113,6 +1344,8 @@ impl Node {
|
||||||
content: final_content,
|
content: final_content,
|
||||||
attachments,
|
attachments,
|
||||||
timestamp_ms: now,
|
timestamp_ms: now,
|
||||||
|
fof_gating,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let post_id = compute_post_id(&post);
|
let post_id = compute_post_id(&post);
|
||||||
|
|
@ -1126,8 +1359,13 @@ impl Node {
|
||||||
let _ = storage.pin_blob(&att.cid);
|
let _ = storage.pin_blob(&att.cid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize encrypted receipt + comment slots for non-public posts
|
// Initialize encrypted receipt + comment slots for non-public posts.
|
||||||
if !matches!(visibility, PostVisibility::Public) {
|
// 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)
|
||||||
|
{
|
||||||
let participant_count = match &visibility {
|
let participant_count = match &visibility {
|
||||||
PostVisibility::Encrypted { recipients } => recipients.len(),
|
PostVisibility::Encrypted { recipients } => recipients.len(),
|
||||||
PostVisibility::GroupEncrypted { .. } => {
|
PostVisibility::GroupEncrypted { .. } => {
|
||||||
|
|
@ -1142,7 +1380,7 @@ impl Node {
|
||||||
_ => 2,
|
_ => 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PostVisibility::Public => unreachable!(),
|
PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
|
let receipt_slots: Vec<Vec<u8>> = (0..participant_count)
|
||||||
|
|
@ -1396,6 +1634,15 @@ impl Node {
|
||||||
).ok()
|
).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)
|
(id, post, vis, decrypted)
|
||||||
})
|
})
|
||||||
|
|
@ -1503,12 +1750,22 @@ impl Node {
|
||||||
storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid)
|
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(
|
let profile_post = crate::profile::build_profile_post(
|
||||||
&posting_id,
|
&posting_id,
|
||||||
&posting_secret,
|
&posting_secret,
|
||||||
&display_name,
|
&display_name,
|
||||||
&bio,
|
&bio,
|
||||||
avatar_cid,
|
avatar_cid,
|
||||||
|
vouch_grants,
|
||||||
|
bio_epoch,
|
||||||
);
|
);
|
||||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||||
let timestamp_ms = profile_post.timestamp_ms;
|
let timestamp_ms = profile_post.timestamp_ms;
|
||||||
|
|
@ -1637,6 +1894,220 @@ impl Node {
|
||||||
storage.get_display_name(node_id)
|
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<u32> {
|
||||||
|
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<usize> {
|
||||||
|
// 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<Vec<(NodeId, String, u64)>> {
|
||||||
|
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<Vec<(NodeId, String, u32, u64)>> {
|
||||||
|
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 ----
|
// ---- Blobs ----
|
||||||
|
|
||||||
/// Get a blob by CID from local store.
|
/// Get a blob by CID from local store.
|
||||||
|
|
@ -1684,6 +2155,15 @@ impl Node {
|
||||||
Ok(None)
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2807,6 +3287,9 @@ impl Node {
|
||||||
PostVisibility::GroupEncrypted { .. } => {
|
PostVisibility::GroupEncrypted { .. } => {
|
||||||
anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead")
|
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<NodeId> = existing_recipients
|
let new_recipient_ids: Vec<NodeId> = existing_recipients
|
||||||
|
|
@ -2886,6 +3369,8 @@ impl Node {
|
||||||
content: new_content,
|
content: new_content,
|
||||||
attachments: post.attachments.clone(),
|
attachments: post.attachments.clone(),
|
||||||
timestamp_ms: post.timestamp_ms,
|
timestamp_ms: post.timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
};
|
};
|
||||||
let new_post_id = compute_post_id(&new_post);
|
let new_post_id = compute_post_id(&new_post);
|
||||||
|
|
||||||
|
|
@ -4344,6 +4829,21 @@ impl Node {
|
||||||
post_id: PostId,
|
post_id: PostId,
|
||||||
content: String,
|
content: String,
|
||||||
) -> anyhow::Result<crate::types::InlineComment> {
|
) -> anyhow::Result<crate::types::InlineComment> {
|
||||||
|
// 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
|
self.comment_on_post_inner(post_id, content, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4391,6 +4891,9 @@ impl Node {
|
||||||
signature,
|
signature,
|
||||||
deleted_at: None,
|
deleted_at: None,
|
||||||
ref_post_id,
|
ref_post_id,
|
||||||
|
pub_x_index: None,
|
||||||
|
group_sig: None,
|
||||||
|
encrypted_payload: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
|
|
@ -4516,6 +5019,286 @@ impl Node {
|
||||||
Ok(())
|
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<crate::types::InlineComment> {
|
||||||
|
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.
|
/// Get the comment policy for a post.
|
||||||
pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result<Option<crate::types::CommentPolicy>> {
|
pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result<Option<crate::types::CommentPolicy>> {
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
|
|
@ -4608,6 +5391,11 @@ impl Node {
|
||||||
Ok(None)
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ pub fn apply_profile_post_if_applicable(
|
||||||
}
|
}
|
||||||
let content = verify_profile_post(post)?;
|
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).
|
// Only apply if newer than the stored row (last-writer-wins by timestamp).
|
||||||
if let Some(existing) = s.get_profile(&post.author)? {
|
if let Some(existing) = s.get_profile(&post.author)? {
|
||||||
if existing.updated_at >= content.timestamp_ms {
|
if existing.updated_at >= content.timestamp_ms {
|
||||||
|
|
@ -68,14 +76,113 @@ pub fn apply_profile_post_if_applicable(
|
||||||
Ok(())
|
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<u32> = 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
|
/// Build a Profile post signed by the posting identity. Caller is
|
||||||
/// responsible for storing and propagating it.
|
/// 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(
|
pub fn build_profile_post(
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
author_secret: &[u8; 32],
|
author_secret: &[u8; 32],
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
bio: &str,
|
bio: &str,
|
||||||
avatar_cid: Option<[u8; 32]>,
|
avatar_cid: Option<[u8; 32]>,
|
||||||
|
vouch_grants: Option<crate::types::VouchGrantBatch>,
|
||||||
|
bio_epoch: u32,
|
||||||
) -> Post {
|
) -> Post {
|
||||||
let timestamp_ms = std::time::SystemTime::now()
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -88,12 +195,16 @@ pub fn build_profile_post(
|
||||||
avatar_cid,
|
avatar_cid,
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
signature,
|
signature,
|
||||||
|
vouch_grants,
|
||||||
|
bio_epoch,
|
||||||
};
|
};
|
||||||
Post {
|
Post {
|
||||||
author: *author,
|
author: *author,
|
||||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,6 +214,110 @@ pub fn profile_post_visibility() -> PostVisibility {
|
||||||
PostVisibility::Public
|
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<Option<crate::types::VouchGrantBatch>> {
|
||||||
|
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<u8>> = 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.
|
/// Compute the `PostId` for a freshly-built profile post.
|
||||||
pub fn profile_post_id(post: &Post) -> PostId {
|
pub fn profile_post_id(post: &Post) -> PostId {
|
||||||
crate::content::compute_post_id(post)
|
crate::content::compute_post_id(post)
|
||||||
|
|
@ -130,7 +345,7 @@ mod tests {
|
||||||
let s = temp_storage();
|
let s = temp_storage();
|
||||||
let (sec, pub_id) = make_keypair(11);
|
let (sec, pub_id) = make_keypair(11);
|
||||||
|
|
||||||
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None);
|
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
|
||||||
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
||||||
|
|
@ -145,7 +360,7 @@ mod tests {
|
||||||
let (sec_b, _pub_b) = make_keypair(2);
|
let (sec_b, _pub_b) = make_keypair(2);
|
||||||
|
|
||||||
// Build a post claiming `pub_a` but signing with `sec_b`.
|
// Build a post claiming `pub_a` but signing with `sec_b`.
|
||||||
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None);
|
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
|
||||||
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
||||||
assert!(res.is_err());
|
assert!(res.is_err());
|
||||||
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
||||||
|
|
@ -157,7 +372,7 @@ mod tests {
|
||||||
let (sec, pub_id) = make_keypair(3);
|
let (sec, pub_id) = make_keypair(3);
|
||||||
|
|
||||||
// Seed with a newer profile.
|
// Seed with a newer profile.
|
||||||
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None);
|
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
|
||||||
// Hack the timestamp to make it clearly newer.
|
// Hack the timestamp to make it clearly newer.
|
||||||
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
||||||
content.timestamp_ms = 10_000;
|
content.timestamp_ms = 10_000;
|
||||||
|
|
@ -167,7 +382,7 @@ mod tests {
|
||||||
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
// Apply an older profile — should be ignored.
|
// Apply an older profile — should be ignored.
|
||||||
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None);
|
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
|
||||||
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
||||||
content_o.timestamp_ms = 5_000;
|
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);
|
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
|
||||||
|
|
@ -178,4 +393,162 @@ mod tests {
|
||||||
let stored = s.get_profile(&pub_id).unwrap().unwrap();
|
let stored = s.get_profile(&pub_id).unwrap().unwrap();
|
||||||
assert_eq!(stored.display_name, "NewName");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,22 @@ pub struct Post {
|
||||||
pub attachments: Vec<Attachment>,
|
pub attachments: Vec<Attachment>,
|
||||||
/// Unix timestamp in milliseconds
|
/// Unix timestamp in milliseconds
|
||||||
pub timestamp_ms: u64,
|
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<FoFCommentGating>,
|
||||||
|
/// 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<PostId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reference to a media blob attached to a post
|
/// A reference to a media blob attached to a post
|
||||||
|
|
@ -209,6 +225,16 @@ pub enum PostVisibility {
|
||||||
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
|
/// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
|
||||||
wrapped_cek: Vec<u8>,
|
wrapped_cek: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// 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 {
|
impl Default for PostVisibility {
|
||||||
|
|
@ -274,6 +300,50 @@ pub struct ProfilePostContent {
|
||||||
/// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
|
/// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
|
||||||
/// layout signed by the posting identity.
|
/// layout signed by the posting identity.
|
||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
|
/// 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<VouchGrantBatch>,
|
||||||
|
/// 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<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content payload of a `VisibilityIntent::Announcement` post.
|
/// Content payload of a `VisibilityIntent::Announcement` post.
|
||||||
|
|
@ -874,6 +944,9 @@ pub struct InlineComment {
|
||||||
pub post_id: PostId,
|
pub post_id: PostId,
|
||||||
/// Either the full comment text (short comments) or a short preview of
|
/// Either the full comment text (short comments) or a short preview of
|
||||||
/// the referenced post (when `ref_post_id` is set).
|
/// 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,
|
pub content: String,
|
||||||
/// When the comment was created (ms)
|
/// When the comment was created (ms)
|
||||||
pub timestamp_ms: u64,
|
pub timestamp_ms: u64,
|
||||||
|
|
@ -888,6 +961,23 @@ pub struct InlineComment {
|
||||||
/// for the expanded view.
|
/// for the expanded view.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ref_post_id: Option<PostId>,
|
pub ref_post_id: Option<PostId>,
|
||||||
|
/// 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<u32>,
|
||||||
|
/// 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<Vec<u8>>,
|
||||||
|
/// 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<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permission level for comments on a post
|
/// Permission level for comments on a post
|
||||||
|
|
@ -900,6 +990,13 @@ pub enum CommentPermission {
|
||||||
FollowersOnly,
|
FollowersOnly,
|
||||||
/// Comments disabled
|
/// Comments disabled
|
||||||
None,
|
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 {
|
impl Default for CommentPermission {
|
||||||
|
|
@ -943,6 +1040,65 @@ 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<u8>,
|
||||||
|
/// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B
|
||||||
|
/// (32B sealed priv_x ed25519 seed + 16B tag).
|
||||||
|
pub sign_ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<WrapSlot>,
|
||||||
|
/// Initially empty. Receivers accumulate revocations as diffs
|
||||||
|
/// arrive; the on-wire t=0 snapshot is empty.
|
||||||
|
#[serde(default)]
|
||||||
|
pub revocation_list: Vec<RevocationEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Author-controlled engagement policy for a post
|
/// Author-controlled engagement policy for a post
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct CommentPolicy {
|
pub struct CommentPolicy {
|
||||||
|
|
@ -981,6 +1137,50 @@ pub enum BlobHeaderDiffOp {
|
||||||
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
|
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
|
||||||
/// Add new encrypted comment slots (each 256 bytes)
|
/// Add new encrypted comment slots (each 256 bytes)
|
||||||
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
|
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
/// Unknown ops from newer protocol versions — silently ignored
|
/// Unknown ops from newer protocol versions — silently ignored
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -256,6 +256,10 @@ async fn post_to_dto(
|
||||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
|
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
|
||||||
None => ("encrypted".to_string(), None),
|
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 {
|
let recipients = match vis {
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
|
|
@ -346,6 +350,10 @@ async fn decrypt_just_created(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// FoF Layer 3: FoFClosed body decrypt happens via the dedicated
|
||||||
|
// async read_fof_closed_body command. This sync helper returns
|
||||||
|
// None and the frontend dispatches the FoF read explicitly.
|
||||||
|
PostVisibility::FoFClosed => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -910,6 +918,7 @@ async fn post_to_dto_batch(
|
||||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
|
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
|
||||||
None => ("encrypted".to_string(), None),
|
None => ("encrypted".to_string(), None),
|
||||||
},
|
},
|
||||||
|
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
|
||||||
};
|
};
|
||||||
let recipients = match vis {
|
let recipients = match vis {
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
|
|
@ -1089,6 +1098,187 @@ async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeer
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- FoF Layer 1: Vouches ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct VouchGivenDto {
|
||||||
|
node_id: String,
|
||||||
|
display_name: String,
|
||||||
|
granted_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct VouchReceivedDto {
|
||||||
|
node_id: String,
|
||||||
|
display_name: String,
|
||||||
|
epoch: u32,
|
||||||
|
received_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn 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.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<Vec<VouchGivenDto>, 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<Vec<VouchReceivedDto>, 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<FoFPostCreatedDto, String> {
|
||||||
|
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<FoFPostCreatedDto, String> {
|
||||||
|
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<Option<String>, 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<VmeRotatedDto, String> {
|
||||||
|
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<CascadeRevokeResultDto, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
|
@ -3103,6 +3293,18 @@ pub fn run() {
|
||||||
ignore_peer,
|
ignore_peer,
|
||||||
unignore_peer,
|
unignore_peer,
|
||||||
list_ignored_peers,
|
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,
|
list_circles,
|
||||||
create_circle,
|
create_circle,
|
||||||
delete_circle,
|
delete_circle,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"productName": "itsgoin",
|
"productName": "itsgoin",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"identifier": "com.itsgoin.app",
|
"identifier": "com.itsgoin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../frontend",
|
"frontendDist": "../../frontend",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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, distribution/exchange mechanism, minimal UI. No posts yet.
|
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.
|
||||||
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.
|
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.
|
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.
|
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).
|
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.** Deferred; may not be in v1. Drafted as a stub for design review.
|
6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Superseded by Layer 4. File retained as a record of alternatives considered.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -68,7 +68,9 @@ 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.
|
- **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}`.
|
- **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.
|
- **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_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.
|
- **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.
|
||||||
- **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.)
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|
@ -79,9 +81,11 @@ 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.
|
- **`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`.
|
- **`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).
|
- **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating).
|
||||||
- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`).
|
- **`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.
|
||||||
- **`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).
|
- **`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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Layer 1 — Vouch Primitive
|
# Layer 1 — Vouch Primitive
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -10,19 +10,27 @@ After Layer 1 ships:
|
||||||
|
|
||||||
- Each persona owns a current `V_me` symmetric key. New personas auto-generate one at creation.
|
- 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}`.
|
- Each persona has a keyring of received vouch keys: `{V_x : x has vouched for me}`.
|
||||||
- Users can view who they've vouched for, who has vouched for them, and revoke/rotate `V_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.
|
||||||
- Wire protocol can transfer `V_me` from voucher to vouchee inside an existing encrypted channel.
|
- 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).
|
||||||
- No post encryption or comment gating depends on Layer 1 yet — that arrives in Layer 2/3.
|
- No post encryption or comment gating depends on Layer 1 yet — that arrives in Layer 2/3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lead decisions
|
## Lead decisions
|
||||||
|
|
||||||
- **`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).
|
- **`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 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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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).
|
- **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.
|
||||||
- **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -32,68 +40,163 @@ After Layer 1 ships:
|
||||||
|
|
||||||
Per-persona, stores the persona's own `V_me` history (current + recent-past for graceful rotation).
|
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(
|
vouch_keys_own(
|
||||||
persona_id BLOB,
|
persona_id BLOB,
|
||||||
epoch INTEGER,
|
epoch INTEGER,
|
||||||
key_material BLOB, -- TBD — OPUS: symmetric key bytes (32B for a 256-bit key?)
|
key_material BLOB(32),
|
||||||
created_at_ms INTEGER,
|
created_at_ms INTEGER,
|
||||||
is_current INTEGER, -- 1 for the active V_me, 0 for retained past epochs
|
is_current INTEGER, -- 1 for active, 0 for retained past
|
||||||
PRIMARY KEY (persona_id, epoch)
|
PRIMARY KEY (persona_id, epoch)
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `vouch_keys_received` table
|
### `vouch_keys_received` table
|
||||||
|
|
||||||
Per-persona, stores vouch keys received from other personas (one row per `(owner_id, epoch)` currently held).
|
Per-persona keyring — vouch keys successfully unwrapped from others' bio posts.
|
||||||
|
|
||||||
```
|
```
|
||||||
vouch_keys_received(
|
vouch_keys_received(
|
||||||
holder_persona_id BLOB, -- whose keyring this entry belongs to
|
holder_persona_id BLOB, -- whose keyring this entry belongs to
|
||||||
owner_id BLOB, -- the persona who owns (issued) this V_x
|
owner_id BLOB, -- the persona who issued V_x
|
||||||
epoch INTEGER,
|
epoch INTEGER,
|
||||||
key_material BLOB,
|
key_material BLOB(32),
|
||||||
received_at_ms INTEGER,
|
received_at_ms INTEGER,
|
||||||
|
source_bio_post_id BLOB, -- provenance (which bio post we unwrapped from)
|
||||||
PRIMARY KEY (holder_persona_id, owner_id, epoch)
|
PRIMARY KEY (holder_persona_id, owner_id, epoch)
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
### `vouch_bio_scan_cache` table
|
||||||
|
|
||||||
---
|
Skips re-scanning bio posts that haven't changed since last attempt.
|
||||||
|
|
||||||
## Key generation
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
VouchGrant {
|
vouch_bio_scan_cache(
|
||||||
voucher_persona_id: NodeId, -- sender's persona
|
scanner_persona_id BLOB,
|
||||||
epoch: u32,
|
bio_author_id BLOB,
|
||||||
key_material: [u8; 32], -- V_voucher at this epoch
|
bio_epoch INTEGER, -- bio-post revision counter
|
||||||
issued_at_ms: u64,
|
result INTEGER, -- 0 = no wrapper unlocked; 1 = unlocked
|
||||||
sig: [u8; 64], -- voucher's identity-key signature over the above
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wrapper format
|
||||||
|
|
||||||
|
Bio post carries a `VouchGrantBatch`:
|
||||||
|
|
||||||
|
```
|
||||||
|
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<Wrapper>, // 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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
Per-recipient wrapper construction (RFC 9180 HPKE sealing, single-shot mode):
|
||||||
|
|
||||||
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.
|
```
|
||||||
|
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<VouchGrantBatch>,
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -101,30 +204,47 @@ TBD — OPUS: decide whether vouch grants should use a reserved visibility inten
|
||||||
|
|
||||||
Minimum viable surface for Layer 1 ship:
|
Minimum viable surface for Layer 1 ship:
|
||||||
|
|
||||||
- **Persona screen**: "Vouch for someone" button. Picker of contacts. Hands them a `V_me`.
|
- **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**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`).
|
- **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`).
|
||||||
- **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).
|
- **Persona screen**: "People I've vouched for" list (reads `own_vouch_targets` where `current = 1`).
|
||||||
- **Settings**: "Rotate my vouch key" button → generates new epoch, queues re-distribution to tracked vouchees.
|
- **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).
|
||||||
|
|
||||||
Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet.
|
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
|
## Open questions
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **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").
|
- **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.
|
||||||
- **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.
|
- **`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.
|
||||||
- **Epoch granularity.** Monotonic counter per persona, or wall-clock-based? Counter is simpler; wall-clock aids debugging. Lead leaning: counter.
|
- **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.
|
||||||
- **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.
|
- **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ship criteria for Layer 1
|
## Ship criteria for Layer 1
|
||||||
|
|
||||||
- All personas auto-generate `V_me` at creation.
|
- All personas auto-generate `V_me` at creation.
|
||||||
- Users can vouch and receive vouches end-to-end via DM-wrapped `VouchGrant`.
|
- Bio-post publish path can embed a `VouchGrantBatch`.
|
||||||
- UI shows received and issued vouches per persona.
|
- `own_vouch_targets` table tracks who the persona has vouched for locally.
|
||||||
- `V_me` rotation works; re-distribution to tracked vouchees is demonstrated.
|
- `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`.
|
||||||
- No change to post visibility / comment behavior.
|
- No change to post visibility / comment behavior.
|
||||||
- Integration test: two personas on two devices, Alice vouches for Bob, Bob's `vouch_keys_received` contains `V_alice` with correct signature.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,46 @@
|
||||||
# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments
|
# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments (CDN-Verified)
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
- An author creates a public post with `comment_policy = FriendsOfFriends`.
|
- 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.
|
- Body is plaintext in the CDN (unchanged public-post path).
|
||||||
- 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.
|
- 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).
|
||||||
- Non-FoF readers can still READ the post. They cannot post an accepted comment.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lead decisions
|
## Lead decisions
|
||||||
|
|
||||||
- **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.
|
- **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` 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).
|
- **`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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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`.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -39,89 +57,251 @@ pub enum CommentPolicy {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Post header additions (for posts with `comment_policy = FriendsOfFriends`)
|
### Post header additions (for `comment_policy = FriendsOfFriends`)
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
struct PostHeader {
|
struct PostHeader {
|
||||||
// ... existing fields ...
|
// ... existing fields ...
|
||||||
|
|
||||||
// NEW for Mode 2 comment gating:
|
pub_post_set: Vec<[u8; 32]>, // real pub_x + dummy pubkeys, random-order; .len() == wrap_slots.len()
|
||||||
pub_post: Option<[u8; 32]>, // ed25519 public key of per-post ephemeral keypair
|
wrap_slots: Vec<WrapSlot>, // real slots + rand(32..=128) dummy slots, shuffled
|
||||||
wrap_slots: Vec<WrapSlot>, // wraps priv_post under each V_x in author's keyring
|
revocation_list: Vec<RevocationEntry>, // initially empty; appended via signed diffs
|
||||||
|
author_sig: [u8; 64], // ed25519 sig over the header
|
||||||
// TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
### `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<u8>, // 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Extend `InlineComment`
|
### Extend `InlineComment`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
struct InlineComment {
|
struct InlineComment {
|
||||||
// ... existing fields ...
|
// CDN-visible:
|
||||||
|
parent_post_id: PostId,
|
||||||
|
ciphertext: Vec<u8>, // 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
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
// Plaintext inside ciphertext (FoF-readable):
|
||||||
group_sig: Option<[u8; 64]>, // ed25519 signature over comment_hash, verifies against parent post's pub_post
|
// comment_body
|
||||||
|
// vouch_mac: [u8; 16] // HMAC(V_x, post_id || comment_hash)[:16B]
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
// parent_comment_id: Option<CommentId>
|
||||||
vouch_mac: Option<[u8; 16]>, // HMAC(V_x, post_id || comment_hash), truncated
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Comment creation (author of the comment)
|
## Comment creation (FoF commenter)
|
||||||
|
|
||||||
1. Commenter fetches parent post. Reads `pub_post` from header.
|
1. Commenter fetches parent post. Reads `pub_post_set`, `wrap_slots`, `revocation_list`.
|
||||||
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`.
|
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. 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.
|
3. On successful unwrap: now holds `CEK`, `priv_x`. Derive `CEK_comments = HKDF(CEK, ...)`.
|
||||||
4. Publishes comment through normal comment-propagation path.
|
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.
|
||||||
|
|
||||||
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.
|
If no slot unwraps: commenter is not in the author's FoF set. Client-side: hide the comment box.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Comment verification (reader side)
|
## Propagation-node accept rule
|
||||||
|
|
||||||
At feed render time, for every comment on a `FriendsOfFriends`-policy post:
|
For every incoming `InlineComment` targeting a FoF-policy post:
|
||||||
|
|
||||||
1. If `group_sig` is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: **filter out**.
|
1. **Valid index**: `pub_x_index < pub_post_set.len()`. Else drop.
|
||||||
2. Verify `group_sig` against parent post's `pub_post` over `comment_hash`. If fail → filter out.
|
2. **Not revoked**: `pub_post_set[pub_x_index] ∉ revocation_list`. Else drop.
|
||||||
3. Verify identity sig of comment as normal. If fail → filter out.
|
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.
|
||||||
|
|
||||||
At author side (strict mode, optional):
|
On drop: do not store, do not forward. No error response to sender (avoid oracle).
|
||||||
|
|
||||||
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).
|
Rate-limit per `commenter_id` and per `pub_x_index` (operational knob, not in spec).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Propagation
|
## Reader side (FoF)
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **`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).
|
- **Rate limiting.** Operational knob. Per-`commenter_id` + per-`pub_x_index` caps in propagation-node config. Out of spec.
|
||||||
- **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 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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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).
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ship criteria for Layer 2
|
## Ship criteria for Layer 2
|
||||||
|
|
||||||
- `CommentPolicy::FriendsOfFriends` variant exists end-to-end (storage, protocol, UI picker).
|
- `CommentPolicy::FriendsOfFriends` end-to-end (storage, protocol, UI picker).
|
||||||
- Authors can create public posts with FoF-gated comments.
|
- Dual-derivation wrap slot: read → CEK, sign → priv_x. AEAD with `post_id` AAD.
|
||||||
- `pub_post` / `priv_post` / `wrap_slots` generated on post creation, wrapped under author's full keyring.
|
- `pub_post_set` inline in header, 1:1 with `wrap_slots`, real + dummy entries.
|
||||||
- Commenters: client-side check of FoF eligibility before offering comment box; `group_sig` + `vouch_mac` attached on send.
|
- `pub_x_index` on comments.
|
||||||
- Readers: filter-out comments failing `group_sig` verification.
|
- CEK_comments derived via HKDF; all comment bodies encrypted.
|
||||||
- Author strict-mode: optional ingress rejection on unknown `vouch_mac`.
|
- Propagation nodes enforce four-check accept rule before forwarding.
|
||||||
- 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.
|
- Revocation diff format + CDN honor path (retroactive delete + forward).
|
||||||
- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
# Layer 3 — Mode 1: `FOF_CLOSED` Posts
|
# 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; 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 under CEK; readership emerges from keyring intersection with `wrap_slots`.
|
||||||
|
|
||||||
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`).
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -20,11 +22,12 @@ Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same s
|
||||||
## Lead decisions
|
## 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.
|
- **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 `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.
|
- **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).
|
||||||
- **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).
|
- **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).
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -119,21 +122,27 @@ Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted po
|
||||||
|
|
||||||
## Open questions
|
## 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 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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ship criteria for Layer 3
|
## Ship criteria for Layer 3
|
||||||
|
|
||||||
- `PostVisibility::FoFClosed` exists end-to-end.
|
- `PostVisibility::FoFClosed` exists end-to-end.
|
||||||
- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2.
|
- 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.
|
||||||
- Reader decryption path iterates personas × keyring with prefilter tag.
|
- Reader decryption path iterates personas × keyring with prefilter tag.
|
||||||
- `receive_post` accepts FoFClosed ciphertext without decrypting.
|
- `receive_post` accepts FoFClosed ciphertext without decrypting.
|
||||||
- UI surface: post composer has Public / Friends-only / FoF / Custom picker.
|
- UI surface: post composer has three presets — Public / Friends-only / Friends-of-Friends. Custom subset is v2.
|
||||||
- Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt.
|
- 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).
|
- Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible).
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,144 @@
|
||||||
# Layer 4 — Per-Post Keypair Rotation
|
# Layer 4 — Rotation, Revocation, and Key Lifecycle
|
||||||
|
|
||||||
**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`.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
- Author can update the FoF set of an existing post without deleting / recreating it.
|
- An author can narrow comment authority on a published post via Layer 2 revocation (default, cheap).
|
||||||
- A `PostKeyRotation` record, signed by author identity key, carries a new `(priv_post', pub_post')` wrapped under the current keyring.
|
- 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).
|
||||||
- Existing comments under old `pub_post` stay cryptographically valid.
|
- 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`.
|
||||||
- New comments must sign under `priv_post'`.
|
- 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.
|
||||||
- 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.
|
- 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lead decisions
|
## Lead decisions
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **Rotation is optional.** Simple case is a post with one immutable `pub_post`. Layer 4 adds the escape hatch; most posts never rotate.
|
- **`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`.
|
||||||
- **Author-signed.** Only the post author (identity key) can rotate. Prevents an admitted commenter from rotating others out.
|
- **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").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data model
|
## What goes away from the original skeleton
|
||||||
|
|
||||||
### `PostKeyRotation` record
|
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:
|
||||||
|
|
||||||
```rust
|
- Layer 2's `RevocationEntry` for the comment-narrowing case.
|
||||||
struct PostKeyRotation {
|
- Standard post-publish + optional `supersedes_post_id` for the re-issue case.
|
||||||
post_id: PostId,
|
- In-place wrap_slot swap as a new, optional key-burn diff (below).
|
||||||
rotation_index: u32, // monotonic; 0 = original keypair (implicit), 1 = first rotation, etc.
|
|
||||||
new_pub_post: [u8; 32],
|
What goes away:
|
||||||
new_wrap_slots: Vec<WrapSlot>, // wraps new priv_post under current keyring (same as post creation)
|
- `PostKeyRotation` record type.
|
||||||
superseded_at: u64, // ms; rotation timestamp
|
- `rotation_index` / `pub_post_index` field on comments.
|
||||||
sig: [u8; 64], // author identity-key signature over the above
|
- 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)
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Comment verification after rotation
|
### Optional `supersedes_post_id` field on `PostHeader`
|
||||||
|
|
||||||
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
|
```rust
|
||||||
struct InlineComment {
|
struct PostHeader {
|
||||||
// ... existing fields ...
|
// ... existing fields ...
|
||||||
#[serde(default)]
|
|
||||||
pub_post_index: u32, // 0 for original keypair, n for rotation n
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
group_sig: ...,
|
supersedes_post_id: Option<PostId>,
|
||||||
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."
|
||||||
|
|
||||||
## Rotation flow (author side)
|
### `KeyBurnDiff` (header-diff type)
|
||||||
|
|
||||||
1. Author changes FoF-relevant state (new vouch granted, someone un-vouched, `V_me` rotated).
|
```rust
|
||||||
2. Author decides to re-gate a specific post's comments: UI action "rotate comment keys for this post."
|
struct KeyBurnDiff {
|
||||||
3. Generate new `(priv_post', pub_post')`.
|
post_id: PostId,
|
||||||
4. Re-wrap `priv_post'` under the author's CURRENT keyring (the same algorithm as initial post creation, Layer 3).
|
slot_index: u32, // which slot to swap
|
||||||
5. Build `PostKeyRotation` record, sign, publish.
|
new_wrap_slot: WrapSlot, // sealed under V_x_new (typically author's V_me_new)
|
||||||
6. Rotation record propagates via normal CDN (it's a diff on the post, same mechanism as engagement diffs).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Reader/commenter side
|
## Author UX surfaces
|
||||||
|
|
||||||
- On receiving a `PostKeyRotation` record, readers store it keyed by `(post_id, rotation_index)`.
|
- **"Remove this commenter from this post"** → `RevocationEntry` for that specific `pub_x`. Standard Layer 2 path.
|
||||||
- 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.
|
- **"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.
|
||||||
- At comment-verification time: look up the rotation referenced by `comment.pub_post_index`; verify against that `pub_post`.
|
- **"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.
|
||||||
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.
|
- **"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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Propagation
|
## Cascade decision tree (for the author)
|
||||||
|
|
||||||
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.
|
| Scenario | Default action | Optional escalation |
|
||||||
|
|---|---|---|
|
||||||
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).
|
| 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. |
|
||||||
## 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
|
## Open questions
|
||||||
|
|
||||||
- **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`).
|
- **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.
|
||||||
- **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.
|
- **`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.
|
||||||
- **UI for rotation.** "Update who can comment" button on post. Simple. No scheduling / batch rotation UI in v1.
|
- **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.
|
||||||
- **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.
|
- **`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ship criteria for Layer 4
|
## Ship criteria for Layer 4
|
||||||
|
|
||||||
- `PostKeyRotation` record type exists end-to-end.
|
- `vouch_keys_own` retains multi-epoch rows without auto-deletion on rotation.
|
||||||
- Author UI action: "Update who can comment on this post."
|
- `vouch_keys_received` retains multi-epoch rows; trial-unwrap iterates the chain per voucher.
|
||||||
- Rotation records propagate via CDN.
|
- `own_post_slot_provenance` table populated at every post-publish.
|
||||||
- Comment signing uses latest rotation's `priv_post`; `pub_post_index` attached.
|
- Author UI: "Rotate my vouch key" with optional follow-up "Issue to existing vouchees."
|
||||||
- Comment verification routes to the correct `pub_post` via index.
|
- Author UI: "Cascade revocations onto my old posts" as a post-rotation action.
|
||||||
- Back-compat: posts without rotation records are handled as `pub_post_index = 0` uniformly.
|
- Author UI: "Re-issue this post with narrower access" (advanced).
|
||||||
- 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.
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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 `priv_post` implicitly (author-local cache of per-post keypairs). 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 the post's CEK + every `priv_x` directly from creation (author-local cache, keyed by `post_id`). 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.
|
- **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`:
|
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 `priv_post` from local author cache. Done.
|
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.
|
||||||
2. **Cache lookup**: Query `vouch_unlock_cache` for `(any_persona, A)`. For each cached winning `V_x`:
|
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]`.
|
- Compute `prefilter_tag = HMAC(V_x, post_id)[:2B]`.
|
||||||
- Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open.
|
- Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Layer 6 — Revocation & Rotation Cascades
|
# Layer 6 — Revocation & Rotation Cascades
|
||||||
|
|
||||||
**Status**: Stub. May not be in v1. Drafted for design review only.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
184
frontend/app.js
184
frontend/app.js
|
|
@ -486,11 +486,18 @@ function renderPost(post, index) {
|
||||||
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
||||||
} else if (post.visibility === 'encrypted') {
|
} else if (post.visibility === 'encrypted') {
|
||||||
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
||||||
|
} else if (post.visibility === 'fof-closed') {
|
||||||
|
visBadge = '<span class="vis-badge vis-encrypted-mine">fof-closed</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayContent;
|
let displayContent;
|
||||||
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
||||||
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
||||||
|
} 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 = `<span class="encrypted-placeholder" data-fof-closed-pending="${post.id}">(fof-closed — unlocking…)</span>`;
|
||||||
} else if (post.decryptedContent) {
|
} else if (post.decryptedContent) {
|
||||||
displayContent = escapeHtml(post.decryptedContent);
|
displayContent = escapeHtml(post.decryptedContent);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -579,6 +586,29 @@ function renderMessage(post, index, showFollowBtn) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
function renderEmptyState(message, hint) {
|
||||||
return `<div class="empty-state">
|
return `<div class="empty-state">
|
||||||
<div class="empty-state-icon"></div>
|
<div class="empty-state-icon"></div>
|
||||||
|
|
@ -793,6 +823,9 @@ async function loadFeed(force) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
|
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) {
|
if (authorFilterNodeId) {
|
||||||
const clearBtn = document.getElementById('clear-author-filter');
|
const clearBtn = document.getElementById('clear-author-filter');
|
||||||
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
||||||
|
|
@ -947,6 +980,7 @@ async function loadMyPosts(force) {
|
||||||
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
|
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
|
||||||
} else {
|
} else {
|
||||||
myPostsList.innerHTML = mine.map(renderPost).join('');
|
myPostsList.innerHTML = mine.map(renderPost).join('');
|
||||||
|
unlockFoFClosedPlaceholders(myPostsList);
|
||||||
if (_myPostsHasMore) {
|
if (_myPostsHasMore) {
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'myposts-scroll-sentinel';
|
sentinel.id = 'myposts-scroll-sentinel';
|
||||||
|
|
@ -1633,8 +1667,10 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
||||||
const follows = await invoke('list_follows').catch(() => []);
|
const follows = await invoke('list_follows').catch(() => []);
|
||||||
const ignored = await invoke('list_ignored_peers').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 following = follows.some(f => f.nodeId === nodeId);
|
||||||
const isIgnored = ignored.some(i => i.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 name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
|
||||||
const bio = (resolved && resolved.bio) || '';
|
const bio = (resolved && resolved.bio) || '';
|
||||||
const icon = generateIdenticon(nodeId, 48);
|
const icon = generateIdenticon(nodeId, 48);
|
||||||
|
|
@ -1654,6 +1690,9 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
${following
|
${following
|
||||||
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
||||||
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
||||||
|
${isVouched
|
||||||
|
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
|
||||||
|
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
|
||||||
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
||||||
${isIgnored
|
${isIgnored
|
||||||
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
||||||
|
|
@ -1700,6 +1739,27 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
|
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
|
||||||
catch (e) { toast('Error: ' + e); }
|
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) {
|
} catch (e) {
|
||||||
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2603,8 +2663,40 @@ async function doPost() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentPerm = document.getElementById('comment-perm-select').value;
|
||||||
|
const reactPerm = document.getElementById('react-perm-select').value;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (selectedFiles.length > 0) {
|
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) {
|
||||||
// Convert ArrayBuffers to base64 strings
|
// Convert ArrayBuffers to base64 strings
|
||||||
const files = selectedFiles.map(f => {
|
const files = selectedFiles.map(f => {
|
||||||
const bytes = new Uint8Array(f.data);
|
const bytes = new Uint8Array(f.data);
|
||||||
|
|
@ -2618,9 +2710,9 @@ async function doPost() {
|
||||||
result = await invoke('create_post', params);
|
result = await invoke('create_post', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set engagement policy if non-default
|
// Set engagement policy if non-default (FoF posts also publish
|
||||||
const commentPerm = document.getElementById('comment-perm-select').value;
|
// the policy diff so receivers route the comment-receive path
|
||||||
const reactPerm = document.getElementById('react-perm-select').value;
|
// through the FoF four-check verify gate).
|
||||||
if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) {
|
if ((commentPerm !== 'public' || reactPerm !== 'both') && result && result.id) {
|
||||||
try {
|
try {
|
||||||
await invoke('set_comment_policy', {
|
await invoke('set_comment_policy', {
|
||||||
|
|
@ -3062,7 +3154,16 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
loadMessages(true); loadDmRecipientOptions();
|
loadMessages(true); loadDmRecipientOptions();
|
||||||
clearNotifications('msg-');
|
clearNotifications('msg-');
|
||||||
}
|
}
|
||||||
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); }
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -3618,6 +3719,79 @@ 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 = '<p class="empty-hint" style="margin:0">No vouches given.</p>';
|
||||||
|
} else {
|
||||||
|
givenEl.innerHTML = given.map(v => {
|
||||||
|
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
|
||||||
|
const icon = generateIdenticon(v.nodeId, 18);
|
||||||
|
return `<div class="peer-card" data-node-id="${v.nodeId}">
|
||||||
|
<div class="peer-card-row">${icon} ${label}</div>
|
||||||
|
<div class="peer-card-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm revoke-vouch-btn" data-node-id="${v.nodeId}" data-name="${label}">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).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 = '<p class="empty-hint" style="margin:0">No vouches received.</p>';
|
||||||
|
} else {
|
||||||
|
recvEl.innerHTML = received.map(v => {
|
||||||
|
const label = escapeHtml(v.displayName || v.nodeId.slice(0, 12));
|
||||||
|
const icon = generateIdenticon(v.nodeId, 18);
|
||||||
|
return `<div class="peer-card" data-node-id="${v.nodeId}">
|
||||||
|
<div class="peer-card-row">${icon} ${label}</div>
|
||||||
|
<div class="peer-card-actions empty-hint" style="font-size:0.75rem">epoch ${v.epoch}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
givenEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Release announcement / upgrade banner ---
|
// --- Release announcement / upgrade banner ---
|
||||||
async function loadUpgradeBanner() {
|
async function loadUpgradeBanner() {
|
||||||
const banner = document.getElementById('upgrade-banner');
|
const banner = document.getElementById('upgrade-banner');
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@
|
||||||
<select id="comment-perm-select" title="Comment permission">
|
<select id="comment-perm-select" title="Comment permission">
|
||||||
<option value="public">Comments: All</option>
|
<option value="public">Comments: All</option>
|
||||||
<option value="followers_only">Comments: Followers</option>
|
<option value="followers_only">Comments: Followers</option>
|
||||||
|
<option value="friends_of_friends">Comments: Friends of Friends</option>
|
||||||
|
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</option>
|
||||||
<option value="none">Comments: Off</option>
|
<option value="none">Comments: Off</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="react-perm-select" title="React permission">
|
<select id="react-perm-select" title="React permission">
|
||||||
|
|
@ -198,6 +200,26 @@
|
||||||
<div id="ignored-list" style="text-align:left"></div>
|
<div id="ignored-list" style="text-align:left"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card" style="text-align:center">
|
||||||
|
<h3 style="margin-bottom:0.25rem">Vouches</h3>
|
||||||
|
<p class="empty-hint" style="margin-bottom:0.5rem">Vouches you've given let those friends read your Friend-of-Friend posts. Vouches you've received unlock the posts of those who vouched for you. Revoking rotates your vouch key — the revoked friend keeps access to your existing posts, but not future ones.</p>
|
||||||
|
<div style="display:flex;gap:1rem;text-align:left">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Given</h4>
|
||||||
|
<div id="vouches-given-list"></div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<h4 style="margin:0 0 0.4rem;font-size:0.9rem">Received</h4>
|
||||||
|
<div id="vouches-received-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border)">
|
||||||
|
<p class="empty-hint" style="margin-bottom:0.4rem">Rotate your vouch key (V_me) to issue a fresh key to all your current vouchees. Old posts remain readable by anyone who held the old key — use "Cascade revoke" afterward to actively cut off comment access to your old posts.</p>
|
||||||
|
<button id="rotate-v-me-btn" class="btn btn-ghost btn-sm">Rotate my vouch key</button>
|
||||||
|
<span id="rotate-v-me-status" class="empty-hint" style="margin-left:0.5rem;font-size:0.8rem"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-card" style="text-align:center">
|
<div class="section-card" style="text-align:center">
|
||||||
<h3 style="margin-bottom:0.25rem">Updates</h3>
|
<h3 style="margin-bottom:0.25rem">Updates</h3>
|
||||||
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>
|
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>
|
||||||
|
|
|
||||||
181
sessions.md
181
sessions.md
|
|
@ -6,6 +6,187 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-24 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants`
|
||||||
|
|
||||||
|
**Started**: April 24 UTC
|
||||||
|
**Instance**: Scott's primary Claude (Lead)
|
||||||
|
**Issue**: none (spec refinement)
|
||||||
|
**Branch**: `docs/fof-spec-layer1-bio-grants`
|
||||||
|
**Scope**: Fold Scott + Opus's Layer 1 design answer into the spec. Vouch distribution moves from DM-wrapped `VouchGrant` to HPKE-sealed per-recipient wrappers carried in the voucher's bio post, leveraging existing bio-post CDN propagation and HPKE (RFC 9180) key privacy for recipient anonymity.
|
||||||
|
|
||||||
|
**Key design commitments added to Layer 1**:
|
||||||
|
- HPKE RFC 9180 (DHKEM X25519 + HKDF-SHA256 + ChaCha20Poly1305) for per-recipient wrappers; one ephemeral pubkey per batch; 48B per wrapper.
|
||||||
|
- HKDF `info = "itsgoin/vouch-grant/v1/" || bio_post_id` — recipient-free (non-negotiable for key privacy).
|
||||||
|
- No prefilter tag on grants (no prior shared secret); full X25519 trial at ~60µs per wrapper per persona is tolerable (≤90ms even at 512×3 worst case).
|
||||||
|
- Scan policy: auto-scan bio posts of followed personas; manual "check bio" gesture for non-followed; scan cache keyed by `(scanner_persona, bio_author, bio_epoch)`.
|
||||||
|
- Bucket-padding (64/128/256/512) and per-publish wrapper shuffle for size/position opacity.
|
||||||
|
- No separate `vouches_issued` table on the wire; bio post IS the authoritative record. Local-only `own_vouch_targets` tracks what the persona has granted.
|
||||||
|
- Incremental grant-as-comment path (Scott's suggestion for avoiding full republish) deferred; v1 ships with full republish per change.
|
||||||
|
|
||||||
|
**Completed**:
|
||||||
|
- Rewrote `docs/fof-spec/layer-1-vouch-primitive.md` end-to-end.
|
||||||
|
- README updated: Layer 1 scope line + added bio-post integration bullet.
|
||||||
|
- Self-merged to master.
|
||||||
|
|
||||||
|
**Pending**:
|
||||||
|
- Opus confirmation passes still open on other layers (WrapSlot byte layout, AEAD choice for body, padding schemes).
|
||||||
|
- Layer 2–6 untouched in this pass.
|
||||||
|
|
||||||
|
**Stopping point**: Scott asked to hold merges until Layers 2–6 iterations complete. Branch stays open locally and on Forgejo; continuing to stack commits on it.
|
||||||
|
|
||||||
|
### Update 2026-04-24 — Layer 2 rewrite (CDN-level verification)
|
||||||
|
|
||||||
|
**Scope**: Scott shared Opus's Layer 2 design answer. Folded in.
|
||||||
|
|
||||||
|
**Design commitments added**:
|
||||||
|
- **Per-`V_x` signing keypair `(pub_x, priv_x)`** — replaces single per-post `pub_post/priv_post`. CDN can now verify comment signatures against a published `pub_post_set` before forwarding, killing the bandwidth-amplification DoS an admitted FoF member could otherwise mount.
|
||||||
|
- **Dual-derivation wrap slot**: `read_slot → CEK`, `sign_slot → priv_x`. One unwrap yields both capabilities. Slot structure is shared with Layer 3 (canonical form lives here).
|
||||||
|
- **Comment body encrypted under `CEK_comments = HKDF(CEK, "comments")`** — Mode 2 comments are genuinely FoF-read-gated now, not just FoF-sign-filtered at render (strengthening vs skeleton).
|
||||||
|
- **Propagation-node four-check accept rule**: valid `pub_x_index`, not in `revocation_list`, `group_sig` verifies, `identity_sig` verifies. Any fail → drop without forwarding.
|
||||||
|
- **Author-signed revocation diff** appended to post header; CDN honors on next sync. Per-chain revocation at propagation layer.
|
||||||
|
- **`pub_x_index` is a per-post pseudonym** — leaks "these N comments came through the same chain" within a single post; re-randomizes across posts. Accepted tradeoff for CDN-level DoS resistance.
|
||||||
|
- **v1 ships Ed25519 inline** (~77KB header at 500 vouchees). **PQ future** requires Merkle-commit over `pub_post_set` with per-comment inclusion proofs; deferred but spec shape doesn't preclude.
|
||||||
|
|
||||||
|
**Files touched**:
|
||||||
|
- `docs/fof-spec/layer-2-mode2-fof-comments.md` — rewritten end-to-end.
|
||||||
|
- `docs/fof-spec/layer-3-mode1-fof-closed.md` — prominent "partially superseded" banner added; body retained pending reconciliation when Scott + Opus review Layer 3.
|
||||||
|
- `docs/fof-spec/README.md` — glossary updated (`pub_x`/`priv_x`, `pub_post_set`, `revocation_list`); integration bullet updated for new `InlineComment` fields + CDN accept rule.
|
||||||
|
|
||||||
|
**Open questions I raised back to Scott** (awaiting his answer before finalizing):
|
||||||
|
1. `(pub_x, priv_x)` lifecycle: generated at `V_x` genesis (Layer 1) and stable across posts, vs regenerated per-post by author. Lead leaning per-post. Needs confirmation.
|
||||||
|
2. `pub_post_set` padding vs `wrap_slots` padding — real/dummy alignment when dummies shouldn't be indexable by `pub_x_index`.
|
||||||
|
3. Non-FoF rendering of comment count (reveal engagement? suppress?).
|
||||||
|
4. Who holds `priv_me` (author) — generated alongside `V_me` at Layer 1, vs per-post regeneration. Same as #1 but for author's own entry.
|
||||||
|
|
||||||
|
**Pending**:
|
||||||
|
- Scott reviews / answers open questions.
|
||||||
|
- Layer 3 reconciliation when Scott + Opus get to Mode 1.
|
||||||
|
- Layers 4–6 iterations.
|
||||||
|
|
||||||
|
**Stopping point**: commit `b8b38a6` (Layer 1) + new commit for Layer 2 both on branch; not merged. Awaiting Scott.
|
||||||
|
|
||||||
|
### Update 2026-05-13 — design.html FoF section (20a) added
|
||||||
|
|
||||||
|
Added a new section `20a. Friend-of-Friend Visibility` to `website/design.html`, sitting between Encryption (20) and Delete Propagation (21). Marked all subsections `badge-planned`. Layer table at the bottom shows ship status per layer.
|
||||||
|
|
||||||
|
**Key writing decisions**:
|
||||||
|
- Up-front disambiguation note: this section's "vouch" is the cryptographic V_me primitive, distinct from the directory-vouch system in section 27. Symmetric disambiguation note added to section 27 pointing the other direction.
|
||||||
|
- User-facing 4-level model (Public / Friends-only / FoF / Custom-v2) leads. Crypto primitives follow.
|
||||||
|
- Mode 1 vs Mode 2 split called out via `card` divs.
|
||||||
|
- CDN-level verification highlighted as the propagation-DoS resistance story.
|
||||||
|
- Revocation lifecycle: three `card` blocks — per-post default, V_me rotation, opt-in cascade + key-burn.
|
||||||
|
- PQ-readiness explicitly addressed (symmetric primitives PQ-safe; Ed25519 → ML-DSA-65 swap path noted).
|
||||||
|
- Cross-ref to `docs/fof-spec/` for implementation detail.
|
||||||
|
|
||||||
|
**Tables updated**:
|
||||||
|
- Visibility variants table (in section 20) now has a `FoFClosed` row with overhead + bucketed-audience note.
|
||||||
|
- New layer-status table at the bottom of section 20a shows the five ship-able layers.
|
||||||
|
|
||||||
|
**Other touched**:
|
||||||
|
- TOC entry added (`20a. Friend-of-Friend Visibility`).
|
||||||
|
- `reference_design_index.md` auto-updated by the design-index hook on save.
|
||||||
|
- Section 27 (Directory) got a reciprocal disambiguation note pointing at section 20a.
|
||||||
|
|
||||||
|
**Files in this commit**:
|
||||||
|
- `website/design.html`
|
||||||
|
- `sessions.md` (this entry)
|
||||||
|
|
||||||
|
Branch state: still `docs/fof-spec-layer1-bio-grants`, still unmerged. Implementation can now begin from a coherent public design + internal spec.
|
||||||
|
|
||||||
|
### Update 2026-05-13 — Layer 4 written (rotation + revocation + key lifecycle)
|
||||||
|
|
||||||
|
Iterative session with Scott. Recap of where the model landed:
|
||||||
|
|
||||||
|
**Rotation/revocation model (now in spec)**:
|
||||||
|
- Default narrowing of comment authority on a post = Layer 2 revocation (existing mechanism). No new wire primitive.
|
||||||
|
- Advanced narrowing of read access = full re-issue with `supersedes_post_id` link. Discouraged due to network overhead.
|
||||||
|
- `V_me` rotation = the persona-wide revocation primitive. Generate new V_me, distribute via next bio-post batch to non-revoked vouchees only. Revoked person retains old V_me.
|
||||||
|
- Receiver-chain model: receiver appends new V_me to `vouch_keys_received` (does NOT overwrite). Trial-unwrap iterates the chain. UX-wise the "current" key is the newest; older epochs are archived but kept for historical decrypts.
|
||||||
|
- **Grandfather-by-default**: CDN is V_me-blind, so rotation does NOT auto-cascade comment deletion. Revoked vouchee keeps comment authority on old posts unless the author opts to cascade per-pub_x revocations.
|
||||||
|
- **Per-post cascade is opt-in**: author can query a local `own_post_slot_provenance` table to find pub_x's sealed under V_me_old in any of their posts, then publish per-pub_x RevocationEntries to cascade.
|
||||||
|
- **Key-burn primitive (new, optional)**: signed `KeyBurnDiff` swaps an old wrap_slot for a new one in-place on a specific post. Used when V_me leaked and the author wants to scrub it from the CDN copy of old posts. Body CEK unchanged; affects future fresh-decrypts only.
|
||||||
|
|
||||||
|
**Cryptographic stack confirmed (Scott reconfirmed)**:
|
||||||
|
- Body encryption: symmetric ChaCha20-Poly1305 under CEK. PQ-safe.
|
||||||
|
- Wrap_slots: AEAD under V_x. PQ-safe.
|
||||||
|
- Comment signing: **asymmetric Ed25519** (per-V_x per-post `(pub_x, priv_x)`). NOT PQ-safe; ML-DSA-65 migration deferred. Scott confirmed the asymmetric-for-signing tradeoff is intentional — it's what makes CDN-level bandwidth-DoS filtering work.
|
||||||
|
|
||||||
|
**Files touched in this round**:
|
||||||
|
- `docs/fof-spec/layer-4-keypair-rotation.md`: full rewrite from skeleton.
|
||||||
|
- `docs/fof-spec/layer-1-vouch-primitive.md`: rotation language updated to point at Layer 4's append-only model; multi-epoch UI hook added.
|
||||||
|
|
||||||
|
**Branch state**: `docs/fof-spec-layer1-bio-grants` (despite the name, holds all Layer 1–4 spec work). Commit pending. Not merged per Scott's standing instruction.
|
||||||
|
|
||||||
|
**Pending**:
|
||||||
|
- Layer 5 (unlock cache + prefilter): existing skeleton text still reflects single per-post keypair model. Needs reconciliation with per-V_x model from Layer 2.
|
||||||
|
- Layer 3 (Mode 1): partially-superseded banner still present. Needs Scott/Opus reconciliation pass.
|
||||||
|
- Layer 6 (revocation): stub still. Largely obviated by Layer 4 work.
|
||||||
|
|
||||||
|
### Update 2026-04-24 — Layer 3 round 2 (last two open questions)
|
||||||
|
|
||||||
|
Two follow-up questions resolved:
|
||||||
|
|
||||||
|
- **Access-grant slot ordering**: **append at tail** (not re-shuffle). I'd initially overcorrected to a "switch comments from index to pub_x bytes so shuffles are free" change; Scott reverted that and clarified the choice. Append-at-tail preserves `pub_x_index` stability across the post's lifetime — already-stored comments stay verifiable, no write amplification on grant. Accepted positional-recency leak (tail = newest grants).
|
||||||
|
- **Minimum slot bucket**: **8**. Singleton/tiny-set posts pad up to 8 slots. Brand-new personas don't publish "I have no vouchees" headers.
|
||||||
|
|
||||||
|
**Files touched**:
|
||||||
|
- `docs/fof-spec/layer-2-mode2-fof-comments.md`: access-grant lead decision made explicit about append-at-tail and index stability.
|
||||||
|
- `docs/fof-spec/layer-3-mode1-fof-closed.md`: minimum-8 floor added to padding lead decision; both open questions moved to Resolved.
|
||||||
|
- `sessions.md`: this entry.
|
||||||
|
|
||||||
|
### Update 2026-04-24 — Layer 3 round 1 + cross-cutting padding rule (corrected)
|
||||||
|
|
||||||
|
Scott talked to Opus and resolved Layer 3 open questions + introduced a unified padding rule that supersedes Layer 2 round 2's `rand(32..=128)`.
|
||||||
|
|
||||||
|
**First-pass misread (corrected by Scott):** I initially wrote the rule as "power-of-2 up to 256, then `real + rand(0..=256)` above." That's wrong — the rule is **bucketed throughout**, not random above the threshold.
|
||||||
|
|
||||||
|
**Bucketed padding rule (applies to both slot count and body size)**:
|
||||||
|
- ≤256 real units → next power-of-2 bucket (8, 16, 32, 64, 128, 256).
|
||||||
|
- >256 real units → next linear-step bucket: +128 step for slots (384, 512, 640, …), +256KB step for body bytes (512KB, 768KB, 1024KB, …).
|
||||||
|
- Deterministic. Author publishes `next_bucket(real)`; dummies fill the gap.
|
||||||
|
|
||||||
|
Why this is stronger than random: observers learn the bucket but never the position within it. Across multiple posts from the same author, the bucket is stable until the author crosses a boundary — so no "min over many posts" attack converges tighter than the bucket bound. Random padding would have leaked `min(observed) - max_noise` as a floor.
|
||||||
|
|
||||||
|
Linear-step above 256 vs pure power-of-2: avoids the 2× waste of jumping 256→512 for an author with 257 vouchees. Above 256, step buckets are 128 (slots) or 256KB (body) so worst-case in-bucket overhead is bounded (~33% at the worst spot).
|
||||||
|
|
||||||
|
Applies uniformly to slot count and body size.
|
||||||
|
|
||||||
|
**Other resolved Layer 3 questions**:
|
||||||
|
- Custom mode UI deferred. v1 ships three presets only: Public / Friends-only / FoF.
|
||||||
|
- Slot dedup at `V_x` byte level. One slot per unique key.
|
||||||
|
- Body-length padding adopted.
|
||||||
|
|
||||||
|
**Files touched**:
|
||||||
|
- `docs/fof-spec/layer-3-mode1-fof-closed.md`: Lead decisions updated (hybrid padding, dedup, three-preset UI); open questions split into still-open + Resolved; ship criteria updated.
|
||||||
|
- `docs/fof-spec/layer-2-mode2-fof-comments.md`: padding rule promoted to hybrid scheme; size budget rewritten with three regime examples; privacy section rewritten for two-regime analysis; Resolved bullet superseded with pointer.
|
||||||
|
- `sessions.md`: this entry.
|
||||||
|
|
||||||
|
**Still-open Layer 3 questions worth flagging to Scott**:
|
||||||
|
1. Access-grant ordering — does appending a new slot re-shuffle the full `wrap_slots` / `pub_post_set` (preserves the random-order privacy property but invalidates `pub_x_index` values in already-stored comments), or is it append-only (`pub_x_index` is stable but tail-positional leak says "these are recent grants")? Lead leaning: **append-only**; index stability matters for revocation and stored-comment verification.
|
||||||
|
2. Minimum slot-count floor for tiny authors. Power-of-2-of-1 = 1, which leaks "this persona has one vouch (probably just themselves)." Lead leaning: minimum bucket of 8.
|
||||||
|
|
||||||
|
**Pending**: Layers 4–6 iterations. Scott to confirm two flagged questions.
|
||||||
|
|
||||||
|
### Update 2026-04-24 — Layer 2 round 2 (Scott answers all 5 questions)
|
||||||
|
|
||||||
|
Scott resolved all five open questions:
|
||||||
|
|
||||||
|
1. **Per-post `(pub_x, priv_x)`** — confirmed.
|
||||||
|
2. **Random-count dummy padding** (`rand(32..=128)`) replaces power-of-2 buckets, with dummy pubkeys in `pub_post_set` so `.len() == wrap_slots.len()`. Across multiple posts from the same author, an observer cannot even establish a reliable floor for the real vouch-set size.
|
||||||
|
3. **Non-FoF comment UX**: "Comments are private" affordance with optional "Request access via DM" button. No count leak.
|
||||||
|
4. **Author's own entry in `pub_post_set`** — confirmed.
|
||||||
|
5. **Revocation is retroactive delete + forward.** File-holders delete locally-stored comments signed by the revoked `pub_x`, then propagate the diff. Stronger than stop-forwarding — prior garbage is cleaned up as the diff sweeps the mesh.
|
||||||
|
|
||||||
|
**New primitive**: **access-grant author comment**. Author can retroactively widen a post's read-set by publishing an author-signed special comment appending a new `WrapSlot` + `pub_post_set` entry. Lets a newly-vouched persona gain read + comment access without republishing the whole post. Answers the "non-FoF requests access via DM, author approves" UX loop.
|
||||||
|
|
||||||
|
**Files touched**:
|
||||||
|
- `docs/fof-spec/layer-2-mode2-fof-comments.md` — updated Lead decisions, post-header, revocation flow (retroactive), added Access-grant author comment section, updated Privacy tradeoff (size-leak analysis with random padding), Open questions split into unresolved + Resolved, size budget, ship criteria.
|
||||||
|
- `sessions.md` — this entry.
|
||||||
|
|
||||||
|
Commit pending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton`
|
## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton`
|
||||||
|
|
||||||
**Started**: late April 23 UTC
|
**Started**: late April 23 UTC
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
<a href="#erasure-cdn">18b. Erasure-Coded CDN Replication</a>
|
<a href="#erasure-cdn">18b. Erasure-Coded CDN Replication</a>
|
||||||
<a href="#sync">19. Sync Protocol</a>
|
<a href="#sync">19. Sync Protocol</a>
|
||||||
<a href="#encryption">20. Encryption</a>
|
<a href="#encryption">20. Encryption</a>
|
||||||
|
<a href="#fof">20a. Friend-of-Friend Visibility</a>
|
||||||
<a href="#deletes">21. Delete Propagation</a>
|
<a href="#deletes">21. Delete Propagation</a>
|
||||||
<a href="#privacy">22. Social Graph Privacy</a>
|
<a href="#privacy">22. Social Graph Privacy</a>
|
||||||
<a href="#multidevice">23. Multi-Device Identity</a>
|
<a href="#multidevice">23. Multi-Device Identity</a>
|
||||||
|
|
@ -1304,6 +1305,7 @@ END</code></pre>
|
||||||
<tr><td><code>Public</code></td><td>None</td><td>Unlimited</td></tr>
|
<tr><td><code>Public</code></td><td>None</td><td>Unlimited</td></tr>
|
||||||
<tr><td><code>Encrypted { recipients }</code></td><td>~60 bytes per recipient</td><td>~500 (256KB cap)</td></tr>
|
<tr><td><code>Encrypted { recipients }</code></td><td>~60 bytes per recipient</td><td>~500 (256KB cap)</td></tr>
|
||||||
<tr><td><code>GroupEncrypted { group_id, epoch, wrapped_cek }</code></td><td>~100 bytes total</td><td>Unlimited (one CEK wrap for the group)</td></tr>
|
<tr><td><code>GroupEncrypted { group_id, epoch, wrapped_cek }</code></td><td>~100 bytes total</td><td>Unlimited (one CEK wrap for the group)</td></tr>
|
||||||
|
<tr><td><code>FoFClosed { pub_post_set, wrap_slots }</code> <span class="badge badge-planned">Planned</span></td><td>~154 bytes per admitted V_x, padded</td><td>Bucketed (8/16/32/64/128/256, then +128 steps)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>PostId integrity</h3>
|
<h3>PostId integrity</h3>
|
||||||
|
|
@ -1350,6 +1352,105 @@ END</code></pre>
|
||||||
<p>Different profile versions per circle, encrypted with the circle/group key. A peer sees the profile version for the most-privileged circle they belong to. <code>CircleProfileUpdate</code> (<code>0xB4</code>) wire message. Public profiles can be hidden (<code>public_visible=false</code> strips display_name/bio).</p>
|
<p>Different profile versions per circle, encrypted with the circle/group key. A peer sees the profile version for the most-privileged circle they belong to. <code>CircleProfileUpdate</code> (<code>0xB4</code>) wire message. Public profiles can be hidden (<code>public_visible=false</code> strips display_name/bio).</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 20a. Friend-of-Friend Visibility -->
|
||||||
|
<section id="fof">
|
||||||
|
<h2>20a. Friend-of-Friend Visibility <span class="badge badge-planned">Planned</span></h2>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Distinct from directory vouches.</strong> The "FoF vouch" described here is a <em>cryptographic</em> primitive for post readership and comment gating (per-persona symmetric key <code>V_me</code>). It is unrelated to the <em>directory vouch</em> system in <a href="#directory">section 27</a>, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>The problem</h3>
|
||||||
|
<p>Existing visibility variants gate by explicit recipient lists (<code>Encrypted{recipients}</code>) or named-circle membership (<code>GroupEncrypted</code>). Neither expresses "people who are reachable through my social graph" without leaking the graph itself. FoF visibility fills that gap: posts whose readership emerges from <em>cryptographic reachability</em> through a unilateral vouch graph, with no recipient IDs on the wire and no centrally-computed membership lists.</p>
|
||||||
|
|
||||||
|
<h3>User-facing model</h3>
|
||||||
|
<p>Authors pick one of four visibility levels at compose time:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Level</th><th>Reaches</th></tr>
|
||||||
|
<tr><td><strong>Public</strong></td><td>All readers (unchanged)</td></tr>
|
||||||
|
<tr><td><strong>Friends-only</strong></td><td>Personas you have vouched for</td></tr>
|
||||||
|
<tr><td><strong>Friends-of-Friends</strong></td><td>Your vouchees + every vouchee of anyone who vouched for you (emergent FoF)</td></tr>
|
||||||
|
<tr><td><strong>Custom</strong> <span class="badge badge-planned">v2</span></td><td>Author-selected subset of held vouch keys</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Core primitives</h3>
|
||||||
|
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||||
|
<li><strong><code>V_me</code></strong>: a 32-byte symmetric key owned by each persona. Distributed to everyone the persona vouches for. Anyone holding <code>V_alice</code> can decrypt wrap slots Alice sealed under it.</li>
|
||||||
|
<li><strong>Keyring</strong>: per-persona, holds the persona's own <code>V_me</code> plus every <code>V_x</code> received from vouchers. The union of these is what makes FoF reach emergent: an author wraps a post slot under every <code>V_x</code> they hold, and any reader whose keyring intersects with that set can decrypt.</li>
|
||||||
|
<li><strong>Wrap slot</strong>: an anonymous AEAD ciphertext in the post header, sealed under one <code>V_x</code>. Carries the post's CEK plus a per-slot signing key. No recipient ID visible on the wire.</li>
|
||||||
|
<li><strong>Prefilter tag</strong>: a 2-byte <code>HMAC(V_x, post_id)[:2B]</code> on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000× per post.</li>
|
||||||
|
<li><strong><code>pub_post_set</code></strong>: list of all admitted signing pubkeys for a post's FoF set. Inline in the post header, randomly ordered. Allows CDN-level verification of comment signatures without revealing membership identities.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Distribution: vouches ride bio posts</h3>
|
||||||
|
<p>Vouches are NOT delivered via DM. Instead, the voucher publishes anonymous HPKE-sealed wrappers (one per recipient) inside their bio post. HPKE (RFC 9180) provides <strong>recipient anonymity</strong> — wrapper ciphertext reveals nothing about the recipient's pubkey. Each wrapper is 48 bytes (32-byte sealed <code>V_me</code> + 16-byte AEAD tag); one shared ephemeral pubkey per batch.</p>
|
||||||
|
<p>Readers auto-scan bio posts of accounts they follow, trial-decrypting each wrapper against each of their personas. Cost is ~60µs per wrapper per persona on mobile — a 200-wrapper bio scanned against 3 personas is ~36 ms. The bio's <code>VouchGrantBatch</code> is padded with dummy wrappers and shuffled on every publish so observers see neither vouch-set size nor change-targets.</p>
|
||||||
|
|
||||||
|
<h3>Two modes</h3>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Mode 2: Public body, FoF-gated comments</h3>
|
||||||
|
<p>Body is plaintext in the CDN (indexable, cacheable, shardable — unchanged from existing public-post path). Comments are encrypted under a CEK derived from the wrap-slot CEK, so only FoF members can read them. Non-FoF observers see only ciphertext + signature fields. The compose UI exposes this via a new <code>CommentPolicy::FriendsOfFriends</code> variant.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Mode 1: <code>FoFClosed</code> (body + comments encrypted)</h3>
|
||||||
|
<p>New <code>PostVisibility::FoFClosed</code> variant. Body is encrypted under CEK (same wrap-slot mechanism as comments). Both readership and comment authority emerge from keyring intersection with the post's <code>wrap_slots</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>CDN-level comment verification</h3>
|
||||||
|
<p>Each wrap slot is dual-derived: one half yields the shared CEK (read capability), the other yields a per-V_x Ed25519 <em>signing</em> keypair (<code>priv_x</code> sealed inside the slot; the corresponding <code>pub_x</code> published in the post's <code>pub_post_set</code>). Comments declare which <code>pub_x</code> signed them and carry the signature.</p>
|
||||||
|
<p>Propagation nodes verify three things before forwarding a comment:</p>
|
||||||
|
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||||
|
<li><code>pub_x_index</code> points to a valid entry in <code>pub_post_set</code>.</li>
|
||||||
|
<li>That entry is not in the post's <code>revocation_list</code>.</li>
|
||||||
|
<li>The comment's <code>group_sig</code> validates against that <code>pub_x</code>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Any failure → drop, do not forward. This kills the bandwidth-amplification attack that a single admitted-but-malicious FoF member could otherwise mount: their forgeries cannot pass the propagation gate.</p>
|
||||||
|
|
||||||
|
<h3>Privacy properties</h3>
|
||||||
|
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||||
|
<li><strong>Unilateral</strong>: vouching is a one-way act, no handshake. The FoF graph forms without bilateral negotiation.</li>
|
||||||
|
<li><strong>Graph-private</strong>: wrap slots carry no recipient IDs. Observers cannot enumerate who can read a post.</li>
|
||||||
|
<li><strong>Bucketed padding</strong>: slot count and body size are deterministically padded to fixed buckets (power-of-2 up to 256, then +128 steps for slots; same shape up to 256 KB then +256 KB steps for bodies). Observers learn the bucket, not the position within it.</li>
|
||||||
|
<li><strong>Recipient anonymity on vouch distribution</strong>: HPKE key privacy ensures bio-post wrappers do not reveal recipients.</li>
|
||||||
|
<li><strong>Per-post chain pseudonym</strong> (accepted tradeoff): the <code>pub_x_index</code> in a comment lets observers correlate "these N comments came through the same chain" within a single post. Cross-post correlation is broken because keys regenerate per post.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Revocation & key lifecycle</h3>
|
||||||
|
<p>Three complementary mechanisms:</p>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Per-post comment revocation (default)</h3>
|
||||||
|
<p>The author signs a <code>RevocationEntry</code> for a specific <code>pub_x</code> on a specific post. Propagation nodes <strong>delete</strong> locally-stored comments by that signer, remove the entry from <code>pub_post_set</code>, append to <code>revocation_list</code>, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Persona-wide <code>V_me</code> rotation</h3>
|
||||||
|
<p>To remove a vouchee, the persona generates <code>V_me_new</code> and issues it to every non-revoked vouchee via the next bio-post batch. Revoked vouchees retain <code>V_me_old</code>. Old posts (sealed under <code>V_me_old</code>) stay readable by anyone who holds the old key — <strong>grandfathered by default</strong>. The CDN does not auto-cascade revocations.</p>
|
||||||
|
<p>Receivers append new <code>V_me</code> values to their keyring (chain), so newly-issued keys do not invalidate prior ones — the receiver keeps holding the old key for reading historical content.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Opt-in cascade + key burn (advanced)</h3>
|
||||||
|
<p>If the author wants to cut off comment authority on old posts after a rotation, they cascade by publishing per-<code>pub_x</code> revocations on each affected post. Local-only <code>own_post_slot_provenance</code> table maps "which pub_x was sealed under which V_me" so the author can target precisely.</p>
|
||||||
|
<p>For the rare case of a leaked <code>V_me</code>, an optional <code>KeyBurnDiff</code> primitive swaps the V_old wrap slot for a V_new wrap slot in-place on a specific post. Scrubs the leaked key from the CDN copy of old posts. Body CEK unchanged.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Performance budget</h3>
|
||||||
|
<p>At realistic scale (~500 vouchees, ~500 wrap slots per post), reader-side decryption uses an unlock cache: the first time persona <code>P</code> decrypts a post from author <code>A</code> via key <code>V_x</code>, the <code>(P, V_x)</code> tuple is cached. Subsequent posts from <code>A</code> try that key first — one HMAC + one AEAD attempt in the hot path. Full scan only on cache miss; newly-received <code>V_x</code> triggers a retry sweep over the unreadable-posts table.</p>
|
||||||
|
|
||||||
|
<h3>Post-quantum readiness</h3>
|
||||||
|
<p>Body encryption, wrap slots, and HKDF/HMAC are all symmetric — PQ-safe. Comment signing uses Ed25519 today; the spec shape is algorithm-agnostic so ML-DSA-65 (~2 KB pubkey, ~3.3 KB signature) can substitute, optionally with a Merkle-commit variant on <code>pub_post_set</code> to keep header size bounded.</p>
|
||||||
|
|
||||||
|
<h3>Implementation</h3>
|
||||||
|
<p>Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in <code>docs/fof-spec/</code>. The implementation is layered for bottom-up shipping:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Layer</th><th>Scope</th><th>Status</th></tr>
|
||||||
|
<tr><td>1</td><td>Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||||
|
<tr><td>2</td><td>Mode 2: public posts with FoF-gated comments, CDN-level verification</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||||
|
<tr><td>3</td><td>Mode 1: <code>FoFClosed</code> body + wrap slots + anonymous prefilter</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||||
|
<tr><td>4</td><td>Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||||
|
<tr><td>5</td><td>Unlock cache + prefilter optimization (perf-critical at scale)</td><td><span class="badge badge-planned">Planned</span></td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 21. Delete Propagation -->
|
<!-- 21. Delete Propagation -->
|
||||||
<section id="deletes">
|
<section id="deletes">
|
||||||
<h2>21. Delete Propagation</h2>
|
<h2>21. Delete Propagation</h2>
|
||||||
|
|
@ -1578,6 +1679,10 @@ END</code></pre>
|
||||||
<p><em>The directory is an opt-in convenience layer for discovery and creator protection. It is not node access — losing directory presence does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.</em></p>
|
<p><em>The directory is an opt-in convenience layer for discovery and creator protection. It is not node access — losing directory presence does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.</em></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Distinct from FoF cryptographic vouches.</strong> The "vouch" described in this section is a <em>directory-layer</em> trust signal governing discovery and bot-ring resistance. It is unrelated to the <em>cryptographic vouch</em> (<code>V_me</code>) in <a href="#fof">section 20a</a>, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Scope</h3>
|
<h3>Scope</h3>
|
||||||
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
|
||||||
<li><strong>Whitelist track</strong> — discoverability, vouch-based entry, graph-scoped visibility.</li>
|
<li><strong>Whitelist track</strong> — discoverability, vouch-based entry, graph-scoped visibility.</li>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,41 @@
|
||||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge — it no longer connects to the live network.</p>
|
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge — it no longer connects to the live network.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem;">v0.7.0 — May 15, 2026</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Friend-of-Friend gating is live. Posts can be public to readers but FoF-gated for comments (Mode 2), or fully FoF-gated for body + comments (Mode 1, <code>FoFClosed</code>). The CDN verifies comment signatures before propagating, killing the bandwidth-DoS attack a single admitted FoF member could otherwise mount. Vouches distribute via HPKE-sealed wrappers in your bio post — no DMs, no recipient IDs on the wire.</p>
|
||||||
|
|
||||||
|
<div class="downloads">
|
||||||
|
<a href="itsgoin-0.7.0.apk" class="download-btn btn-android">
|
||||||
|
Android APK
|
||||||
|
<span class="sub">v0.7.0</span>
|
||||||
|
</a>
|
||||||
|
<a href="itsgoin_0.7.0_amd64.AppImage" class="download-btn btn-linux">
|
||||||
|
Linux AppImage
|
||||||
|
<span class="sub">v0.7.0</span>
|
||||||
|
</a>
|
||||||
|
<a href="itsgoin-cli-0.7.0-linux-amd64" class="download-btn btn-linux">
|
||||||
|
Linux CLI / Anchor
|
||||||
|
<span class="sub">v0.7.0</span>
|
||||||
|
</a>
|
||||||
|
<a href="itsgoin-0.7.0-windows-x64-setup.exe" class="download-btn btn-windows">
|
||||||
|
Windows Installer
|
||||||
|
<span class="sub">v0.7.0</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
||||||
|
<li><strong>Vouch primitive (V_me).</strong> Every persona owns a 32B symmetric key issued to vouchees via HPKE-sealed anonymous wrappers in the voucher's bio post. Receivers auto-scan followed bios; the keyring is per-persona.</li>
|
||||||
|
<li><strong>Mode 2: public posts, FoF-gated comments.</strong> Body indexable and CDN-shardable like any public post; comments encrypted under a per-post CEK only FoF members can unwrap. Non-FoF observers see only ciphertext.</li>
|
||||||
|
<li><strong>Mode 1: <code>FoFClosed</code> posts.</strong> Body itself encrypted under the FoF gating. Non-members propagate the ciphertext but cannot read it.</li>
|
||||||
|
<li><strong>CDN-level comment verification.</strong> Per-V_x signing keypair + propagation-node four-check accept rule. Any admitted FoF member who tries to flood junk gets dropped at first hop — not just at the author's render-time filter.</li>
|
||||||
|
<li><strong>Bucketed padding.</strong> Wrap-slot count and body size pad to power-of-2 buckets (up to 256 / 256KB) then linear steps above. Observer learns the bucket, never the real count.</li>
|
||||||
|
<li><strong>Revocation + access-grant.</strong> Author can per-post revoke a chain (cascade-deletes stored comments by that signer); or post-hoc grant access to a newly-vouched persona without republishing.</li>
|
||||||
|
<li><strong>V_me rotation, cascade, key-burn.</strong> Pure rotation grandfathers old content. Optional cascade revokes that chain across all the author's old posts. Key-burn swaps a single slot in-place for leaked-key scenarios.</li>
|
||||||
|
<li><strong>Unlock cache + retry sweep.</strong> First successful unlock from an author is cached; later posts hot-path to a single AEAD attempt. Posts no held V_x unlocks queue up; sweep on new V_x arrival.</li>
|
||||||
|
<li><strong>Pre-deploy hardening.</strong> Receive-path FoF wire-shape validation, queue-size caps, key-burn replay rejection (monotonic timestamps).</li>
|
||||||
|
</ul>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.0 is a wire-additive release: new <code>PostVisibility::FoFClosed</code> variant, new <code>BlobHeaderDiffOp::FoF{Revocation,AccessGrant,KeyBurn}</code>, new fields on <code>InlineComment</code> and <code>ProfilePostContent</code>. Old clients don't understand FoF gating; upgrade for FoF features. See <a href="design.html#fof">design.html section 20a</a> for the full architecture.</p>
|
||||||
|
|
||||||
<h2 style="margin-top: 2rem;">v0.6.2 — April 23, 2026</h2>
|
<h2 style="margin-top: 2rem;">v0.6.2 — April 23, 2026</h2>
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Every remaining persona-signed direct push is off the wire. Deletes, visibility changes, profile updates, and group-key distribution now travel as encrypted / signed posts through the CDN. Groups are a first-class primitive. Plus two pre-release fixes — an admin-forgery check on group keys and a cap on concurrent port-scan hole punches that explains the 10 Mbps upload storm some users saw on VPNs.</p>
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Every remaining persona-signed direct push is off the wire. Deletes, visibility changes, profile updates, and group-key distribution now travel as encrypted / signed posts through the CDN. Groups are a first-class primitive. Plus two pre-release fixes — an admin-forgery check on group keys and a cap on concurrent port-scan hole punches that explains the 10 Mbps upload storm some users saw on VPNs.</p>
|
||||||
|
|
||||||
|
|
@ -68,19 +103,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
|
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.6.2 was the last release before FoF gating. v0.7.0 is wire-additive; v0.6.2 clients won't understand FoF posts but otherwise interop.</p>
|
||||||
<li><strong>Deletes + visibility changes travel as signed control posts</strong> through the CDN. The <code>DeleteRecord</code> / <code>VisibilityUpdate</code> direct pushes are gone.</li>
|
|
||||||
<li><strong>Profile display data (name, bio, avatar) travels as a persona-signed profile post.</strong> Peer-visible names are back — but bound to the posting identity, not the network endpoint.</li>
|
|
||||||
<li><strong>Rich comments</strong> — a comment can reference a separate post for long bodies or attachments; inline preview is signed alongside the reference.</li>
|
|
||||||
<li><strong>Groups as a primitive</strong> — many-way posting anchored at a public root post. Circles remain one-way (admin-only).</li>
|
|
||||||
<li><strong>Group keys distribute as encrypted posts</strong> — the <code>GroupKeyDistribute</code> wire message is gone.</li>
|
|
||||||
<li><strong>Audience removed.</strong> Simpler social graph; anyone-can-send model via follows.</li>
|
|
||||||
<li><strong>PostPush / PostNotification wire messages retired</strong> — all content propagates via CDN.</li>
|
|
||||||
<li><strong>Port-scan hole punches are now capped at 1 concurrent</strong> — fixes sustained multi-Mbps upload on obfuscated VPNs after anchor connect.</li>
|
|
||||||
<li><strong>Outgoing-connect dedup</strong> — auto-reconnect, rebalance, and relay-introduction no longer race to the same peer.</li>
|
|
||||||
<li><strong>Security fix: group-key distribution verifies the claimed admin matches the post author</strong>, preventing a pollution attack where a peer who knows your posting id could overwrite your stored group key.</li>
|
|
||||||
</ul>
|
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.6.2 is a wire-breaking fork from v0.6.1 (the retired message types are not optional). Upgrade both ends.</p>
|
|
||||||
|
|
||||||
<h2 style="margin-top: 2rem;">v0.6.1 — April 22, 2026</h2>
|
<h2 style="margin-top: 2rem;">v0.6.1 — April 22, 2026</h2>
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Network identity is now fully separated from posting identity on every install. Plus: Android auto-backup disabled by default, Reset actually resets, import preserves your personas, and display name is optional.</p>
|
<p style="color: var(--text-muted); font-size: 0.85rem;">Network identity is now fully separated from posting identity on every install. Plus: Android auto-backup disabled by default, Reset actually resets, import preserves your personas, and display name is optional.</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue