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:
parent
66c9851061
commit
17dbf076cb
19 changed files with 5237 additions and 524 deletions
|
|
@ -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(×tamp_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(×tamp_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![],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue