feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings

A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 08:28:26 -04:00
parent 66c9851061
commit 17dbf076cb
19 changed files with 5237 additions and 524 deletions

File diff suppressed because it is too large Load diff

View file

@ -923,15 +923,15 @@ pub fn rotate_group_key(
// --- CDN Manifest Signing ---
/// Compute the canonical digest for an AuthorManifest (for signing/verification).
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json)
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json)
/// v0.8: author_addresses removed from AuthorManifest (and from this digest) —
/// posting-identity manifests must not carry device addresses.
fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&manifest.post_id);
hasher.update(&manifest.author);
hasher.update(&manifest.created_at.to_le_bytes());
hasher.update(&manifest.updated_at.to_le_bytes());
let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default();
hasher.update(addrs_json.as_bytes());
let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default();
hasher.update(prev_json.as_bytes());
let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default();
@ -1010,8 +1010,20 @@ pub fn random_slot_noise(size: usize) -> Vec<u8> {
// --- Engagement crypto ---
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1";
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1";
/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a
/// comment's TTL can never be silently extended by holders. v0.8 has
/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone.
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2";
const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1";
/// v0.8 (A3): self-certifying comment-delete signature context.
const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1";
/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can
/// compute V_open from the post alone: author pubkey + slot_binder_nonce.
const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1";
/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into
/// the derivation (same domain-separation style as vouch grants).
const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key";
const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce";
/// Encrypt a private reaction payload (only the post author can decrypt).
/// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305.
@ -1067,25 +1079,31 @@ pub fn decrypt_private_reaction(
String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e))
}
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content ||
/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms).
///
/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity —
/// holders cannot extend a comment's life without invalidating the sig.
fn comment_digest(
author: &NodeId,
post_id: &PostId,
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> blake3::Hash {
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
hasher.update(author);
hasher.update(post_id);
hasher.update(content.as_bytes());
hasher.update(&timestamp_ms.to_le_bytes());
// Domain-separated append: `None` yields the same digest as the v0.6.1
// scheme, so plain comments keep verifying; `Some(ref)` adds the ref id.
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
if let Some(rid) = ref_post_id {
hasher.update(b"ref:");
hasher.update(rid);
}
hasher.update(b"expires:");
hasher.update(&expires_at_ms.to_le_bytes());
hasher.finalize()
}
@ -1096,13 +1114,14 @@ pub fn sign_comment(
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
}
/// Verify a comment's ed25519 signature.
/// Verify a comment's ed25519 signature (digest v2, expiry included).
pub fn verify_comment_signature(
author: &NodeId,
post_id: &PostId,
@ -1110,6 +1129,7 @@ pub fn verify_comment_signature(
timestamp_ms: u64,
signature: &[u8],
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
return false;
@ -1117,10 +1137,177 @@ pub fn verify_comment_signature(
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
return false;
};
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
}
/// Convenience: verify an `InlineComment`'s identity signature.
pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool {
verify_comment_signature(
&comment.author,
&comment.post_id,
&comment.content,
comment.timestamp_ms,
&comment.signature,
comment.ref_post_id.as_ref(),
comment.expires_at_ms,
)
}
// --- v0.8 (A3): self-certifying comment deletes ---
fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash {
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT);
hasher.update(author);
hasher.update(post_id);
hasher.update(&timestamp_ms.to_le_bytes());
hasher.finalize()
}
/// Sign a comment delete: ed25519 by the comment author's posting key
/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id ||
/// timestamp_ms). Verifiable from the delete op alone — works on holders
/// that never met the persona (registry unregister path).
pub fn sign_comment_delete(
seed: &[u8; 32],
author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let digest = comment_delete_digest(author, post_id, timestamp_ms);
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
}
/// Verify a self-certifying comment-delete signature.
pub fn verify_comment_delete(
author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
signature: &[u8],
) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; };
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; };
let digest = comment_delete_digest(author, post_id, timestamp_ms);
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
}
// --- v0.8 (A3): derivable open-slot V_x (design §27) ---
/// Derive the open slot's V_x from the post alone:
/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1",
/// author_posting_pubkey || slot_binder_nonce)`.
/// Computable by any stranger (author is on the Post, nonce is in the
/// gating); distinct per publish. The CEK recovered through an open slot
/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality
/// (greetings) comes from the inner HPKE-style seal.
pub fn derive_open_slot_vx(
author_posting_pubkey: &NodeId,
slot_binder_nonce: &[u8; 32],
) -> [u8; 32] {
let mut input = [0u8; 64];
input[..32].copy_from_slice(author_posting_pubkey);
input[32..].copy_from_slice(slot_binder_nonce);
blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input)
}
// --- v0.8 (A3): sealed greeting bodies (bucketed) ---
fn derive_greeting_key_nonce(
shared_secret: &[u8; 32],
bio_post_id: &PostId,
) -> ([u8; 32], [u8; 12]) {
let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id));
let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id));
let 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]);
(key, nonce)
}
/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting
/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path
/// the rest of the codebase uses.
pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) {
generate_vouch_batch_ephemeral()
}
/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly
/// `body_bucket` plaintext bytes. Output layout:
/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`.
/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every
/// greeting in the same bucket — all greetings on a bio are
/// size-identical ciphertexts.
///
/// The recipient key is the bio author's posting key converted via
/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw
/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the
/// comment is placed ON (binds the seal to that post).
pub fn seal_greeting_body(
recipient_x25519_pub: &[u8; 32],
bio_post_id: &PostId,
plaintext: &[u8],
body_bucket: usize,
) -> Result<Vec<u8>> {
if plaintext.len() > body_bucket {
bail!(
"greeting body too large: {} > bucket {}",
plaintext.len(),
body_bucket
);
}
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
let shared = x25519_dh(&eph_priv, recipient_x25519_pub);
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
// len prefix + body + random pad to the bucket.
let mut padded = Vec::with_capacity(4 + body_bucket);
padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes());
padded.extend_from_slice(plaintext);
let mut pad = vec![0u8; body_bucket - plaintext.len()];
rand::rng().fill_bytes(&mut pad);
padded.extend_from_slice(&pad);
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), padded.as_slice())
.map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?;
let mut out = Vec::with_capacity(32 + ciphertext.len());
out.extend_from_slice(&eph_pub);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Try to open a sealed greeting body with the recipient's x25519
/// private scalar. Returns `None` on any shape/AEAD failure (not sealed
/// to this key, or tampered).
pub fn open_greeting_body(
recipient_x25519_priv: &[u8; 32],
bio_post_id: &PostId,
sealed: &[u8],
) -> Option<Vec<u8>> {
if sealed.len() < 32 + 4 + 16 {
return None;
}
let mut eph_pub = [0u8; 32];
eph_pub.copy_from_slice(&sealed[..32]);
let shared = x25519_dh(recipient_x25519_priv, &eph_pub);
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?;
let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?;
if padded.len() < 4 {
return None;
}
let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize;
if 4 + real_len > padded.len() {
return None;
}
Some(padded[4..4 + real_len].to_vec())
}
/// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms).
pub fn sign_reaction(
seed: &[u8; 32],
@ -1359,20 +1546,133 @@ mod tests {
let ref_post = [2u8; 32];
let content = "preview";
let ts = 1000u64;
let exp = 5000u64;
// Signature including ref_post_id.
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post));
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp);
// Verifies only when the ref is supplied.
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post)));
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp));
// Same signature must NOT verify when the ref is dropped (binding).
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp));
// Nor when the ref is swapped.
let other_ref = [3u8; 32];
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref)));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp));
// Plain-comment signature still works (backward compat with v0.6.1).
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None);
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None));
// Plain-comment signature still works.
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp);
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp));
}
/// A3: digest v2 covers expires_at_ms — mutating the expiry flips
/// verification (no silent TTL extension possible).
#[test]
fn comment_signature_binds_expiry() {
let (seed, nid) = make_keypair(8);
let post_id = [1u8; 32];
let ts = 1000u64;
let exp = 90_000_000u64;
let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp);
assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp));
// Extended expiry must fail.
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1));
// Stripped expiry (0) must fail.
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0));
}
/// A3: self-certifying comment-delete roundtrip + wrong-key reject.
#[test]
fn comment_delete_sign_verify() {
let (seed, nid) = make_keypair(9);
let (_mseed, mallory) = make_keypair(10);
let post_id = [4u8; 32];
let ts = 123_456u64;
let sig = sign_comment_delete(&seed, &nid, &post_id, ts);
assert_eq!(sig.len(), 64);
assert!(verify_comment_delete(&nid, &post_id, ts, &sig));
// Wrong author key.
assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig));
// Wrong tuple.
assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig));
assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig));
// Garbage signature.
assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64]));
assert!(!verify_comment_delete(&nid, &post_id, ts, &[]));
}
/// A3: derive_open_slot_vx determinism + distinctness across nonces
/// and authors.
#[test]
fn open_slot_vx_deterministic_and_distinct() {
let (_s1, a1) = make_keypair(21);
let (_s2, a2) = make_keypair(22);
let n1 = [0xAA; 32];
let n2 = [0xBB; 32];
let v = derive_open_slot_vx(&a1, &n1);
assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic");
assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce");
assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author");
}
/// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext
/// length equality across different plaintext lengths, tamper reject,
/// wrong-key reject.
#[test]
fn greeting_body_roundtrip_and_bucketing() {
let (bob_priv, bob_pub) = make_persona_x25519(30);
let (carol_priv, _carol_pub) = make_persona_x25519(31);
let bio_post_id: PostId = [0x77; 32];
const BUCKET: usize = 1024;
let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#;
let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap();
assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext");
// Different plaintext length → same ciphertext length.
let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap();
assert_eq!(sealed.len(), sealed2.len(), "bucket hides length");
// Recipient opens.
let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap();
assert_eq!(opened, body.to_vec());
// Non-recipient cannot.
assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none());
// Wrong bio post id cannot.
assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none());
// Tampered ciphertext fails AEAD.
let mut tampered = sealed.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none());
// Oversized body refused.
assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err());
}
/// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored
/// reply private key and NOT with the recipient's long-term key.
#[test]
fn greeting_reply_sealed_to_fresh_key_only() {
// Long-term persona key of the greeting's original sender.
let (longterm_priv, _longterm_pub) = make_persona_x25519(40);
// Fresh per-greeting reply keypair minted by that sender.
let (reply_priv, reply_pub) = generate_x25519_keypair();
let return_path_post: PostId = [0x99; 32];
let reply_body = b"hello back";
let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap();
// Stored fresh reply key opens it.
let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap();
assert_eq!(opened, reply_body.to_vec());
// Long-term key does NOT.
assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none());
}
#[test]
@ -1383,7 +1683,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec!["10.0.0.1:4433".to_string()],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![ManifestEntry {
@ -1400,6 +1699,97 @@ mod tests {
assert!(verify_manifest_signature(&manifest));
}
/// v0.8 (A2): manifests must never carry device addresses — neither the
/// author-signed part nor the CdnManifest wire wrapper.
#[test]
fn test_manifest_carries_no_addresses() {
use crate::types::{AuthorManifest, CdnManifest, ManifestEntry};
let (seed, node_id) = make_keypair(1);
let (_hseed, host_id) = make_keypair(2);
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
created_at: 1000,
updated_at: 2000,
previous_posts: vec![ManifestEntry {
post_id: [1u8; 32],
timestamp_ms: 900,
has_attachments: true,
}],
following_posts: vec![],
signature: vec![],
};
manifest.signature = sign_manifest(&seed, &manifest);
assert!(verify_manifest_signature(&manifest));
let author_json = serde_json::to_string(&manifest).unwrap();
assert!(
!author_json.contains("addresses"),
"AuthorManifest JSON must not contain any address field: {author_json}"
);
let cdn = CdnManifest {
author_manifest: manifest,
host: host_id,
};
let cdn_json = serde_json::to_string(&cdn).unwrap();
assert!(
!cdn_json.contains("addresses"),
"CdnManifest JSON must not contain any address field: {cdn_json}"
);
assert!(!cdn_json.contains("downstream_count"));
assert!(!cdn_json.contains("\"source\""));
}
/// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple.
/// The historical bug paired the default posting secret with the NETWORK
/// NodeId — a pair that can never unwrap anything.
#[test]
fn test_cek_unwrap_requires_matching_persona_pair() {
let (author_seed, author_id) = make_keypair(1);
let (persona_seed, persona_id) = make_keypair(2); // recipient persona
let (network_seed, network_id) = make_keypair(9); // device network key
let (_encrypted, wrapped_keys) =
encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap();
// Mismatched pair (persona seed + network id): recipient lookup fails.
let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_none(), "network id must not appear in recipients");
// Network pair entirely: also fails.
let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_none());
// Matched persona pair: succeeds.
let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK");
}
/// A1 (revocation): rewrap_visibility works with the AUTHOR persona's
/// (seed, id) pair — and cannot work with the network pair the old code
/// passed.
#[test]
fn test_rewrap_visibility_uses_author_persona_pair() {
let (author_seed, author_id) = make_keypair(3); // non-default persona
let (_b_seed, bob_id) = make_keypair(4);
let (_c_seed, carol_id) = make_keypair(5);
let (network_seed, network_id) = make_keypair(9);
let (_encrypted, wrapped_keys) =
encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap();
// Old bug shape: (default/other secret, network id) — must fail.
assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err());
// Correct: the author persona's own pair — succeeds and re-wraps
// for the reduced recipient set.
let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap();
assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id));
assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone");
}
#[test]
fn test_forged_manifest_rejected() {
use crate::types::AuthorManifest;
@ -1409,7 +1799,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec![],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![],

View file

@ -91,7 +91,14 @@ pub async fn export_data(
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
(vec![], vec![])
} else {
gather_posts(storage, node_id).await?
// Own posts are authored by POSTING identities (personas) — the
// network NodeId never authors posts. Export across all personas.
let author_ids: Vec<NodeId> = {
let s = storage.get().await;
s.list_posting_identities()?
.into_iter().map(|p| p.node_id).collect()
};
gather_posts(storage, &author_ids).await?
};
let (follows, profiles, settings) = if scope == ExportScope::Everything {
@ -245,10 +252,10 @@ pub async fn export_data(
})
}
/// Gather own posts and their blob CIDs.
/// Gather own posts (authored by any of our posting identities) and their blob CIDs.
async fn gather_posts(
storage: &StoragePool,
node_id: &NodeId,
author_ids: &[NodeId],
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
let s = storage.get().await;
let posts_with_vis = s.list_posts_with_visibility()?;
@ -257,7 +264,7 @@ async fn gather_posts(
for (id, post, vis) in &posts_with_vis {
// Only export our own posts
if post.author != *node_id {
if !author_ids.contains(&post.author) {
continue;
}

View file

@ -20,7 +20,11 @@ use rand::RngCore;
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
use crate::storage::Storage;
use crate::types::{FoFCommentGating, NodeId, WrapSlot};
use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot};
/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything
/// larger on receive is presumed attacker-shaped.
pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
/// Build the `FoFCommentGating` block for a post about to be published
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
@ -33,9 +37,16 @@ use crate::types::{FoFCommentGating, NodeId, WrapSlot};
///
/// Side effect: this function is pure; no storage writes. The caller
/// owns persisting the resulting Post.
/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is
/// sealed under the derivable `V_open =
/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in
/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots).
/// The open slot is a REAL wrap slot sealed by the normal path; what
/// makes it open is only that its V_x is derivable by anyone.
pub fn build_fof_comment_gating(
storage: &Storage,
author_persona_id: &NodeId,
open_slot: Option<(OpenSlotKind, u16)>,
) -> Result<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// The author's own V_me appears with owner=author_persona_id.
@ -70,6 +81,9 @@ pub fn build_fof_comment_gating(
// (slot_index, owner, epoch, pub_x) afterward for provenance.
enum EntryKind {
Real { v_x_owner: NodeId, v_x_epoch: u32 },
/// A3: the derivable open slot (greeting/registry). Not part of
/// V_x provenance — its key is public by construction.
Open,
Dummy,
}
let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len());
@ -88,6 +102,25 @@ pub fn build_fof_comment_gating(
entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot));
}
// A3: seal the open slot (if requested) under the derivable V_open.
if open_slot.is_some() {
let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce);
let mut seed = [0u8; 32];
rand::rng().fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let pub_x = *signing_key.verifying_key().as_bytes();
let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?;
entries.push((
EntryKind::Open,
pub_x,
WrapSlot {
prefilter_tag: sealed.prefilter_tag,
read_ciphertext: sealed.read_ciphertext,
sign_ciphertext: sealed.sign_ciphertext,
},
));
}
// Pad to bucket with dummies.
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
let mut rng = rand::rng();
@ -117,24 +150,39 @@ pub fn build_fof_comment_gating(
let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
let mut open_slot_index: Option<u32> = None;
for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
real_slot_provenance.push(RealSlotProvenance {
slot_index: idx as u32,
v_x_owner,
v_x_epoch,
pub_x,
});
match kind {
EntryKind::Real { v_x_owner, v_x_epoch } => {
real_slot_provenance.push(RealSlotProvenance {
slot_index: idx as u32,
v_x_owner,
v_x_epoch,
pub_x,
});
}
EntryKind::Open => open_slot_index = Some(idx as u32),
EntryKind::Dummy => {}
}
pub_post_set.push(pub_x);
wrap_slots.push(slot);
}
let open_slot_decl = match (open_slot, open_slot_index) {
(Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl {
slot_index,
kind,
body_bucket,
}),
_ => None,
};
let gating = FoFCommentGating {
slot_binder_nonce,
pub_post_set,
wrap_slots,
revocation_list: Vec::new(),
open_slot: open_slot_decl,
};
Ok(Some(FoFCommentGatingBuilt {
@ -142,6 +190,7 @@ pub fn build_fof_comment_gating(
cek,
slot_binder_nonce,
real_slot_provenance,
open_slot_index,
}))
}
@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt {
/// `own_post_slot_provenance` for later cascade revocations.
/// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
pub real_slot_provenance: Vec<RealSlotProvenance>,
/// A3: index of the derivable open slot, when one was requested.
/// Mirrors `gating.open_slot.slot_index`.
pub open_slot_index: Option<u32>,
}
/// One entry per real (non-dummy) slot in a published FoF post.
@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x(
Ok(unlocked)
}
/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's
/// open-slot `V_open` from the post alone and opens the DECLARED slot,
/// returning a `PostUnlock` usable with [`build_fof_comment`].
/// `persona_id` is filled with the caller-provided commenter id
/// (typically a throwaway identity minted per greeting).
///
/// Returns `None` when the post has no gating, no open-slot declaration,
/// or the declared slot doesn't open under the derived key (malformed or
/// key-burned open slot = the author's global off-switch).
pub fn derive_open_slot_unlock(
post: &crate::types::Post,
commenter_persona_id: &NodeId,
) -> Option<PostUnlock> {
let gating = post.fof_gating.as_ref()?;
let decl = gating.open_slot.as_ref()?;
let slot = gating.wrap_slots.get(decl.slot_index as usize)?;
let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce);
let opened = crate::crypto::open_wrap_slot(
&v_open,
&gating.slot_binder_nonce,
&slot.read_ciphertext,
&slot.sign_ciphertext,
)?;
Some(PostUnlock {
persona_id: *commenter_persona_id,
slot_index: decl.slot_index,
cek: opened.cek,
priv_x_seed: opened.priv_x_seed,
})
}
/// Inner helper: prefilter + AEAD-open against a single V_x.
fn try_unlock_with_v_x(
gating: &crate::types::FoFCommentGating,
@ -376,6 +459,7 @@ pub fn build_fof_comment(
body: &str,
parent_comment_id: Option<[u8; 32]>,
now_ms: u64,
expires_at_ms: u64,
) -> Result<crate::types::InlineComment> {
use ed25519_dalek::{Signer, SigningKey};
@ -411,6 +495,7 @@ pub fn build_fof_comment(
"",
now_ms,
None,
expires_at_ms,
);
Ok(crate::types::InlineComment {
@ -424,6 +509,7 @@ pub fn build_fof_comment(
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: Some(encrypted),
expires_at_ms,
})
}
@ -439,7 +525,14 @@ pub fn verify_fof_group_sig(
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let Some(pub_x_index) = comment.pub_x_index else { return false; };
let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
// A3: plaintext open-slot comments (registry entries) carry their
// body in `content` with no encrypted_payload — the group_sig then
// covers the content bytes instead. FoF/greeting comments always
// carry Some(encrypted_payload) as before.
let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() {
Some(p) => p,
None => comment.content.as_bytes(),
};
let idx = pub_x_index as usize;
if idx >= gating.pub_post_set.len() { return false; }
if group_sig.len() != 64 { return false; }
@ -552,6 +645,24 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
);
}
// A3: open-slot declaration sanity.
if let Some(decl) = gating.open_slot.as_ref() {
if decl.slot_index as usize >= gating.wrap_slots.len() {
anyhow::bail!(
"open_slot.slot_index {} out of bounds ({} slots)",
decl.slot_index,
gating.wrap_slots.len(),
);
}
if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET {
anyhow::bail!(
"open_slot.body_bucket {} out of range (1..={})",
decl.body_bucket,
MAX_OPEN_SLOT_BODY_BUCKET,
);
}
}
Ok(())
}
@ -955,7 +1066,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built");
// Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
assert_eq!(built.gating.pub_post_set.len(), 8);
assert_eq!(built.gating.wrap_slots.len(), 8);
@ -1009,7 +1120,7 @@ mod tests {
let s = temp_storage();
let (alice_id, _) = make_persona(5);
// No V_me inserted.
let built = build_fof_comment_gating(&s, &alice_id).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap();
assert!(built.is_none(), "no V_me → no gating block");
}
@ -1031,7 +1142,7 @@ mod tests {
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
// Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
// We can't assert real count directly without exposing internals,
// but we can confirm exactly two distinct successful unwraps:
@ -1088,7 +1199,7 @@ mod tests {
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
// Alice publishes the gating block.
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
let parent_post_id = [0xCC; 32];
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
@ -1128,6 +1239,7 @@ mod tests {
"great post alice",
None,
4000,
4_000_000_000_000,
).unwrap();
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
assert!(comment.pub_x_index.is_some());
@ -1183,7 +1295,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xDE; 32];
// Persist the post so apply_fof_revocation_locally can resolve
@ -1228,7 +1340,7 @@ mod tests {
};
let comment = build_fof_comment(
&post_id, &bob_unlock, &built.slot_binder_nonce,
&bob_id, &bob_seed, "hello", None, 4000,
&bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000,
).unwrap();
s.store_comment(&comment).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
@ -1277,7 +1389,7 @@ mod tests {
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
// Initial gating: Alice only.
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xBC; 32];
let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![],
@ -1379,7 +1491,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_old);
s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xAB; 32];
let post = crate::types::Post {
author: alice_id, content: "x".into(), attachments: vec![],
@ -1469,6 +1581,7 @@ mod tests {
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
revocation_list: vec![],
open_slot: None,
}
}
@ -1574,7 +1687,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xEE; 32];
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
@ -1706,7 +1819,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap();
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
@ -1750,7 +1863,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_carol);
alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
// shouldn't unlock for him yet.
@ -1826,7 +1939,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let body_plaintext = "secret to the FoF set only";
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
@ -1905,7 +2018,7 @@ mod tests {
s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap();
s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
// 3 unique V_x's → 3 real slots, padded to bucket 8.
assert_eq!(built.real_slot_provenance.len(), 3);
assert_eq!(built.gating.pub_post_set.len(), 8);
@ -1962,4 +2075,105 @@ mod tests {
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
let _ = alice_seed;
}
// --- v0.8 (A3): open-slot tests ---
/// Gating built with an open slot: declaration present, validates on
/// receive; a stranger (no vouch keys at all) derives V_open from the
/// post alone, recovers CEK + signing seed, and a comment built with
/// the derived key passes the CDN group_sig check unmodified.
#[test]
fn open_slot_stranger_derive_and_comment() {
use crate::types::{OpenSlotKind, PostingIdentity};
use ed25519_dalek::SigningKey;
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(140);
s.upsert_posting_identity(&PostingIdentity {
node_id: alice_id, secret_seed: alice_seed,
display_name: "Alice".into(), created_at: 1000,
}).unwrap();
let mut v_me_alice = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)))
.unwrap().expect("built");
let decl = built.gating.open_slot.as_ref().expect("open slot declared");
assert_eq!(decl.kind, OpenSlotKind::Greeting);
assert_eq!(decl.body_bucket, 1024);
assert_eq!(Some(decl.slot_index), built.open_slot_index);
assert!((decl.slot_index as usize) < built.gating.wrap_slots.len());
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
};
// Wire-shape validation passes.
validate_fof_gating_on_receive(&post).unwrap();
// A stranger mints a throwaway identity, derives the unlock.
let (stranger_id, stranger_seed) = make_persona(141);
let unlock = derive_open_slot_unlock(&post, &stranger_id)
.expect("stranger derives the open slot unlock");
assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK");
assert_eq!(unlock.slot_index, decl.slot_index);
// The derived signing seed matches the published pub_x.
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub);
// Stranger authors a comment through the normal FoF path — must
// pass the CDN group_sig gate unmodified.
let comment = build_fof_comment(
&crate::content::compute_post_id(&post), &unlock,
&built.slot_binder_nonce, &stranger_id, &stranger_seed,
"hello, stranger here", None, 4000, 4_000_000_000_000,
).unwrap();
assert!(verify_fof_group_sig(&comment, &built.gating));
}
/// Open-slot declaration bounds are enforced on receive.
#[test]
fn open_slot_validation_bounds() {
use crate::types::{OpenSlotDecl, OpenSlotKind};
// slot_index out of bounds.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of bounds"), "got: {}", err);
// body_bucket too big.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of range"), "got: {}", err);
// body_bucket zero.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
}
/// No open slot declared → derive_open_slot_unlock returns None even
/// though the gating exists.
#[test]
fn open_slot_unlock_requires_declaration() {
let g = dummy_gating(8);
let p = dummy_post(Some(g));
let (stranger, _) = make_persona(150);
assert!(derive_open_slot_unlock(&p, &stranger).is_none());
}
}

View file

@ -297,4 +297,40 @@ mod tests {
assert!(!applied2);
assert!(s2.get_group_key(&group_id).unwrap().is_none());
}
/// A1 (multi-persona): a distribution post addressed to our SECOND
/// persona must still be applied — the trial-unwrap iterates ALL held
/// posting identities, not just the default one.
#[test]
fn second_persona_member_applies_distribution() {
let s = temp_storage();
let (admin_sec, admin_id) = make_keypair(1);
let (default_sec, default_id) = make_keypair(2); // default persona (not a member)
let (second_sec, second_id) = make_keypair(3); // second persona (the member)
let group_id = [43u8; 32];
let record = GroupKeyRecord {
group_id,
circle_name: "second".to_string(),
epoch: 1,
group_public_key: [8u8; 32],
admin: admin_id,
created_at: 100,
canonical_root_post_id: None,
};
let group_seed = [11u8; 32];
// Addressed ONLY to the second persona.
let (_pid, post, visibility) = build_distribution_post(
&admin_id, &admin_sec, &record, &group_seed, &[second_id],
).unwrap();
let personas = vec![
mk_persona(default_sec, default_id),
mk_persona(second_sec, second_id),
];
let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap();
assert!(applied, "second persona must unwrap the group seed");
assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed);
}
}

View file

@ -17,6 +17,7 @@ pub mod network;
pub mod node;
pub mod profile;
pub mod protocol;
pub mod registry;
pub mod storage;
pub mod stun;
pub mod types;

View file

@ -2291,6 +2291,22 @@ impl Network {
sent += 1;
}
}
// v0.8 (A3): no-known-holders fallback — seed the diff through
// up to 3 current mesh connections. Without this, a first
// engagement on a post with no recorded file_holders (e.g. a
// fresh registration on the self-materialized registry post, or
// a greeting on a bio just pulled) goes nowhere until someone
// pulls the header on the slow cadence.
if sent == 0 {
for peer in self.connected_peers().await.into_iter().take(3) {
if &peer == exclude_peer {
continue;
}
if self.send_to_peer_uni(&peer, MessageType::BlobHeaderDiff, payload).await.is_ok() {
sent += 1;
}
}
}
sent
}
}

File diff suppressed because it is too large Load diff

View file

@ -175,6 +175,9 @@ pub fn scan_vouch_grants_for_all_personas(
/// 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.
/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including
/// the Greeting open slot when the persona's `greetings_open` consent is
/// on. `None` publishes a bio that accepts no greetings.
pub fn build_profile_post(
author: &NodeId,
author_secret: &[u8; 32],
@ -183,6 +186,7 @@ pub fn build_profile_post(
avatar_cid: Option<[u8; 32]>,
vouch_grants: Option<crate::types::VouchGrantBatch>,
bio_epoch: u32,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -203,8 +207,8 @@ pub fn build_profile_post(
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
supersedes_post_id: None,
fof_gating,
supersedes_post_id: None,
}
}
@ -345,7 +349,7 @@ mod tests {
let s = temp_storage();
let (sec, pub_id) = make_keypair(11);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None);
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
@ -360,7 +364,7 @@ mod tests {
let (sec_b, _pub_b) = make_keypair(2);
// Build a post claiming `pub_a` but signing with `sec_b`.
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None);
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
assert!(res.is_err());
assert!(s.get_profile(&pub_a).unwrap().is_none());
@ -372,7 +376,7 @@ mod tests {
let (sec, pub_id) = make_keypair(3);
// Seed with a newer profile.
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None);
// Hack the timestamp to make it clearly newer.
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
content.timestamp_ms = 10_000;
@ -382,7 +386,7 @@ mod tests {
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
// Apply an older profile — should be ignored.
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None);
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
content_o.timestamp_ms = 5_000;
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);

View file

@ -1,11 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::types::{
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId,
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
};
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
///
/// DEPLOY GATE (v0.8): the manifest wire format + signature digest changed
/// incompatibly in this tree (AuthorManifest.author_addresses removed from
/// struct AND digest; CdnManifest reduced to 2 fields; GroupKeyRequest
/// 0xA1/0xA2 removed). Mixed-version nodes half-interop: old-signed
/// manifests fail the new digest check, new CdnManifest JSON fails old
/// deserialization. Scott ruled ALPN -> b"itsgoin/4" for v0.8 (2026-07-29,
/// scheduled Iteration B). Bump it BEFORE any build/deploy of this change
/// set so mixed generations refuse cleanly at handshake — ask Scott first
/// (feedback_ask_before_release).
pub const ALPN_V2: &[u8] = b"itsgoin/3";
/// A post bundled with its visibility metadata for sync
@ -52,8 +62,6 @@ pub enum MessageType {
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
// 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel
// as encrypted posts via the CDN. See `group_key_distribution` module.
GroupKeyRequest = 0xA1,
GroupKeyResponse = 0xA2,
RelayIntroduce = 0xB0,
RelayIntroduceResult = 0xB1,
SessionRelay = 0xB2,
@ -103,8 +111,6 @@ impl MessageType {
0x92 => Some(Self::ManifestRefreshRequest),
0x93 => Some(Self::ManifestRefreshResponse),
0x94 => Some(Self::ManifestPush),
0xA1 => Some(Self::GroupKeyRequest),
0xA2 => Some(Self::GroupKeyResponse),
0xB0 => Some(Self::RelayIntroduce),
0xB1 => Some(Self::RelayIntroduceResult),
0xB2 => Some(Self::SessionRelay),
@ -390,23 +396,6 @@ pub struct ManifestPushEntry {
// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See
// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`.
/// Member requests current group key (bi-stream request)
#[derive(Debug, Serialize, Deserialize)]
pub struct GroupKeyRequestPayload {
pub group_id: GroupId,
pub known_epoch: GroupEpoch,
}
/// Admin responds with wrapped key (bi-stream response)
#[derive(Debug, Serialize, Deserialize)]
pub struct GroupKeyResponsePayload {
pub group_id: GroupId,
pub epoch: GroupEpoch,
pub group_public_key: [u8; 32],
pub admin: NodeId,
pub member_key: Option<GroupMemberKey>,
}
// --- Relay introduction payloads ---
/// Relay introduction identifier for deduplication
@ -745,8 +734,6 @@ mod tests {
MessageType::ManifestRefreshRequest,
MessageType::ManifestRefreshResponse,
MessageType::ManifestPush,
MessageType::GroupKeyRequest,
MessageType::GroupKeyResponse,
MessageType::RelayIntroduce,
MessageType::RelayIntroduceResult,
MessageType::SessionRelay,

474
crates/core/src/registry.rs Normal file
View file

@ -0,0 +1,474 @@
//! v0.8 (A3): the network registry post — a well-known "registrations
//! here" post whose comment chain is the opt-in, searchable linkage of a
//! public persona name/keywords to a posting-identity author ID
//! (design.html §27 `id="discovery"`).
//!
//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`];
//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the
//! authority — never re-serialize the `Post` struct at runtime to
//! derive the id (serde field order = declaration order; any future
//! field would silently change the bytes).
//! - Every v0.8 node self-materializes the post at startup
//! ([`materialize_registry_post`]); only the comment-chain pull is
//! network work.
//! - Registration = the SAME open-slot comment implementation as
//! greetings (round-6 ruling), aimed at this post: plaintext JSON
//! entry, self-certified by the standard comment signature under the
//! persona's REAL posting key, fixed 30-day TTL, one active entry per
//! persona (newest wins).
//! - Sharding: `shard(seed, k)` derivation exists from day one via the
//! content string embedding the seed + k. k=1 forever in A3; adding
//! shard k+1 is a new content string = threshold change, not a
//! migration.
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::storage::Storage;
use crate::types::{NodeId, Post, PostId};
/// Shard seed + index (design ruling: derivable shard identity).
pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1";
pub const REGISTRY_SHARD_K: u32 = 1;
/// Fixed genesis timestamp baked into the canonical bytes
/// (2026-07-30T00:00:00Z).
pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000;
/// Single padded bucket for registration entries (plaintext JSON).
pub const REGISTRY_BODY_BUCKET: u16 = 512;
/// Registrations live exactly 30 days; re-sign to stay listed.
pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000;
/// Holder-side slack when checking the fixed TTL (clock skew).
pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000;
/// Entry shape limits (holder-enforced).
pub const MAX_REGISTRY_NAME_CHARS: usize = 64;
pub const MAX_REGISTRY_KEYWORDS: usize = 8;
pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32;
/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE
/// signing (the expiry is inside the signed digest).
pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`.
/// The randomness serves identity hygiene — throwaway commenter IDs
/// vanish at unpredictable times.
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` makes every ordinary
/// comment expire in n seconds (integration-test hook; debug builds only).
pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 {
#[cfg(debug_assertions)]
{
if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") {
if let Ok(secs) = secs.parse::<u64>() {
return now_ms + secs * 1000;
}
}
}
use rand::Rng;
let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS);
now_ms + ttl
}
/// The frozen canonical serialized bytes of the registry post. Generated
/// once (see `build_canonical_registry_post` + the `frozen_bytes_*`
/// tests) and pasted here as a literal. DO NOT regenerate at runtime.
pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json");
/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted
/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`).
pub const REGISTRY_POST_ID: PostId = [
0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc,
0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d,
0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf,
0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4,
];
/// Deterministic builder for the canonical registry post. All seal
/// inputs are blake3-derived from the shard seed + k, so the output
/// bytes are reproducible bit-for-bit — this is what generated (and what
/// CI re-checks against) [`REGISTRY_POST_JSON`].
pub fn build_canonical_registry_post() -> Result<Post> {
use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot};
use ed25519_dalek::SigningKey;
let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K);
let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) };
let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder");
let cek = derive("itsgoin/registry/v1/cek");
let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed");
let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes();
let v_open = crate::crypto::derive_open_slot_vx(
&crate::DEFAULT_ANCHOR_POSTING_ID,
&slot_binder_nonce,
);
let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?;
let gating = FoFCommentGating {
slot_binder_nonce,
pub_post_set: vec![pub_x],
wrap_slots: vec![WrapSlot {
prefilter_tag: sealed.prefilter_tag,
read_ciphertext: sealed.read_ciphertext,
sign_ciphertext: sealed.sign_ciphertext,
}],
revocation_list: vec![],
open_slot: Some(OpenSlotDecl {
slot_index: 0,
kind: OpenSlotKind::Registry,
body_bucket: REGISTRY_BODY_BUCKET,
}),
};
Ok(Post {
author: crate::DEFAULT_ANCHOR_POSTING_ID,
content: format!(
"ItsGoin Network Registry — shard({}, k={}). Register here with a signed \
public comment {{name, keywords}} authored by your persona's posting key. \
Entries are self-certifying, expire after 30 days, and are newest-wins per \
persona. Delete = signed DeleteComment.",
REGISTRY_SHARD_SEED, REGISTRY_SHARD_K,
),
attachments: vec![],
timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS,
fof_gating: Some(gating),
supersedes_post_id: None,
})
}
/// Parse the frozen constant into a `Post`. `verify_post_id` passes by
/// construction everywhere.
pub fn registry_post() -> Post {
serde_json::from_str(REGISTRY_POST_JSON)
.expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise")
}
/// Self-materialize the registry post (store-if-absent, Public). Every
/// v0.8 node can therefore hold/serve the chain; no fetch needed.
/// Returns `true` if the post was newly stored.
pub fn materialize_registry_post(storage: &Storage) -> Result<bool> {
let post = registry_post();
debug_assert!(crate::content::verify_post_id(&REGISTRY_POST_ID, &post));
storage.store_post_with_intent(
&REGISTRY_POST_ID,
&post,
&crate::types::PostVisibility::Public,
&crate::types::VisibilityIntent::Public,
)
}
/// A registration entry — plaintext JSON in the comment `content`.
/// Public by intent: no encryption, no anonymity.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegistrationEntry {
pub v: u32,
pub name: String,
#[serde(default)]
pub keywords: Vec<String>,
}
/// Parse + shape-validate a registration entry from comment content.
pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
if content.len() > REGISTRY_BODY_BUCKET as usize {
bail!("registration entry exceeds body bucket ({} bytes)", content.len());
}
let entry: RegistrationEntry = serde_json::from_str(content)
.map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?;
if entry.v != 1 {
bail!("unsupported registration version {}", entry.v);
}
if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS {
bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS);
}
if entry.keywords.len() > MAX_REGISTRY_KEYWORDS {
bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS);
}
for kw in &entry.keywords {
if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS {
bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS);
}
}
Ok(entry)
}
/// Fixed-30d TTL check for registrations: `expires_at_ms timestamp_ms`
/// must equal 30 days within ±5 minutes of slack.
pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool {
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; };
ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS
}
/// One search hit from the local registry chain.
#[derive(Debug, Clone)]
pub struct RegistryMatch {
pub author: NodeId,
pub name: String,
pub keywords: Vec<String>,
pub timestamp_ms: u64,
pub expires_at_ms: u64,
}
/// Case-insensitive substring search over name + keywords of the
/// locally-held, unexpired, newest-wins registry entries. Search cost
/// lands on the searcher (design §27) — LOCAL query only; chain refresh
/// is the caller's job (`Node::search_registry`).
///
/// An empty query returns every live entry (browse mode).
pub fn search_entries(storage: &Storage, query: &str) -> Result<Vec<RegistryMatch>> {
let comments = storage.get_comments(&REGISTRY_POST_ID)?;
let needle = query.trim().to_lowercase();
// Newest-wins per author (storage enforces at ingest; dedupe
// defensively for locally-authored duplicates).
let mut newest: std::collections::HashMap<NodeId, RegistryMatch> =
std::collections::HashMap::new();
for c in &comments {
let Ok(entry) = parse_registration(&c.content) else { continue; };
let m = RegistryMatch {
author: c.author,
name: entry.name,
keywords: entry.keywords,
timestamp_ms: c.timestamp_ms,
expires_at_ms: c.expires_at_ms,
};
match newest.get(&c.author) {
Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {}
_ => {
newest.insert(c.author, m);
}
}
}
let mut hits: Vec<RegistryMatch> = newest
.into_values()
.filter(|m| {
if needle.is_empty() {
return true;
}
m.name.to_lowercase().contains(&needle)
|| m.keywords.iter().any(|k| k.to_lowercase().contains(&needle))
})
.collect();
hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
Ok(hits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::InlineComment;
fn temp_storage() -> Storage {
Storage::open(":memory:").unwrap()
}
/// Generator (dev-time): rebuild the canonical bytes and print them.
/// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture`
/// when (and only when) the canonical definition intentionally changes,
/// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID.
#[test]
#[ignore]
fn registry_generator() {
let post = build_canonical_registry_post().unwrap();
let json = serde_json::to_string(&post).unwrap();
let id = blake3::hash(json.as_bytes());
println!("JSON:\n{}", json);
println!("ID: {:?}", id.as_bytes());
println!("ID hex: {}", hex::encode(id.as_bytes()));
}
/// CI: the frozen literal, its BLAKE3, the shipped constant, and the
/// deterministic builder must all agree — forever.
#[test]
fn frozen_bytes_triple_assertion() {
// 1. blake3(frozen bytes) == REGISTRY_POST_ID.
let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes());
assert_eq!(
hash.as_bytes(),
&REGISTRY_POST_ID,
"REGISTRY_POST_ID must equal blake3 of the frozen bytes",
);
// 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID —
// i.e., re-serialization is currently byte-identical, so
// verify_post_id passes everywhere.
let post = registry_post();
assert_eq!(
crate::content::compute_post_id(&post),
REGISTRY_POST_ID,
"round-trip serialization must reproduce the frozen bytes",
);
// 3. The deterministic builder still reproduces the constant
// (all seal inputs derived — no drift in crypto derivations).
let rebuilt = build_canonical_registry_post().unwrap();
assert_eq!(
serde_json::to_string(&rebuilt).unwrap(),
REGISTRY_POST_JSON,
"deterministic builder must reproduce the frozen bytes",
);
// Sanity: canonical fields.
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS);
let gating = post.fof_gating.as_ref().expect("gating");
let decl = gating.open_slot.as_ref().expect("open slot");
assert_eq!(decl.slot_index, 0);
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
assert_eq!(gating.wrap_slots.len(), 1);
}
/// The registry open slot must actually open under the derivable key
/// and yield the signing seed matching pub_post_set[0].
#[test]
fn registry_open_slot_derivable() {
use ed25519_dalek::SigningKey;
let post = registry_post();
let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32])
.expect("registry open slot must open under the derived key");
assert_eq!(unlock.slot_index, 0);
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub);
}
#[test]
fn materialize_is_idempotent() {
let s = temp_storage();
assert!(materialize_registry_post(&s).unwrap(), "first store inserts");
assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops");
let (post, vis) = s.get_post_with_visibility(&REGISTRY_POST_ID).unwrap().unwrap();
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
assert!(matches!(vis, crate::types::PostVisibility::Public));
}
#[test]
fn registration_shape_limits() {
// Good.
parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap();
// No keywords is fine.
parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap();
// Wrong version.
assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err());
// Empty name.
assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err());
// Name too long.
let long_name = "x".repeat(65);
assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err());
// Too many keywords.
let kws: Vec<String> = (0..9).map(|i| format!("k{}", i)).collect();
let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string();
assert!(parse_registration(&json).is_err());
// Keyword too long.
let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string();
assert!(parse_registration(&json).is_err());
// Not JSON.
assert!(parse_registration("hello").is_err());
}
#[test]
fn registration_ttl_fixed_30d() {
let ts = 1_000_000u64;
// Exact 30d.
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS));
// Inside slack.
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS));
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS));
// Outside slack.
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1));
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1));
// Degenerate.
assert!(!registration_ttl_ok(ts, ts));
assert!(!registration_ttl_ok(ts, 0));
}
fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment {
InlineComment {
author,
post_id: REGISTRY_POST_ID,
content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(),
timestamp_ms: ts,
signature: vec![0u8; 64],
deleted_at: None,
ref_post_id: None,
pub_x_index: Some(0),
group_sig: None,
encrypted_payload: None,
expires_at_ms: ts + REGISTRATION_TTL_MS,
}
}
fn test_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
#[test]
fn newest_wins_upsert() {
let s = temp_storage();
let author = [7u8; 32];
let t0 = test_now();
// First entry at t0.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0).unwrap());
s.store_comment(&reg_comment(author, t0, "Old", &["a"])).unwrap();
// Newer entry at t0+1000: accepted, older row hard-deleted.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
s.store_comment(&reg_comment(author, t0 + 1000, "New", &["b"])).unwrap();
let comments = s.get_comments(&REGISTRY_POST_ID).unwrap();
assert_eq!(comments.len(), 1, "older row hard-deleted");
assert_eq!(comments[0].timestamp_ms, t0 + 1000);
// Older-than-stored entry: rejected, nothing changes.
assert!(!s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 500).unwrap());
assert_eq!(s.get_comments(&REGISTRY_POST_ID).unwrap().len(), 1);
// Same-timestamp re-ingest: idempotent accept.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
}
#[test]
fn search_matches_name_and_keywords() {
let s = temp_storage();
let alice = [1u8; 32];
let bob = [2u8; 32];
let t0 = test_now();
s.store_comment(&reg_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
s.store_comment(&reg_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap();
// Keyword hit (case-insensitive).
let hits = search_entries(&s, "RUST").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].author, alice);
assert_eq!(hits[0].name, "Alice");
// Name substring hit.
let hits = search_entries(&s, "bo").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].author, bob);
// Empty query = browse all.
assert_eq!(search_entries(&s, "").unwrap().len(), 2);
// No hit.
assert!(search_entries(&s, "zzz").unwrap().is_empty());
}
#[test]
fn search_skips_expired_entries() {
let s = temp_storage();
let alice = [1u8; 32];
let mut c = reg_comment(alice, 1000, "Alice", &["rust"]);
c.expires_at_ms = 2000; // long past
s.store_comment(&c).unwrap();
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
}
}

View file

@ -0,0 +1 @@
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}}

View file

@ -366,9 +366,28 @@ impl Storage {
group_sig BLOB,
encrypted_payload BLOB,
deleted_at INTEGER,
-- v0.8 (A3): absolute expiry (ms). NULL/0 = legacy no-TTL.
expires_at INTEGER,
PRIMARY KEY (author, post_id, timestamp_ms)
);
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
-- v0.8 (A3): local-only greeting inbox dismissals.
CREATE TABLE IF NOT EXISTS greeting_dismissals (
comment_author BLOB NOT NULL,
post_id BLOB NOT NULL,
timestamp_ms INTEGER NOT NULL,
dismissed_at INTEGER NOT NULL,
PRIMARY KEY (comment_author, post_id, timestamp_ms)
);
-- v0.8 (A3): private halves of per-greeting reply x25519 keys,
-- keyed by the reply pubkey we told the recipient to seal to.
-- return_post_id = our return-path post (own bio) at mint time.
CREATE TABLE IF NOT EXISTS greeting_reply_keys (
reply_pubkey BLOB PRIMARY KEY,
reply_privkey BLOB NOT NULL,
return_post_id BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS comment_policies (
post_id BLOB PRIMARY KEY,
policy_json TEXT NOT NULL
@ -800,6 +819,17 @@ impl Storage {
)?;
}
// v0.8 (A3): add expires_at for comment TTL (rand 30365d ordinary,
// fixed 30d registry entries). NULL/0 = legacy comment without TTL.
let has_expires_at = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='expires_at'"
)?.query_row([], |row| row.get::<_, i64>(0))?;
if has_expires_at == 0 {
self.conn.execute_batch(
"ALTER TABLE comments ADD COLUMN expires_at INTEGER DEFAULT NULL;"
)?;
}
// v0.6.2: add canonical_root_post_id to group_keys. When set, the
// record is a group (many-way, anchored at a public root post);
// when NULL it's a traditional circle (one-way, admin-only).
@ -1249,20 +1279,29 @@ impl Storage {
}
/// Batch: reaction counts for multiple posts at once
pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_node_id: &NodeId) -> anyhow::Result<std::collections::HashMap<PostId, Vec<(String, u64, bool)>>> {
/// `our_ids` = ALL of the local node's posting identities (reactors are
/// posting ids — the network NodeId never reacts).
pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_ids: &[NodeId]) -> anyhow::Result<std::collections::HashMap<PostId, Vec<(String, u64, bool)>>> {
use std::collections::HashMap;
let mut result: HashMap<PostId, Vec<(String, u64, bool)>> = HashMap::new();
if post_ids.is_empty() { return Ok(result); }
let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::<Vec<_>>().join(",");
let mine_placeholders: String = if our_ids.is_empty() {
"NULL".to_string()
} else {
(0..our_ids.len()).map(|i| format!("?{}", post_ids.len() + 1 + i)).collect::<Vec<_>>().join(",")
};
let sql = format!(
"SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor = ?{} THEN 1 ELSE 0 END) as my_count
"SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count
FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL
GROUP BY post_id, emoji ORDER BY cnt DESC",
post_ids.len() + 1, placeholders
mine_placeholders, placeholders
);
let mut stmt = self.conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box<dyn rusqlite::types::ToSql>).collect();
params.push(Box::new(our_node_id.to_vec()));
for oid in our_ids {
params.push(Box::new(oid.to_vec()));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let pid: Vec<u8> = row.get(0)?;
@ -2557,15 +2596,18 @@ impl Storage {
}
}
/// Resolve display info for a peer: check circle profiles the viewer belongs to,
/// then fall back to public profile.
/// Resolve display info for a peer: check circle profiles any of the
/// viewer's posting identities belongs to, then fall back to public
/// profile. `viewers` = ALL of the local node's posting identities
/// (circle_members holds posting ids, never the network NodeId).
/// Returns (display_name, bio, avatar_cid).
pub fn resolve_display_for_peer(
&self,
author: &NodeId,
viewer: &NodeId,
viewers: &[NodeId],
) -> anyhow::Result<(String, String, Option<[u8; 32]>)> {
// Find circles where viewer is a member and author has a circle profile
// Find circles where a viewer persona is a member and author has a
// circle profile; take the freshest match across all personas.
let mut stmt = self.conn.prepare(
"SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at
FROM circle_profiles cp
@ -2574,17 +2616,26 @@ impl Storage {
ORDER BY cp.updated_at DESC
LIMIT 1",
)?;
let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?;
if let Some(row) = rows.next()? {
let dn: String = row.get(0)?;
// Only use circle profile if it has actual content
if !dn.is_empty() {
let bio: String = row.get(1)?;
let avatar_cid = row.get::<_, Option<Vec<u8>>>(2).unwrap_or(None)
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
return Ok((dn, bio, avatar_cid));
let mut best: Option<(String, String, Option<[u8; 32]>, u64)> = None;
for viewer in viewers {
let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?;
if let Some(row) = rows.next()? {
let dn: String = row.get(0)?;
// Only use circle profile if it has actual content
if !dn.is_empty() {
let bio: String = row.get(1)?;
let avatar_cid = row.get::<_, Option<Vec<u8>>>(2).unwrap_or(None)
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
let updated_at = row.get::<_, i64>(3).unwrap_or(0) as u64;
if best.as_ref().map(|b| updated_at > b.3).unwrap_or(true) {
best = Some((dn, bio, avatar_cid, updated_at));
}
}
}
}
if let Some((dn, bio, avatar_cid, _)) = best {
return Ok((dn, bio, avatar_cid));
}
// Fall back to public profile
if let Some(profile) = self.get_profile(author)? {
@ -4508,6 +4559,36 @@ impl Storage {
Ok(result)
}
/// List ALL stored CDN manifests: (cid, manifest_json). Used by the v0.8
/// startup migration to re-sign own manifests / purge stale foreign ones.
pub fn list_all_cdn_manifests(&self) -> anyhow::Result<Vec<([u8; 32], String)>> {
let mut stmt = self.conn.prepare(
"SELECT cid, manifest_json FROM cdn_manifests"
)?;
let rows = stmt.query_map([], |row| {
let cid_bytes: Vec<u8> = row.get(0)?;
let json: String = row.get(1)?;
Ok((cid_bytes, json))
})?;
let mut result = Vec::new();
for row in rows {
let (cid_bytes, json) = row?;
let cid: [u8; 32] = cid_bytes.try_into()
.map_err(|_| anyhow::anyhow!("invalid cid in cdn_manifests"))?;
result.push((cid, json));
}
Ok(result)
}
/// Delete a single CDN manifest row by CID (file_holders untouched).
pub fn delete_cdn_manifest(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
self.conn.execute(
"DELETE FROM cdn_manifests WHERE cid = ?1",
params![cid.as_slice()],
)?;
Ok(())
}
/// Get CIDs of manifests older than a cutoff. Callers look up holders
/// via file_holders to pick a refresh source.
pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result<Vec<[u8; 32]>> {
@ -5908,13 +5989,27 @@ impl Storage {
}
/// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions).
pub fn get_reaction_counts(&self, post_id: &PostId, my_node_id: &NodeId) -> anyhow::Result<Vec<(String, u64, bool)>> {
let mut stmt = self.conn.prepare(
/// `my_ids` = ALL of the local node's posting identities (reactors are
/// posting ids — the network NodeId never reacts).
pub fn get_reaction_counts(&self, post_id: &PostId, my_ids: &[NodeId]) -> anyhow::Result<Vec<(String, u64, bool)>> {
let mine_placeholders: String = if my_ids.is_empty() {
"NULL".to_string()
} else {
(0..my_ids.len()).map(|i| format!("?{}", i + 2)).collect::<Vec<_>>().join(",")
};
let sql = format!(
"SELECT emoji, COUNT(*) as cnt,
SUM(CASE WHEN reactor = ?2 THEN 1 ELSE 0 END) as my_count
FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC"
)?;
let rows = stmt.query_map(params![post_id.as_slice(), my_node_id.as_slice()], |row| {
SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count
FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC",
mine_placeholders
);
let mut stmt = self.conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(post_id.to_vec())];
for id in my_ids {
params.push(Box::new(id.to_vec()));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let emoji: String = row.get(0)?;
let count: i64 = row.get(1)?;
let my_count: i64 = row.get(2)?;
@ -5935,15 +6030,18 @@ impl Storage {
self.conn.execute(
"INSERT INTO comments
(author, post_id, content, timestamp_ms, signature, deleted_at,
ref_post_id, pub_x_index, group_sig, encrypted_payload)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ref_post_id, pub_x_index, group_sig, encrypted_payload, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
signature = CASE WHEN excluded.deleted_at IS NOT NULL THEN signature ELSE excluded.signature END,
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END,
ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id),
pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index),
group_sig = COALESCE(excluded.group_sig, group_sig),
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)",
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload),
expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0
THEN excluded.expires_at ELSE expires_at END",
params![
comment.author.as_slice(),
comment.post_id.as_slice(),
@ -5955,6 +6053,7 @@ impl Storage {
comment.pub_x_index.map(|i| i as i64),
comment.group_sig.as_ref().map(|b| b.as_slice()),
comment.encrypted_payload.as_ref().map(|b| b.as_slice()),
if comment.expires_at_ms > 0 { Some(comment.expires_at_ms as i64) } else { None },
],
)?;
Ok(())
@ -5978,14 +6077,18 @@ impl Storage {
Ok(updated > 0)
}
/// Get live (non-tombstoned) comments for a post. Used for UI display.
/// Get live (non-tombstoned, unexpired) comments for a post. Used for
/// UI display. The expiry filter is belt-and-suspenders between
/// `expire_comments` sweeps.
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
let mut stmt = self.conn.prepare(
"SELECT author, post_id, content, timestamp_ms, signature, ref_post_id,
pub_x_index, group_sig, encrypted_payload
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC"
pub_x_index, group_sig, encrypted_payload, expires_at
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)
ORDER BY timestamp_ms ASC"
)?;
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| {
let author: Vec<u8> = row.get(0)?;
let pid: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
@ -5995,11 +6098,12 @@ impl Storage {
let pxi: Option<i64> = row.get(6)?;
let gsig: Option<Vec<u8>> = row.get(7)?;
let epl: Option<Vec<u8>> = row.get(8)?;
Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl))
let exp: Option<i64> = row.get(9)?;
Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl, exp))
})?;
let mut result = Vec::new();
for row in rows {
let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?;
let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl, exp) = row?;
let author = blob_to_nodeid(author_bytes)?;
let post_id = blob_to_postid(pid_bytes)?;
let ref_post_id = match ref_post {
@ -6017,19 +6121,30 @@ impl Storage {
pub_x_index: pxi.map(|v| v as u32),
group_sig: gsig,
encrypted_payload: epl,
expires_at_ms: exp.unwrap_or(0) as u64,
});
}
Ok(result)
}
/// Get ALL comments for a post, including tombstoned ones. Used for header rebuild
/// so tombstones propagate through pull-based sync.
/// Get ALL comments for a post, including tombstoned ones (but never
/// expired ones — expiry is the forgetting mechanism and expired
/// comments must not be re-served). Used for header rebuild so
/// tombstones propagate through pull-based sync.
///
/// v0.8 (A3): also carries the FoF fields (pub_x_index / group_sig /
/// encrypted_payload) + expires_at — without them, FoF/greeting
/// comments served via full-header sync would lose their group_sig
/// and be dropped by the receive gate.
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
let mut stmt = self.conn.prepare(
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id
FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC"
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id,
pub_x_index, group_sig, encrypted_payload, expires_at
FROM comments WHERE post_id = ?1
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)
ORDER BY timestamp_ms ASC"
)?;
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| {
let author: Vec<u8> = row.get(0)?;
let pid: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
@ -6037,11 +6152,15 @@ impl Storage {
let sig: Vec<u8> = row.get(4)?;
let del: Option<i64> = row.get(5)?;
let ref_post: Option<Vec<u8>> = row.get(6)?;
Ok((author, pid, content, ts, sig, del, ref_post))
let pxi: Option<i64> = row.get(7)?;
let gsig: Option<Vec<u8>> = row.get(8)?;
let epl: Option<Vec<u8>> = row.get(9)?;
let exp: Option<i64> = row.get(10)?;
Ok((author, pid, content, ts, sig, del, ref_post, pxi, gsig, epl, exp))
})?;
let mut result = Vec::new();
for row in rows {
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?;
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post, pxi, gsig, epl, exp) = row?;
let author = blob_to_nodeid(author_bytes)?;
let post_id = blob_to_postid(pid_bytes)?;
let ref_post_id = match ref_post {
@ -6056,22 +6175,319 @@ impl Storage {
signature: sig,
deleted_at: del.map(|v| v as u64),
ref_post_id,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
pub_x_index: pxi.map(|v| v as u32),
group_sig: gsig,
encrypted_payload: epl,
expires_at_ms: exp.unwrap_or(0) as u64,
});
}
Ok(result)
}
/// Get comment count for a post (excludes tombstoned comments).
/// Get comment count for a post (excludes tombstoned + expired comments).
pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result<u64> {
let count: i64 = self.conn.prepare(
"SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL"
)?.query_row(params![post_id.as_slice()], |row| row.get(0))?;
"SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)"
)?.query_row(params![post_id.as_slice(), now_ms()], |row| row.get(0))?;
Ok(count as u64)
}
/// v0.8 (A3): rebuild the post's aggregated blob header from vetted
/// DB state (reactions + comments + policy), preserving slot fields
/// from any previously-stored header JSON. Used after local comment
/// creation (so pulls serve our own engagement) and after pull-path
/// ingestion (so we never re-serve peer-supplied comments our accept
/// gate rejected).
pub fn rebuild_blob_header_from_db(
&self,
post_id: &PostId,
fallback_author: &NodeId,
now: u64,
) -> anyhow::Result<()> {
let reactions = self.get_reactions_with_tombstones(post_id).unwrap_or_default();
let comments = self.get_comments_with_tombstones(post_id).unwrap_or_default();
let policy = self.get_comment_policy(post_id).ok().flatten().unwrap_or_default();
let (existing_json, _) = self.get_blob_header(post_id).ok().flatten()
.unwrap_or((String::new(), 0));
let mut header: crate::types::BlobHeader = serde_json::from_str(&existing_json)
.unwrap_or_else(|_| crate::types::BlobHeader {
post_id: *post_id,
author: *fallback_author,
reactions: vec![],
comments: vec![],
policy: CommentPolicy::default(),
updated_at: 0,
thread_splits: vec![],
receipt_slots: vec![],
comment_slots: vec![],
prior_author: None,
});
header.post_id = *post_id;
header.reactions = reactions;
header.comments = comments;
header.policy = policy;
header.updated_at = now;
// Trust the locally-stored post for authorship when available.
let author = self.get_post(post_id).ok().flatten()
.map(|p| p.author)
.unwrap_or(*fallback_author);
header.author = author;
let json = serde_json::to_string(&header)?;
self.store_blob_header(post_id, &author, &json, now)
}
// --- v0.8 (A3): comment TTL sweep + open-slot helpers ---
/// Hard-DELETE (not tombstone) all comments whose expiry has passed.
/// Expiry is the forgetting mechanism — tombstones would keep
/// throwaway IDs alive. Returns the number of rows deleted.
pub fn expire_comments(&self, now_ms_val: u64) -> anyhow::Result<usize> {
let deleted = self.conn.execute(
"DELETE FROM comments
WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?1",
params![now_ms_val as i64],
)?;
Ok(deleted)
}
/// Newest-wins registry rule: one active entry per persona (`author`)
/// on the registry post. Returns `true` if the incoming row (at
/// `incoming_ts`) is the newest for this author — in which case any
/// strictly-older rows are HARD-deleted — and `false` when a newer
/// entry is already stored (caller drops the incoming one).
pub fn upsert_registry_entry_newest_wins(
&self,
post_id: &PostId,
author: &NodeId,
incoming_ts: u64,
) -> anyhow::Result<bool> {
if !self.registry_entry_is_newest(post_id, author, incoming_ts)? {
return Ok(false);
}
self.prune_older_registry_entries(post_id, author, incoming_ts)?;
Ok(true)
}
/// READ-ONLY newest-wins check: is `incoming_ts` at least as new as
/// every stored entry for this persona? Used by the accept gate,
/// which must stay side-effect free — the destructive prune happens
/// in the storing callers AFTER a successful store_comment.
pub fn registry_entry_is_newest(
&self,
post_id: &PostId,
author: &NodeId,
incoming_ts: u64,
) -> anyhow::Result<bool> {
let newest: Option<i64> = self.conn.query_row(
"SELECT MAX(timestamp_ms) FROM comments WHERE post_id = ?1 AND author = ?2",
params![post_id.as_slice(), author.as_slice()],
|row| row.get(0),
)?;
if let Some(newest) = newest {
if (newest as u64) > incoming_ts {
return Ok(false);
}
}
Ok(true)
}
/// Hard-delete this persona's registry entries older than `newest_ts`.
/// Call only after the newest entry has been successfully stored, so
/// a store failure never leaves the persona entry-less.
pub fn prune_older_registry_entries(
&self,
post_id: &PostId,
author: &NodeId,
newest_ts: u64,
) -> anyhow::Result<usize> {
let n = self.conn.execute(
"DELETE FROM comments WHERE post_id = ?1 AND author = ?2 AND timestamp_ms < ?3",
params![post_id.as_slice(), author.as_slice(), newest_ts as i64],
)?;
Ok(n)
}
/// Count live (unexpired, non-tombstoned) comments on a post's open
/// slot — the per-bio greeting flood cap input.
pub fn count_unexpired_open_slot_comments(
&self,
post_id: &PostId,
pub_x_index: u32,
) -> anyhow::Result<u64> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM comments
WHERE post_id = ?1 AND pub_x_index = ?2 AND deleted_at IS NULL
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?3)",
params![post_id.as_slice(), pub_x_index as i64, now_ms()],
|row| row.get(0),
)?;
Ok(count as u64)
}
/// Local-only: mark a greeting as dismissed in the inbox.
pub fn add_greeting_dismissal(
&self,
comment_author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
) -> anyhow::Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO greeting_dismissals
(comment_author, post_id, timestamp_ms, dismissed_at)
VALUES (?1, ?2, ?3, ?4)",
params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64, now_ms()],
)?;
Ok(())
}
/// Local-only: was this greeting dismissed?
pub fn is_greeting_dismissed(
&self,
comment_author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
) -> anyhow::Result<bool> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM greeting_dismissals
WHERE comment_author = ?1 AND post_id = ?2 AND timestamp_ms = ?3",
params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Persist the private half of a per-greeting reply x25519 keypair.
pub fn store_greeting_reply_key(
&self,
reply_pubkey: &[u8; 32],
reply_privkey: &[u8; 32],
return_post_id: &PostId,
) -> anyhow::Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO greeting_reply_keys
(reply_pubkey, reply_privkey, return_post_id, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![
reply_pubkey.as_slice(),
reply_privkey.as_slice(),
return_post_id.as_slice(),
now_ms(),
],
)?;
Ok(())
}
/// List all stored reply private keys (privkey, return_post_id).
/// Used to unseal replies arriving on our return-path posts.
pub fn list_greeting_reply_keys(&self) -> anyhow::Result<Vec<([u8; 32], PostId)>> {
let mut stmt = self.conn.prepare(
"SELECT reply_privkey, return_post_id FROM greeting_reply_keys ORDER BY created_at DESC"
)?;
let rows = stmt.query_map([], |row| {
let privkey: Vec<u8> = row.get(0)?;
let post: Vec<u8> = row.get(1)?;
Ok((privkey, post))
})?;
let mut result = Vec::new();
for row in rows {
let (privkey, post) = row?;
let pk: [u8; 32] = privkey.as_slice().try_into()
.map_err(|_| anyhow::anyhow!("invalid reply privkey length"))?;
result.push((pk, blob_to_postid(post)?));
}
Ok(result)
}
/// All FoF-gated posts authored by `author` (fof_gating_json set),
/// reconstructed with their gating. Used by the greeting inbox to
/// scan own bio posts (current + previous revisions) for open-slot
/// comments.
pub fn list_gated_posts_by_author(
&self,
author: &NodeId,
) -> anyhow::Result<Vec<(PostId, Post)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json
FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL
ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map(params![author.as_slice()], |row| {
let id: Vec<u8> = row.get(0)?;
let author: Vec<u8> = row.get(1)?;
let content: String = row.get(2)?;
let attachments: String = row.get(3)?;
let ts: i64 = row.get(4)?;
let fof_json: Option<String> = row.get(5)?;
Ok((id, author, content, attachments, ts, fof_json))
})?;
let mut result = Vec::new();
for row in rows {
let (id, author_bytes, content, attachments_json, ts, fof_json) = row?;
let attachments: Vec<Attachment> =
serde_json::from_str(&attachments_json).unwrap_or_default();
let fof_gating = fof_json
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
result.push((
blob_to_postid(id)?,
Post {
author: blob_to_nodeid(author_bytes)?,
content,
attachments,
timestamp_ms: ts as u64,
fof_gating,
supersedes_post_id: None,
},
));
}
Ok(result)
}
/// Newest live registry entry for `author`:
/// `(timestamp_ms, expires_at_ms)`. Used by unregister + auto-renew.
pub fn get_newest_registry_entry(
&self,
registry_post_id: &PostId,
author: &NodeId,
) -> anyhow::Result<Option<(u64, u64)>> {
let result = self.conn.query_row(
"SELECT timestamp_ms, expires_at FROM comments
WHERE post_id = ?1 AND author = ?2 AND deleted_at IS NULL
ORDER BY timestamp_ms DESC LIMIT 1",
params![registry_post_id.as_slice(), author.as_slice()],
|row| {
let ts: i64 = row.get(0)?;
let exp: Option<i64> = row.get(1)?;
Ok((ts as u64, exp.unwrap_or(0) as u64))
},
);
match result {
Ok(pair) => Ok(Some(pair)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Latest `VisibilityIntent::Profile` post authored by `author` —
/// the persona's current bio post (the v1 greeting return path).
pub fn get_latest_profile_post_id_by_author(
&self,
author: &NodeId,
) -> anyhow::Result<Option<PostId>> {
let result = self.conn.query_row(
"SELECT id FROM posts
WHERE author = ?1 AND visibility_intent = '\"Profile\"'
ORDER BY timestamp_ms DESC LIMIT 1",
params![author.as_slice()],
|row| row.get::<_, Vec<u8>>(0),
);
match result {
Ok(bytes) => Ok(Some(blob_to_postid(bytes)?)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
// --- Engagement: comment policies ---
/// Store or update a comment policy for a post.
@ -7293,13 +7709,13 @@ mod tests {
s.set_circle_profile(&cp).unwrap();
// Viewer in circle sees circle profile
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &viewer).unwrap();
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[viewer]).unwrap();
assert_eq!(dn, "Alice (CF)");
assert_eq!(bio, "Circle bio");
assert_eq!(avatar, Some([99u8; 32]));
// Stranger sees public profile
let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &stranger).unwrap();
let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &[stranger]).unwrap();
assert_eq!(dn2, "Alice Public");
assert_eq!(bio2, "Public bio");
assert!(avatar2.is_none());
@ -7325,7 +7741,7 @@ mod tests {
}).unwrap();
// Stranger sees nothing
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &stranger).unwrap();
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[stranger]).unwrap();
assert!(dn.is_empty());
assert!(bio.is_empty());
assert!(avatar.is_none());
@ -7488,7 +7904,7 @@ mod tests {
let reactions = s.get_reactions(&post_id).unwrap();
assert_eq!(reactions.len(), 3);
let counts = s.get_reaction_counts(&post_id, &me).unwrap();
let counts = s.get_reaction_counts(&post_id, &[me]).unwrap();
assert_eq!(counts.len(), 2); // 👍 and ❤️
// 👍 has 2 reactions, one from me
let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap();
@ -7520,6 +7936,7 @@ mod tests {
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms: 0,
}).unwrap();
s.store_comment(&InlineComment {
@ -7533,6 +7950,7 @@ mod tests {
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms: 0,
}).unwrap();
let comments = s.get_comments(&post_id).unwrap();
@ -7561,6 +7979,7 @@ mod tests {
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms: 0,
}).unwrap();
let live = s.get_comments(&post_id).unwrap();
@ -7649,4 +8068,287 @@ mod tests {
assert!(s.get_thread_parent(&parent).unwrap().is_none());
}
// --- A1 ID-class fixes: multi-persona coverage ---
fn make_post(author: NodeId, ts: u64) -> Post {
Post {
author,
content: format!("post at {ts}"),
attachments: vec![],
timestamp_ms: ts,
fof_gating: None,
supersedes_post_id: None,
}
}
fn add_persona(s: &Storage, byte: u8) -> NodeId {
let id = make_node_id(byte);
s.upsert_posting_identity(&PostingIdentity {
node_id: id,
secret_seed: [byte; 32],
display_name: String::new(),
created_at: byte as u64,
}).unwrap();
id
}
/// Replication (bug A1-3): own posts are found by unioning over ALL
/// posting identities; a network-NodeId query finds nothing.
#[test]
fn own_recent_posts_found_across_personas_not_network_id() {
let s = temp_storage();
let network_id = make_node_id(9);
let persona1 = add_persona(&s, 1);
let persona2 = add_persona(&s, 2);
s.store_post(&make_post_id(11), &make_post(persona1, 1000)).unwrap();
s.store_post(&make_post_id(12), &make_post(persona2, 2000)).unwrap();
// The old bug: querying by the network id → always empty.
assert!(s.get_own_recent_post_ids(&network_id, 0).unwrap().is_empty());
// The fix pattern: union across all posting identities.
let mut found = Vec::new();
for pi in s.list_posting_identities().unwrap() {
found.extend(s.get_own_recent_post_ids(&pi.node_id, 0).unwrap());
}
assert_eq!(found.len(), 2, "posts from BOTH personas must be found");
}
/// Reaction "mine" flag (caller-side bug): reactors are posting ids;
/// the flag must light up for a reaction from ANY persona and never for
/// the network id.
#[test]
fn reaction_mine_flag_multi_persona() {
let s = temp_storage();
let network_id = make_node_id(9);
let persona1 = make_node_id(1);
let persona2 = make_node_id(2);
let post_id = make_post_id(21);
s.store_reaction(&Reaction {
reactor: persona2,
emoji: "👍".to_string(),
post_id,
timestamp_ms: 1000,
encrypted_payload: None,
deleted_at: None,
signature: vec![],
}).unwrap();
// Old bug shape: network id as "me" → never mine.
let counts = s.get_reaction_counts(&post_id, &[network_id]).unwrap();
assert!(!counts[0].2, "network id must never own a reaction");
// Fix: all personas → reaction by the SECOND persona is mine.
let counts = s.get_reaction_counts(&post_id, &[persona1, persona2]).unwrap();
assert!(counts[0].2, "reaction by any persona must count as mine");
// Batch variant behaves identically.
let batch = s.get_reaction_counts_batch(&[post_id], &[persona1, persona2]).unwrap();
assert!(batch.get(&post_id).unwrap()[0].2);
let batch = s.get_reaction_counts_batch(&[post_id], &[network_id]).unwrap();
assert!(!batch.get(&post_id).unwrap()[0].2);
}
/// Circle display resolution (bug A1-11): the viewer join is against
/// posting-class circle members — a second persona's membership must
/// resolve, the network id must not.
#[test]
fn resolve_display_for_second_persona_viewer() {
let s = temp_storage();
let network_id = make_node_id(9);
let persona1 = make_node_id(1);
let persona2 = make_node_id(2);
let author = make_node_id(5);
s.create_circle("friends").unwrap();
// Only our SECOND persona is a member of the author's circle mirror.
s.add_circle_member("friends", &persona2).unwrap();
s.set_circle_profile(&CircleProfile {
author,
circle_name: "friends".to_string(),
display_name: "Circle Name".to_string(),
bio: "circle bio".to_string(),
avatar_cid: None,
updated_at: 1000,
}).unwrap();
// Old bug shape: network id as viewer → no circle match.
let (dn, _, _) = s.resolve_display_for_peer(&author, &[network_id]).unwrap();
assert_eq!(dn, "", "network-id viewer must not resolve circle profiles");
// Fix: all personas as viewers → second persona matches.
let (dn, bio, _) = s.resolve_display_for_peer(&author, &[persona1, persona2]).unwrap();
assert_eq!(dn, "Circle Name");
assert_eq!(bio, "circle bio");
}
/// Circle revocation (bug A1-9): posts stored under per-persona authors
/// are found by per-persona circle-intent queries, not by the network id.
#[test]
fn circle_intent_posts_found_per_persona() {
let s = temp_storage();
let network_id = make_node_id(9);
let persona1 = add_persona(&s, 1);
let persona2 = add_persona(&s, 2);
let vis = PostVisibility::Encrypted { recipients: vec![] };
s.store_post_with_intent(
&make_post_id(31),
&make_post(persona1, 1000),
&vis,
&VisibilityIntent::Circle("crew".to_string()),
).unwrap();
s.store_post_with_intent(
&make_post_id(32),
&make_post(persona2, 2000),
&vis,
&VisibilityIntent::Circle("crew".to_string()),
).unwrap();
// Old bug shape: query by network id → nothing.
assert!(s.find_posts_by_circle_intent("crew", &network_id).unwrap().is_empty());
// Fix pattern: union across personas finds both posts.
let mut found = 0;
for pi in s.list_posting_identities().unwrap() {
found += s.find_posts_by_circle_intent("crew", &pi.node_id).unwrap().len();
}
assert_eq!(found, 2);
}
// --- v0.8 (A3): comment TTL + greeting storage ---
fn ttl_comment(author: NodeId, post_id: PostId, ts: u64, expires: u64) -> InlineComment {
InlineComment {
author,
post_id,
content: "hello".to_string(),
timestamp_ms: ts,
signature: vec![0u8; 64],
deleted_at: None,
ref_post_id: None,
pub_x_index: None,
group_sig: None,
encrypted_payload: None,
expires_at_ms: expires,
}
}
fn test_now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
/// The expires_at migration must be idempotent across re-opens of a
/// file-backed DB (pragma-guarded ALTER).
#[test]
fn expires_at_migration_idempotent() {
let dir = std::env::temp_dir().join(format!("itsgoin-a3-mig-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("test.db");
let db_str = db_path.to_str().unwrap().to_string();
{
let s = Storage::open(&db_str).unwrap();
let now = test_now_ms();
s.store_comment(&ttl_comment(make_node_id(1), make_post_id(1), now, now + 60_000)).unwrap();
}
// Re-open: migration runs again, must not fail or lose data.
{
let s = Storage::open(&db_str).unwrap();
let comments = s.get_comments(&make_post_id(1)).unwrap();
assert_eq!(comments.len(), 1);
assert!(comments[0].expires_at_ms > 0, "expiry survived re-open");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// expire_comments hard-deletes only due rows; legacy (NULL/0) rows
/// are never touched.
#[test]
fn expire_comments_deletes_only_due_rows() {
let s = temp_storage();
let post_id = make_post_id(2);
let now = test_now_ms();
s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // due
s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); // live
s.store_comment(&ttl_comment(make_node_id(3), post_id, 1002, 0)).unwrap(); // legacy no-TTL
assert_eq!(s.expire_comments(now).unwrap(), 1, "only the due row deleted");
// Hard delete: gone even from the with-tombstones view.
let all = s.get_comments_with_tombstones(&post_id).unwrap();
assert_eq!(all.len(), 2);
assert!(all.iter().all(|c| c.author != make_node_id(1)));
// Idempotent.
assert_eq!(s.expire_comments(now).unwrap(), 0);
}
/// Read filters exclude expired-but-unswept rows (belt-and-suspenders
/// between sweeps).
#[test]
fn read_filters_exclude_expired_unswept() {
let s = temp_storage();
let post_id = make_post_id(3);
let now = test_now_ms();
s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // expired, unswept
s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1);
assert_eq!(s.get_comments_with_tombstones(&post_id).unwrap().len(), 1);
assert_eq!(s.get_comment_count(&post_id).unwrap(), 1);
}
#[test]
fn open_slot_comment_count_helper() {
let s = temp_storage();
let post_id = make_post_id(4);
let now = test_now_ms();
for i in 0..3u8 {
let mut c = ttl_comment(make_node_id(10 + i), post_id, 1000 + i as u64, now + 60_000);
c.pub_x_index = Some(5);
s.store_comment(&c).unwrap();
}
// One on a different slot; one expired on slot 5.
let mut other = ttl_comment(make_node_id(20), post_id, 2000, now + 60_000);
other.pub_x_index = Some(6);
s.store_comment(&other).unwrap();
let mut expired = ttl_comment(make_node_id(21), post_id, 2001, now - 1);
expired.pub_x_index = Some(5);
s.store_comment(&expired).unwrap();
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 5).unwrap(), 3);
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 6).unwrap(), 1);
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 7).unwrap(), 0);
}
#[test]
fn greeting_dismissals_and_reply_keys() {
let s = temp_storage();
let author = make_node_id(1);
let post_id = make_post_id(5);
assert!(!s.is_greeting_dismissed(&author, &post_id, 1000).unwrap());
s.add_greeting_dismissal(&author, &post_id, 1000).unwrap();
assert!(s.is_greeting_dismissed(&author, &post_id, 1000).unwrap());
// Different timestamp = different greeting, not dismissed.
assert!(!s.is_greeting_dismissed(&author, &post_id, 1001).unwrap());
// Idempotent re-dismiss.
s.add_greeting_dismissal(&author, &post_id, 1000).unwrap();
// Reply keys roundtrip.
let privkey = [7u8; 32];
let pubkey = [8u8; 32];
s.store_greeting_reply_key(&pubkey, &privkey, &post_id).unwrap();
let keys = s.list_greeting_reply_keys().unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].0, privkey);
assert_eq!(keys[0].1, post_id);
}
}

View file

@ -68,7 +68,22 @@ pub struct Attachment {
pub size_bytes: u64,
}
/// Public profile — plaintext, synced to all peers
/// Public profile — plaintext, synced to all peers.
///
/// v0.8 keying (dual-purpose struct — the `node_id` decides which class):
/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers,
/// preferred_peers); persona fields stay empty. They travel via
/// ProfileUpdate / InitialExchange, always through
/// `sanitized_for_network_broadcast()`.
/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid,
/// public_visible); topology fields stay empty. They travel ONLY as
/// signed profile posts (`ProfilePostContent`), which have no topology
/// fields at all — so posting identities never advertise device
/// neighborhoods on the wire.
/// The topology NodeIds on network-id rows are the designed 11-needle
/// findability mechanism (device-to-device, no posting-id linkage) — an
/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity
/// ruling before reflagging in audits.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicProfile {
pub node_id: NodeId,
@ -856,8 +871,6 @@ pub struct AuthorManifest {
/// The post this manifest is anchored to
pub post_id: PostId,
pub author: NodeId,
/// Author's N+10:A (ip:port strings)
pub author_addresses: Vec<String>,
/// Original post creation time (ms)
pub created_at: u64,
/// When manifest was last updated (ms)
@ -870,20 +883,16 @@ pub struct AuthorManifest {
pub signature: Vec<u8>,
}
/// CDN manifest traveling with blobs (author-signed part + host metadata)
/// CDN manifest traveling with blobs (author-signed part + serving host).
/// v0.8: ALL device-address fields removed (host_addresses, source,
/// source_addresses, downstream_count) — posting-identity manifests must not
/// carry device addresses (location anonymity). Holder resolution goes via
/// file_holders / worm lookup / redirect peers instead.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdnManifest {
pub author_manifest: AuthorManifest,
/// Serving host's NodeId
/// Serving host's NodeId (the QUIC-authenticated device serving the blob)
pub host: NodeId,
/// Serving host's N+10:A
pub host_addresses: Vec<String>,
/// Who the host got it from
pub source: NodeId,
/// Source's N+10:A
pub source_addresses: Vec<String>,
/// How many downstream this host has
pub downstream_count: u32,
}
/// Cached routing info for a social contact
@ -978,6 +987,38 @@ pub struct InlineComment {
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted_payload: Option<Vec<u8>>,
/// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and
/// covered by the comment signature (digest v2) — no silent extension
/// is possible. Ordinary comments: created_at + rand(30..=365 days).
/// Registry entries: created_at + exactly 30 days. `0` means "no TTL"
/// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest.
#[serde(default)]
pub expires_at_ms: u64,
}
/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see
/// `crypto::seal_greeting_body`). Only the recipient (bio author for an
/// original greeting; holder of the per-greeting `reply_pubkey` private
/// half for a reply) can read this.
///
/// Messaging-first (rounds 89): `return_path` names the post whose
/// Greeting open slot the recipient replies into (v1: the sender's own
/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key —
/// replies are sealed to it, never to a long-term key. No vouch is
/// involved anywhere in this flow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreetingBody {
pub v: u32,
/// hex32 — the sender's REAL posting pubkey (inside the seal only).
pub sender_persona: String,
/// <= 64 chars.
pub sender_name: String,
/// <= 600 chars.
pub text: String,
/// hex32 post id — where the sender watches for a reply.
pub return_path: String,
/// hex32 fresh x25519 pubkey — seal replies to THIS.
pub reply_pubkey: String,
}
/// Permission level for comments on a post
@ -1076,6 +1117,34 @@ pub struct RevocationEntry {
pub author_sig: Vec<u8>,
}
/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap
/// slot whose V_x is derivable by anyone — two features aimed at
/// different parent posts (round-6 ruling).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OpenSlotKind {
/// Sealed first-contact greetings on a bio post.
Greeting,
/// Plaintext self-certifying registration entries on the registry post.
Registry,
}
/// v0.8 (A3): author-signed declaration that one of the post's wrap
/// slots is OPEN — its V_x is derivable from the post alone via
/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by
/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable
/// per publish; a bio republish (new slot_binder_nonce) is the only way
/// to change it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpenSlotDecl {
/// Index into `wrap_slots`; its pub_x is a normal member of
/// `pub_post_set`. Observable-by-design: greetings are identifiable
/// as a class, never by sender.
pub slot_index: u32,
pub kind: OpenSlotKind,
/// Padded plaintext bucket size in bytes (single bucket, design §27).
pub body_bucket: u16,
}
/// 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
@ -1097,6 +1166,10 @@ pub struct FoFCommentGating {
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
pub revocation_list: Vec<RevocationEntry>,
/// v0.8 (A3): optional open-slot declaration (greeting / registry).
/// `None` on ordinary FoF-gated posts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_slot: Option<OpenSlotDecl>,
}
/// Author-controlled engagement policy for a post
@ -1128,7 +1201,19 @@ pub enum BlobHeaderDiffOp {
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
AddComment(InlineComment),
EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
/// v0.8 (A3): self-certifying delete. `signature` is
/// `crypto::sign_comment_delete` by the comment author's posting key
/// over (author || post_id || timestamp_ms). Receivers accept when the
/// signature verifies OR when the diff sender is the parent post's
/// author (moderation override). Empty signature = legacy shape,
/// only the sender-override path can accept it.
DeleteComment {
author: NodeId,
post_id: PostId,
timestamp_ms: u64,
#[serde(default)]
signature: Vec<u8>,
},
SetPolicy(CommentPolicy),
ThreadSplit { new_post_id: PostId },
/// Write an encrypted receipt slot (64 bytes encrypted data)