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

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![],