From 69d8da561bafb17bcf10732a51352b09a16c3019 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 15:12:32 +0000 Subject: [PATCH 01/44] feat(crypto): add multi-recipient envelope + agreement primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the CC/agreements spec (v0.4): the load-bearing backend crypto and armor-grammar foundation, fully unit-tested. crypto.rs — hybrid envelope primitives (spec §10.1): * aes_gcm_encrypt_raw — AES-256-GCM encrypt under a raw CEK (mirror of the existing aes_gcm_decrypt_raw) * generate_cek — random 256-bit Content Encryption Key * wrap_cek / unwrap_cek — NIP-44 wrap/unwrap of the CEK per recipient agreement.rs — RECIPIENTS & CONSENT armor blocks and agreement workflow: * Recipient / Consent types with parse + serialize (spec §10.2, §11.3) * canonicalize_block — quote-prefix/whitespace-invariant canonical form for signing (spec §4.2) * document_hash — H over body+recipients, excluding consent (spec §11.3.1) * level_signing_bytes — per-level signing contribution body||recipients|| consent (spec §4.2) * compute_completion — "M of N signatories signed" accounting (spec §11.5), npub/hex-normalized, deduped, viewer/self excluded Pure functions over extracted block text; wiring into the recursive armor parser/encoder and the To/Cc role mapping follows in later phases. 33 unit tests (18 agreement + 15 crypto) pass. --- tauri-app/backend/src/agreement.rs | 504 +++++++++++++++++++++++++++++ tauri-app/backend/src/crypto.rs | 148 +++++++++ tauri-app/backend/src/lib.rs | 1 + 3 files changed, 653 insertions(+) create mode 100644 tauri-app/backend/src/agreement.rs diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs new file mode 100644 index 0000000..e098cea --- /dev/null +++ b/tauri-app/backend/src/agreement.rs @@ -0,0 +1,504 @@ +//! Multi-recipient (group) encryption and agreement-workflow primitives. +//! +//! This module implements the wire-level pieces of nostr-mail spec v0.4 that +//! support DocuSign-style agreements (spec Sections 10 & 11): +//! +//! * the `RECIPIENTS` block — per-recipient NIP-44-wrapped CEK + role +//! (Section 10.2), with deterministic canonicalization (Section 4.2); +//! * the `CONSENT` block — a signatory's explicit, intentional consent to a +//! specific document hash `H` (Section 11.3); +//! * the document hash `H` over the originating level (Section 11.3.1); +//! * the per-level signing contribution `level(L)` that folds in the +//! recipients and consent blocks so they are tamper-evident (Section 4.2); +//! * agreement completion accounting — "M of N signatories signed" +//! (Section 11.5). +//! +//! These are pure functions over already-extracted block text; the actual +//! envelope encryption/decryption uses the primitives in [`crate::crypto`] +//! (`generate_cek`, `aes_gcm_encrypt_raw`, `wrap_cek`, `unwrap_cek`). Wiring +//! these into the recursive armor parser/encoder lives in `email.rs`. + +use sha2::{Digest, Sha256}; + +/// Workflow role tokens used in a RECIPIENTS stanza (spec Section 10.2 / 11.1). +pub const ROLE_SIGNER: &str = "signer"; +pub const ROLE_VIEWER: &str = "viewer"; +pub const ROLE_SELF: &str = "self"; + +/// A single entry in a `RECIPIENTS` block: ` ` +/// (spec Section 10.2). `pubkey` and `wrapped_cek` are retained exactly as they +/// appear on the wire (hex/npub for the key, base64 NIP-44 payload for the CEK). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recipient { + /// `signer` | `viewer` | `self`, or an unknown future token (lowercased). + pub role: String, + /// Recipient's Nostr public key, hex (64 chars) or npub (bech32). + pub pubkey: String, + /// `NIP44_encrypt(CEK)` to `pubkey`, base64. + pub wrapped_cek: String, +} + +impl Recipient { + /// Serialize this entry as its canonical single line: `role pubkey wrapped-cek`. + pub fn to_line(&self) -> String { + format!("{} {} {}", self.role, self.pubkey, self.wrapped_cek) + } + + /// True for the `signer` role (a required signatory; spec Section 11.1). + pub fn is_signer(&self) -> bool { + self.role == ROLE_SIGNER + } +} + +/// A `CONSENT` block: a signatory's binding consent to document `H` +/// (spec Section 11.3). The block carries no signature of its own — the level's +/// existing SIGNATURE binds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Consent { + /// The document hash `H` being consented to, hex (64 chars). + pub agreement_hash: String, + /// The consenting party's pubkey, hex or npub. MUST equal this level's + /// SIGNATURE pubkey (spec Section 11.3). + pub signer: String, +} + +impl Consent { + /// Serialize as the two canonical lines of a CONSENT block body. + pub fn to_block_body(&self) -> String { + format!("agreement {}\nsigner {}", self.agreement_hash, self.signer) + } +} + +/// Strip leading email-quote prefixes (`>` optionally followed by a space, +/// possibly repeated for nested quoting) from a line. Mirrors how glossia +/// decoders ignore quote prefixes as non-payload (spec Sections 3.5.4, 10.2). +fn strip_quote_prefix(line: &str) -> &str { + let mut s = line; + loop { + if let Some(rest) = s.strip_prefix("> ") { + s = rest; + } else if let Some(rest) = s.strip_prefix('>') { + s = rest; + } else { + return s; + } + } +} + +/// Canonicalize a block body for signing (spec Section 4.2): for each line, +/// strip any `> ` quote prefix and trailing whitespace; drop the (now-empty) +/// blank lines that Sections 10.2 / 11.3 require decoders to ignore; join the +/// surviving lines with `\n` and emit no trailing newline. +/// +/// Used for both `canonical(recipients_L)` and `canonical(consent_L)`. An +/// absent block canonicalizes to the empty string (handled by the caller). +pub fn canonicalize_block(block_body: &str) -> String { + block_body + .split('\n') + .map(|line| strip_quote_prefix(line).trim_end()) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} + +/// Parse a `RECIPIENTS` block body (the lines between `BEGIN NOSTR RECIPIENTS` +/// and the following delimiter) into entries (spec Section 10.2). +/// +/// Each non-blank line must have at least three space-separated tokens +/// ` `; additional trailing tokens are tolerated +/// (forward compatibility) and ignored. Blank lines and `> ` quote prefixes are +/// ignored. Malformed lines (fewer than 3 tokens) are skipped. +pub fn parse_recipients_block(block_body: &str) -> Vec { + let mut out = Vec::new(); + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut toks = line.split_whitespace(); + let role = match toks.next() { + Some(t) => t.to_ascii_lowercase(), + None => continue, + }; + let pubkey = match toks.next() { + Some(t) => t.to_string(), + None => continue, + }; + let wrapped_cek = match toks.next() { + Some(t) => t.to_string(), + None => continue, + }; + out.push(Recipient { role, pubkey, wrapped_cek }); + } + out +} + +/// Serialize recipients into a canonical RECIPIENTS block body (no BEGIN/END +/// delimiters). Entries are emitted in the order given; the caller is +/// responsible for the deterministic ordering of spec Section 10.2 (To, then +/// Cc, then `self` last). +pub fn serialize_recipients(recipients: &[Recipient]) -> String { + recipients + .iter() + .map(Recipient::to_line) + .collect::>() + .join("\n") +} + +/// Parse a `CONSENT` block body into a [`Consent`] (spec Section 11.3). +/// +/// Recognizes the `agreement ` and `signer ` lines; unknown lines +/// are tolerated and ignored (forward compatibility), as are blank lines and +/// `> ` quote prefixes. Returns `None` if either required field is absent. +pub fn parse_consent_block(block_body: &str) -> Option { + let mut agreement_hash: Option = None; + let mut signer: Option = None; + for raw in block_body.split('\n') { + let line = strip_quote_prefix(raw).trim(); + if line.is_empty() { + continue; + } + let mut toks = line.split_whitespace(); + match toks.next() { + Some("agreement") => { + if let Some(v) = toks.next() { + agreement_hash = Some(v.to_string()); + } + } + Some("signer") => { + if let Some(v) = toks.next() { + signer = Some(v.to_string()); + } + } + _ => {} // unknown line — ignore + } + } + Some(Consent { + agreement_hash: agreement_hash?, + signer: signer?, + }) +} + +/// Normalize a pubkey (hex or npub) to lowercase 64-char hex for comparison. +/// Returns `None` if the input is neither a valid npub nor 32-byte hex. +pub fn normalize_pubkey_hex(pubkey: &str) -> Option { + use nostr_sdk::{FromBech32, PublicKey}; + let p = pubkey.trim(); + if let Ok(pk) = PublicKey::from_bech32(p) { + return Some(pk.to_hex()); + } + if let Ok(pk) = PublicKey::from_hex(p) { + return Some(pk.to_hex()); + } + None +} + +/// Compute the document hash `H` for an agreement (spec Section 11.3.1): +/// +/// ```text +/// H = SHA-256( decode(body_1) || canonical(recipients_1) ) +/// ``` +/// +/// `body_1_decoded` is the originating level's decoded body bytes (glossia- or +/// base64-decoded), and `canonical_recipients_1` is the canonicalized +/// originating RECIPIENTS block (see [`canonicalize_block`]). CONSENT blocks are +/// deliberately excluded so that `H` is fixed for the life of the agreement. +/// Returns the 32-byte hash; use [`hex::encode`] for the on-wire form. +pub fn document_hash(body_1_decoded: &[u8], canonical_recipients_1: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(body_1_decoded); + hasher.update(canonical_recipients_1.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +/// Compute one level's signing contribution `level(L)` (spec Section 4.2): +/// +/// ```text +/// level(L) = decode(body_L) || canonical(recipients_L) || canonical(consent_L) +/// ``` +/// +/// concatenated in the fixed order body → recipients → consent. Pass an empty +/// string for `canonical_recipients_l` / `canonical_consent_l` when the level +/// has no such block; a level with neither reduces to the plain Section 4 body +/// model. The full signing target for a level and its nested levels is +/// `SHA-256( level(L) || level(L-1) || … || level(1) )`. +pub fn level_signing_bytes( + body_l_decoded: &[u8], + canonical_recipients_l: &str, + canonical_consent_l: &str, +) -> Vec { + let mut bytes = + Vec::with_capacity(body_l_decoded.len() + canonical_recipients_l.len() + canonical_consent_l.len()); + bytes.extend_from_slice(body_l_decoded); + bytes.extend_from_slice(canonical_recipients_l.as_bytes()); + bytes.extend_from_slice(canonical_consent_l.as_bytes()); + bytes +} + +/// Agreement completion summary (spec Section 11.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgreementStatus { + /// Number of required signatories with a verified consent over `H`. + pub m: usize, + /// Total number of required signatories `N`. + pub n: usize, + /// `true` iff every required signatory has consented (`m == n` and `n > 0`). + pub complete: bool, + /// The required signatory set, normalized to hex, deduplicated. + pub required_signers: Vec, + /// The subset of required signatories that have consented, normalized to hex. + pub consented_signers: Vec, +} + +/// Compute "M of N signed" completion (spec Section 11.5). +/// +/// `required` is the required signatory set (the `signer` stanzas of the +/// originating RECIPIENTS block, plus the originator if they themselves +/// consented). `consented` is the set of pubkeys that carry a CONSENT block +/// over the agreement's `H` **bound by a verified signature** — the caller is +/// responsible for only passing pubkeys whose level signature verified. +/// +/// Both sets are normalized to hex and deduplicated. A consenting pubkey that +/// is not in the required set (e.g. a `viewer` who consents anyway) does not +/// change `N` and is excluded from `M` (Section 11.5); the caller MAY surface +/// it informationally. +pub fn compute_completion(required: &[String], consented: &[String]) -> AgreementStatus { + let required_signers = dedup_normalized(required); + let consented_norm: Vec = dedup_normalized(consented); + + let consented_signers: Vec = required_signers + .iter() + .filter(|pk| consented_norm.contains(pk)) + .cloned() + .collect(); + + let n = required_signers.len(); + let m = consented_signers.len(); + AgreementStatus { + m, + n, + complete: n > 0 && m == n, + required_signers, + consented_signers, + } +} + +/// Normalize each pubkey to hex (dropping any that fail to parse) and dedup, +/// preserving first-seen order. +fn dedup_normalized(pubkeys: &[String]) -> Vec { + let mut seen = Vec::new(); + for pk in pubkeys { + if let Some(hex) = normalize_pubkey_hex(pk) { + if !seen.contains(&hex) { + seen.push(hex); + } + } + } + seen +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX_A: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const HEX_B: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + // A valid pubkey hex (generator point x-coord) and its npub, for normalization tests. + const PK_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn test_strip_quote_prefix() { + assert_eq!(strip_quote_prefix("> hello"), "hello"); + assert_eq!(strip_quote_prefix(">> hello"), "hello"); + assert_eq!(strip_quote_prefix("> > hello"), "hello"); + assert_eq!(strip_quote_prefix(">hello"), "hello"); + assert_eq!(strip_quote_prefix("hello"), "hello"); + } + + #[test] + fn test_parse_recipients_basic() { + let block = format!( + "signer {} wrapcekA\nviewer {} wrapcekB\nself {} wrapcekS", + HEX_A, HEX_B, PK_HEX + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 3); + assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "wrapcekA".into() }); + assert_eq!(recips[1].role, "viewer"); + assert_eq!(recips[2].role, "self"); + } + + #[test] + fn test_parse_recipients_tolerates_extra_tokens_blanks_and_quotes() { + let block = format!( + "> signer {} wrapcekA extratoken\n\n>> viewer {} wrapcekB\n", + HEX_A, HEX_B + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 2); + // Extra trailing token is ignored, not folded into wrapped_cek. + assert_eq!(recips[0].wrapped_cek, "wrapcekA"); + assert_eq!(recips[1].role, "viewer"); + } + + #[test] + fn test_parse_recipients_skips_malformed_lines() { + let block = format!("signer {}\njustonetoken\nsigner {} wrapcekB", HEX_A, HEX_B); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 1); + assert_eq!(recips[0].pubkey, HEX_B); + } + + #[test] + fn test_serialize_recipients_roundtrip() { + let recips = vec![ + Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "cekA".into() }, + Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: "cekS".into() }, + ]; + let body = serialize_recipients(&recips); + assert_eq!(body, format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B)); + assert_eq!(parse_recipients_block(&body), recips); + } + + #[test] + fn test_canonicalize_block_strips_and_drops_blanks() { + let raw = "> signer abc cek \n\n>> viewer def cek2\n \n"; + let canon = canonicalize_block(raw); + assert_eq!(canon, "signer abc cek\nviewer def cek2"); + // No trailing newline. + assert!(!canon.ends_with('\n')); + } + + #[test] + fn test_canonicalize_is_quote_prefix_invariant() { + // The same logical block with and without email quoting canonicalizes identically. + let plain = format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B); + let quoted = format!("> signer {} cekA\n> self {} cekS", HEX_A, HEX_B); + assert_eq!(canonicalize_block(&plain), canonicalize_block("ed)); + } + + #[test] + fn test_parse_consent_basic() { + let block = format!("agreement {}\nsigner {}", HEX_A, PK_HEX); + let consent = parse_consent_block(&block).unwrap(); + assert_eq!(consent.agreement_hash, HEX_A); + assert_eq!(consent.signer, PK_HEX); + } + + #[test] + fn test_parse_consent_tolerates_unknown_lines_and_quotes() { + let block = format!("> agreement {}\n> future-field xyz\n> signer {}", HEX_A, PK_HEX); + let consent = parse_consent_block(&block).unwrap(); + assert_eq!(consent.agreement_hash, HEX_A); + assert_eq!(consent.signer, PK_HEX); + } + + #[test] + fn test_parse_consent_missing_field_is_none() { + assert!(parse_consent_block(&format!("agreement {}", HEX_A)).is_none()); + assert!(parse_consent_block(&format!("signer {}", PK_HEX)).is_none()); + } + + #[test] + fn test_consent_roundtrip() { + let consent = Consent { agreement_hash: HEX_A.into(), signer: PK_HEX.into() }; + let parsed = parse_consent_block(&consent.to_block_body()).unwrap(); + assert_eq!(parsed, consent); + } + + #[test] + fn test_document_hash_excludes_consent_and_is_stable() { + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let recips = format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B); + let h1 = document_hash(body, &canonicalize_block(&recips)); + // Recomputing with the (quote-prefixed) same logical recipients yields identical H. + let recips_quoted = format!("> signer {} cekA\n> self {} cekS", HEX_A, HEX_B); + let h2 = document_hash(body, &canonicalize_block(&recips_quoted)); + assert_eq!(h1, h2); + // Changing the body changes H. + let h3 = document_hash(b"different terms", &canonicalize_block(&recips)); + assert_ne!(h1, h3); + // Changing the recipient/role set changes H. + let recips_tampered = format!("signer {} cekA\nsigner {} cekS", HEX_A, HEX_B); + let h4 = document_hash(body, &canonicalize_block(&recips_tampered)); + assert_ne!(h1, h4); + } + + #[test] + fn test_level_signing_bytes_order_and_emptiness() { + let body = b"body"; + let recips = "signer abc cek"; + let consent = "agreement H\nsigner abc"; + let full = level_signing_bytes(body, recips, consent); + let mut expected = Vec::new(); + expected.extend_from_slice(body); + expected.extend_from_slice(recips.as_bytes()); + expected.extend_from_slice(consent.as_bytes()); + assert_eq!(full, expected); + + // No recipients / consent → reduces to body only (Section 4 model). + assert_eq!(level_signing_bytes(body, "", ""), body.to_vec()); + } + + #[test] + fn test_normalize_pubkey_hex_accepts_hex_and_npub() { + use nostr_sdk::{PublicKey, ToBech32}; + let npub = PublicKey::from_hex(PK_HEX).unwrap().to_bech32().unwrap(); + assert_eq!(normalize_pubkey_hex(PK_HEX).unwrap(), PK_HEX); + assert_eq!(normalize_pubkey_hex(&npub).unwrap(), PK_HEX); + assert_eq!(normalize_pubkey_hex(&format!(" {} ", PK_HEX)).unwrap(), PK_HEX); + assert!(normalize_pubkey_hex("not-a-key").is_none()); + } + + #[test] + fn test_compute_completion_partial_and_full() { + let alice = PK_HEX.to_string(); + let bob = nostr_sdk::Keys::generate().public_key().to_hex(); + let required = vec![alice.clone(), bob.clone()]; + // Only Alice has consented → 1 of 2, not complete. + let status = compute_completion(&required, &[alice.clone()]); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + + // Both consented → 2 of 2, complete. + let status = compute_completion(&required, &[alice.clone(), bob.clone()]); + assert_eq!((status.m, status.n), (2, 2)); + assert!(status.complete); + } + + #[test] + fn test_compute_completion_dedups_and_ignores_non_required() { + let alice = PK_HEX.to_string(); + let bob = nostr_sdk::Keys::generate().public_key().to_hex(); + let viewer = nostr_sdk::Keys::generate().public_key().to_hex(); + let required = vec![alice.clone(), bob.clone()]; + + // Alice consents twice (dedup → counts once); a non-required viewer consents (ignored). + let consented = vec![alice.clone(), alice.clone(), viewer.clone()]; + let status = compute_completion(&required, &consented); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + assert_eq!(status.consented_signers, vec![alice]); + } + + #[test] + fn test_compute_completion_normalizes_npub_vs_hex() { + use nostr_sdk::{PublicKey, ToBech32}; + let alice_npub = PublicKey::from_hex(PK_HEX).unwrap().to_bech32().unwrap(); + // Required lists hex, consent lists npub for the same identity → must match. + let status = compute_completion(&[PK_HEX.to_string()], &[alice_npub]); + assert_eq!((status.m, status.n), (1, 1)); + assert!(status.complete); + } + + #[test] + fn test_compute_completion_empty_required_not_complete() { + let status = compute_completion(&[], &[]); + assert_eq!((status.m, status.n), (0, 0)); + assert!(!status.complete); + } +} diff --git a/tauri-app/backend/src/crypto.rs b/tauri-app/backend/src/crypto.rs index 00f51d7..30fef60 100644 --- a/tauri-app/backend/src/crypto.rs +++ b/tauri-app/backend/src/crypto.rs @@ -391,6 +391,68 @@ pub fn aes_gcm_decrypt_raw(key_bytes: &[u8], encrypted_data: &[u8]) -> Result Result> { + if key_bytes.len() != 32 { + return Err(anyhow::anyhow!("AES key must be 32 bytes, got {}", key_bytes.len())); + } + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher.encrypt(&nonce, plaintext) + .map_err(|e| anyhow::anyhow!("AES-GCM encryption failed: {}", e))?; + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(combined) +} + +/// Generate a random 256-bit Content Encryption Key (CEK) for the +/// multi-recipient hybrid envelope scheme (spec Section 10.1). +pub fn generate_cek() -> [u8; 32] { + use ::secp256k1::rand::RngCore; + let mut cek = [0u8; 32]; + ::secp256k1::rand::rngs::OsRng.fill_bytes(&mut cek); + cek +} + +/// Wrap a CEK to a single recipient using NIP-44 (spec Section 10.1, step 3). +/// +/// The 32-byte CEK is hex-encoded and NIP-44 encrypted with the shared secret of +/// `(sender_priv, recipient_pub)`. The returned string is the NIP-44 payload +/// (base64), suitable for placement as the `wrapped-cek` token in a RECIPIENTS +/// stanza (spec Section 10.2). Accepts bech32 or hex for both keys. +pub fn wrap_cek(sender_priv: &str, recipient_pub: &str, cek: &[u8; 32]) -> Result { + let secret_key = SecretKey::from_bech32(sender_priv) + .or_else(|_| SecretKey::from_hex(sender_priv))?; + let public_key = PublicKey::from_bech32(recipient_pub) + .or_else(|_| PublicKey::from_hex(recipient_pub))?; + let cek_hex = hex::encode(cek); + let wrapped = nip44::encrypt(&secret_key, &public_key, &cek_hex, nip44::Version::default())?; + Ok(wrapped) +} + +/// Unwrap a CEK that was wrapped to the reader with NIP-44 (spec Section 8, step 8). +/// +/// `reader_priv` is the reader's private key, `sender_pub` is the sender's pubkey +/// (the same key used as the counterparty when wrapping). Returns the 32-byte CEK. +/// Accepts bech32 or hex for both keys. +pub fn unwrap_cek(reader_priv: &str, sender_pub: &str, wrapped_cek: &str) -> Result<[u8; 32]> { + let secret_key = SecretKey::from_bech32(reader_priv) + .or_else(|_| SecretKey::from_hex(reader_priv))?; + let public_key = PublicKey::from_bech32(sender_pub) + .or_else(|_| PublicKey::from_hex(sender_pub))?; + let cek_hex = nip44::decrypt(&secret_key, &public_key, wrapped_cek)?; + let cek_bytes = hex::decode(cek_hex.trim()) + .map_err(|e| anyhow::anyhow!("Unwrapped CEK is not valid hex: {}", e))?; + if cek_bytes.len() != 32 { + return Err(anyhow::anyhow!("Unwrapped CEK must be 32 bytes, got {}", cek_bytes.len())); + } + let mut cek = [0u8; 32]; + cek.copy_from_slice(&cek_bytes); + Ok(cek) +} + /// AES-256-GCM decrypt + remove padding. /// After decryption, first 4 bytes are original size (little-endian u32), /// followed by the original data padded to 64 KiB boundaries. @@ -518,4 +580,90 @@ mod tests { let decrypted = aes_gcm_decrypt_padded(&key_bytes, &combined).unwrap(); assert_eq!(decrypted, original_data); } + + #[test] + fn test_aes_gcm_encrypt_decrypt_raw_roundtrip() { + let key_bytes = generate_cek(); + let plaintext = b"multi-recipient body under a CEK"; + let combined = aes_gcm_encrypt_raw(&key_bytes, plaintext).unwrap(); + // nonce (12) + ciphertext + 16-byte GCM tag + assert!(combined.len() >= 12 + plaintext.len() + 16); + let decrypted = aes_gcm_decrypt_raw(&key_bytes, &combined).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_aes_gcm_encrypt_raw_bad_key_length() { + assert!(aes_gcm_encrypt_raw(&[0u8; 16], b"x").is_err()); + } + + #[test] + fn test_generate_cek_is_random_and_32_bytes() { + let a = generate_cek(); + let b = generate_cek(); + assert_eq!(a.len(), 32); + assert_ne!(a, b, "two CEKs should not collide"); + } + + #[test] + fn test_wrap_unwrap_cek_roundtrip() { + // Sender wraps the CEK to a recipient; recipient unwraps with their key + sender pubkey. + let sender = generate_keypair().unwrap(); + let recipient = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &recipient.public_key, &cek).unwrap(); + let unwrapped = unwrap_cek(&recipient.private_key, &sender_pub, &wrapped).unwrap(); + assert_eq!(unwrapped, cek); + } + + #[test] + fn test_self_wrap_unwrap_cek_roundtrip() { + // The `self` stanza: sender wraps to their own pubkey so the Sent copy decrypts. + let sender = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &sender.public_key, &cek).unwrap(); + let unwrapped = unwrap_cek(&sender.private_key, &sender_pub, &wrapped).unwrap(); + assert_eq!(unwrapped, cek); + } + + #[test] + fn test_unwrap_cek_wrong_recipient_fails() { + let sender = generate_keypair().unwrap(); + let recipient = generate_keypair().unwrap(); + let intruder = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let wrapped = wrap_cek(&sender.private_key, &recipient.public_key, &cek).unwrap(); + // An unintended party cannot unwrap the CEK. + assert!(unwrap_cek(&intruder.private_key, &sender_pub, &wrapped).is_err()); + } + + #[test] + fn test_full_hybrid_envelope_roundtrip() { + // End-to-end: encrypt body once under CEK, wrap CEK to two recipients + self, + // each recipient independently recovers the body (spec Section 10.1). + let sender = generate_keypair().unwrap(); + let alice = generate_keypair().unwrap(); + let bob = generate_keypair().unwrap(); + let sender_pub = get_public_key_from_private(&sender.private_key).unwrap(); + + let cek = generate_cek(); + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let ciphertext = aes_gcm_encrypt_raw(&cek, body).unwrap(); + + let wrapped_alice = wrap_cek(&sender.private_key, &alice.public_key, &cek).unwrap(); + let wrapped_bob = wrap_cek(&sender.private_key, &bob.public_key, &cek).unwrap(); + + // Alice recovers the body. + let cek_a = unwrap_cek(&alice.private_key, &sender_pub, &wrapped_alice).unwrap(); + assert_eq!(aes_gcm_decrypt_raw(&cek_a, &ciphertext).unwrap(), body); + // Bob recovers the body. + let cek_b = unwrap_cek(&bob.private_key, &sender_pub, &wrapped_bob).unwrap(); + assert_eq!(aes_gcm_decrypt_raw(&cek_b, &ciphertext).unwrap(), body); + } } \ No newline at end of file diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index 494ef1c..e7f8077 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -5,6 +5,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] pub mod crypto; +pub mod agreement; pub mod email; pub mod imap_pool; pub mod imap_idle; From 1cc04b3fe3be58e04a997350b5347d709aa4407d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 15:29:31 +0000 Subject: [PATCH 02/44] =?UTF-8?q?feat(email):=20parse=20HYBRID/RECIPIENTS/?= =?UTF-8?q?CONSENT=20and=20sign=20per=20spec=20=C2=A74.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a of the CC/agreements spec: teach the raw-text armor layer about multi-recipient and agreement blocks, and fold them into the signing target. * split_armor_level — shared single-level splitter that separates a level's body from its RECIPIENTS (§10.2) and CONSENT (§11.3) blocks and its nested (quoted) armor, honoring the §11.3.2 order body→recipients→consent→nested →signature. parse_armor_depth now delegates to it. * level_* marker helpers recognize BEGIN/END NOSTR HYBRID/RECIPIENTS/CONSENT; RECIPIENTS/CONSENT standalone terminators are excluded from nesting depth. * extract_ciphertext_binary now signs over the §4.2 per-level target decode(body) || canonical(recipients) || canonical(consent), concatenated outermost→innermost. With no such blocks it reduces to decode(body) and is byte-identical to the legacy behavior, so existing messages are unaffected. * populate_armor_from_text recognizes HYBRID bodies and pulls RECIPIENTS/ CONSENT out of the capnp body_text (kept clean; serde/capnp exposure of the block contents lands in the next phase). Adds 5 tests: block separation, §4.2 signing-target inclusion, and full verify-then-tamper coverage for a multi-recipient message and a consent message (re-labeling a signatory or altering the document hash breaks the signature). Full lib suite: 228 passed. --- tauri-app/backend/src/email.rs | 425 ++++++++++++++++++++++++++++----- 1 file changed, 366 insertions(+), 59 deletions(-) diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index f037432..efd4841 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -1753,72 +1753,158 @@ pub fn decode_armor_section(content: &str) -> Option> { None } -/// Parse armor structure with depth counting, separating outermost body from nested armor. -/// Returns (body_text, nested_armor) where nested_armor has one level of "> " prefix stripped. -/// Handles both non-quoted nested armor (reply chains) and > quoted nested armor. -fn parse_armor_depth(body: &str) -> Option<(String, Option)> { +/// The constituent parts of a single armor level, separated from the body's +/// armor region. Per spec Section 11.3.2 the on-wire order within a level is +/// body → RECIPIENTS → CONSENT → nested(inner) → SIGNATURE stack. +struct ArmorLevelParts { + /// The level's body content (ciphertext / encoded plaintext), with the + /// RECIPIENTS and CONSENT blocks removed. + body_text: String, + /// Raw RECIPIENTS block body (the lines between its BEGIN and the following + /// delimiter), if present (spec Section 10.2). + recipients_text: Option, + /// Raw CONSENT block body, if present (spec Section 11.3). + consent_text: Option, + /// The nested (quoted) inner armor, with one level of `> ` prefix stripped, + /// ready for recursive parsing. `None` for a leaf level. + nested_armor: Option, +} + +fn level_is_begin_body(l: &str) -> bool { + let t = l.trim().trim_matches('-').trim(); + t.starts_with("BEGIN NOSTR NIP-") + || t.starts_with("BEGIN NOSTR SIGNED") + || t.starts_with("BEGIN NOSTR HYBRID") +} +fn level_is_begin_recipients(l: &str) -> bool { + l.trim().trim_matches('-').trim() == "BEGIN NOSTR RECIPIENTS" +} +fn level_is_begin_consent(l: &str) -> bool { + l.trim().trim_matches('-').trim() == "BEGIN NOSTR CONSENT" +} +fn level_is_begin_sig_or_seal(l: &str) -> bool { + let t = l.trim().trim_matches('-').trim(); + t == "BEGIN NOSTR SIGNATURE" || t == "BEGIN NOSTR SEAL" +} +fn level_is_end_recipients(l: &str) -> bool { + l.trim().trim_matches('-').trim() == "END NOSTR RECIPIENTS" +} +fn level_is_end_consent(l: &str) -> bool { + l.trim().trim_matches('-').trim() == "END NOSTR CONSENT" +} +/// A message-closing END (`END NOSTR MESSAGE`, legacy `END NOSTR NIP-XX …`, or +/// `END NOSTR SEAL`) — but NOT the standalone RECIPIENTS/CONSENT terminators, +/// which must not affect nesting depth. +fn level_is_end_message(l: &str) -> bool { + let t = l.trim().trim_matches('-').trim(); + t.starts_with("END NOSTR") && t != "END NOSTR RECIPIENTS" && t != "END NOSTR CONSENT" +} + +/// Split one armor level into its body, RECIPIENTS, CONSENT, and nested-armor +/// parts (spec Sections 10–11). This is the shared primitive behind +/// [`parse_armor_depth`] and [`extract_ciphertext_binary`]; both must agree on +/// what counts as "the body" so that signing and verification stay consistent. +/// +/// Existing single-recipient / plaintext messages carry no RECIPIENTS or CONSENT +/// blocks, so `recipients_text`/`consent_text` are `None` and `body_text` is +/// byte-identical to the legacy behavior. +fn split_armor_level(body: &str) -> Option { let body = body.replace("\r\n", "\n"); let lines: Vec<&str> = body.lines().collect(); - let contains_begin_body = |l: &str| { - l.contains("BEGIN NOSTR NIP-") || l.contains("BEGIN NOSTR SIGNED") - }; - let contains_sig_seal = |l: &str| { - l.contains("BEGIN NOSTR SIGNATURE") || l.contains("BEGIN NOSTR SEAL") - }; - let contains_end = |l: &str| l.contains("END NOSTR"); - + #[derive(PartialEq)] + enum S { Before, Body, Recipients, Consent, Nested } + let mut state = S::Before; let mut depth: i32 = 0; - let mut in_body = false; - let mut in_nested = false; let mut body_lines: Vec<&str> = Vec::new(); + let mut recipients_lines: Vec<&str> = Vec::new(); + let mut consent_lines: Vec<&str> = Vec::new(); let mut nested_lines: Vec<&str> = Vec::new(); for line in &lines { - if !in_body && !in_nested { - if contains_begin_body(line) { - depth = 1; - in_body = true; + match state { + S::Before => { + if level_is_begin_body(line) { + depth = 1; + state = S::Body; + } } - continue; - } - - if in_body { - if contains_begin_body(line) { - depth += 1; - in_nested = true; - in_body = false; - nested_lines.push(line); - continue; + S::Body => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_recipients(line) { + state = S::Recipients; + } else if level_is_begin_consent(line) { + state = S::Consent; + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else { + body_lines.push(line); + } } - if depth == 1 && (contains_sig_seal(line) || contains_end(line)) { - break; + S::Recipients => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_consent(line) { + state = S::Consent; + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else if level_is_end_recipients(line) { + state = S::Body; + } else { + recipients_lines.push(line); + } } - body_lines.push(line); - continue; - } - - if in_nested { - nested_lines.push(line); - if contains_begin_body(line) { - depth += 1; + S::Consent => { + if level_is_begin_body(line) { + depth = 2; + state = S::Nested; + nested_lines.push(line); + } else if level_is_begin_sig_or_seal(line) || level_is_end_message(line) { + break; + } else if level_is_end_consent(line) { + state = S::Body; + } else { + consent_lines.push(line); + } } - if contains_end(line) { - depth -= 1; - if depth == 1 { - in_nested = false; - in_body = true; + S::Nested => { + nested_lines.push(line); + if level_is_begin_body(line) { + depth += 1; + } else if level_is_end_message(line) { + depth -= 1; + if depth == 1 { + state = S::Body; + } } } } } + if state == S::Before { + return None; + } + let body_text = body_lines.join("\n").trim().to_string(); if body_text.is_empty() { return None; } - let nested = if nested_lines.is_empty() { + let join_opt = |v: &[&str]| -> Option { + if v.is_empty() { + None + } else { + let s = v.join("\n").trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } + }; + + let nested_armor = if nested_lines.is_empty() { None } else { let stripped: Vec<&str> = nested_lines.iter().map(|l| { @@ -1826,10 +1912,25 @@ fn parse_armor_depth(body: &str) -> Option<(String, Option)> { else if *l == ">" { "" } else { *l } }).collect(); - Some(stripped.join("\n").trim().to_string()) + let s = stripped.join("\n").trim().to_string(); + if s.is_empty() { None } else { Some(s) } }; - Some((body_text, nested)) + Some(ArmorLevelParts { + body_text, + recipients_text: join_opt(&recipients_lines), + consent_text: join_opt(&consent_lines), + nested_armor, + }) +} + +/// Parse armor structure with depth counting, separating outermost body from nested armor. +/// Returns (body_text, nested_armor) where nested_armor has one level of "> " prefix stripped. +/// Handles both non-quoted nested armor (reply chains) and > quoted nested armor. +/// RECIPIENTS / CONSENT blocks (spec Sections 10–11) are excluded from `body_text`. +fn parse_armor_depth(body: &str) -> Option<(String, Option)> { + let parts = split_armor_level(body)?; + Some((parts.body_text, parts.nested_armor)) } /// Decode a combined 96-byte signature+pubkey block. @@ -2120,10 +2221,9 @@ fn populate_armor_from_text( // ── Phase A: Line extraction (state machine) ── let lines: Vec<&str> = normalized[armor_start..].lines().collect(); - let is_begin_body = |l: &str| -> bool { - let t = l.trim().trim_matches('-').trim(); - t.starts_with("BEGIN NOSTR NIP-") || t.starts_with("BEGIN NOSTR SIGNED") - }; + let is_begin_body = |l: &str| -> bool { level_is_begin_body(l) }; + let is_begin_recipients = |l: &str| -> bool { level_is_begin_recipients(l) }; + let is_begin_consent = |l: &str| -> bool { level_is_begin_consent(l) }; let is_begin_sig = |l: &str| -> bool { let t = l.trim().trim_matches('-').trim(); t == "BEGIN NOSTR SIGNATURE" @@ -2132,10 +2232,9 @@ fn populate_armor_from_text( let t = l.trim().trim_matches('-').trim(); t == "BEGIN NOSTR SEAL" }; - let is_end = |l: &str| -> bool { - let t = l.trim().trim_matches('-').trim(); - t.starts_with("END NOSTR") - }; + // Message-closing END only — RECIPIENTS/CONSENT standalone terminators must + // not affect nesting depth (spec Sections 10.2, 11.3). + let is_end = |l: &str| -> bool { level_is_end_message(l) }; let mut depth: i32 = 0; let mut state = "before"; @@ -2144,6 +2243,11 @@ fn populate_armor_from_text( let mut quoted_armor_lines: Vec<&str> = Vec::new(); let mut sig_lines: Vec<&str> = Vec::new(); let mut seal_lines: Vec<&str> = Vec::new(); + // RECIPIENTS / CONSENT block bodies (spec Sections 10–11). Captured here so + // body_text stays clean; serde exposure / capnp storage follows in a later + // phase. + let mut recipients_lines: Vec<&str> = Vec::new(); + let mut consent_lines: Vec<&str> = Vec::new(); for line in &lines { match state { @@ -2159,6 +2263,10 @@ fn populate_armor_from_text( depth += 1; state = "quoted"; quoted_armor_lines.push(line); + } else if is_begin_recipients(line) && depth == 1 { + state = "recipients"; + } else if is_begin_consent(line) && depth == 1 { + state = "consent"; } else if is_begin_sig(line) && depth == 1 { state = "sig"; } else if is_begin_seal(line) && depth == 1 { @@ -2169,6 +2277,42 @@ fn populate_armor_from_text( body_lines.push(line); } } + "recipients" => { + if is_begin_body(line) { + depth += 1; + state = "quoted"; + quoted_armor_lines.push(line); + } else if is_begin_consent(line) && depth == 1 { + state = "consent"; + } else if is_begin_sig(line) && depth == 1 { + state = "sig"; + } else if is_begin_seal(line) && depth == 1 { + state = "seal"; + } else if level_is_end_recipients(line) { + state = "body"; + } else if is_end(line) && depth == 1 { + state = "done"; + } else { + recipients_lines.push(line); + } + } + "consent" => { + if is_begin_body(line) { + depth += 1; + state = "quoted"; + quoted_armor_lines.push(line); + } else if is_begin_sig(line) && depth == 1 { + state = "sig"; + } else if is_begin_seal(line) && depth == 1 { + state = "seal"; + } else if level_is_end_consent(line) { + state = "body"; + } else if is_end(line) && depth == 1 { + state = "done"; + } else { + consent_lines.push(line); + } + } "quoted" => { quoted_armor_lines.push(line); if is_begin_body(line) { depth += 1; } @@ -2189,6 +2333,9 @@ fn populate_armor_from_text( _ => {} } } + // Keep the captured blocks observable to the compiler until serde/capnp + // wiring lands; deliberately unused for now. + let _ = (&recipients_lines, &consent_lines); if state == "before" { debug_log!("[RUST] populate_armor_from_text: state machine never left 'before'"); @@ -3538,12 +3685,25 @@ pub fn decrypt_attachment_pipeline( /// decoded bytes from all levels (matching the JS signing behavior). /// For non-armored bodies: returns the UTF-8 bytes of the body text. pub fn extract_ciphertext_binary(body: &str) -> Vec { - // Use depth-counting parser to properly handle nested reply armor - if let Some((body_text, nested_armor)) = parse_armor_depth(body) { - if let Some(mut bytes) = decode_armor_section(&body_text) { - if let Some(ref nested) = nested_armor { + // Use depth-counting parser to properly handle nested reply armor. + // The per-level signing contribution is body || canonical(recipients) || + // canonical(consent) (spec Section 4.2); levels are concatenated outermost + // → innermost, matching Sections 3.5.0 / 3.6.1. For messages with no + // RECIPIENTS/CONSENT blocks (the common case) this reduces to decode(body) + // and is byte-identical to the legacy behavior. + if let Some(parts) = split_armor_level(body) { + if let Some(body_bytes) = decode_armor_section(&parts.body_text) { + let canon_recipients = parts.recipients_text.as_deref() + .map(crate::agreement::canonicalize_block) + .unwrap_or_default(); + let canon_consent = parts.consent_text.as_deref() + .map(crate::agreement::canonicalize_block) + .unwrap_or_default(); + let mut bytes = crate::agreement::level_signing_bytes( + &body_bytes, &canon_recipients, &canon_consent); + if let Some(ref nested) = parts.nested_armor { let nested_bytes = extract_ciphertext_binary(nested); - debug_log!("[RUST] extract_ciphertext_binary: concatenating {} outer + {} nested bytes", + debug_log!("[RUST] extract_ciphertext_binary: concatenating {} outer (body+recipients+consent) + {} nested bytes", bytes.len(), nested_bytes.len()); bytes.extend_from_slice(&nested_bytes); } @@ -4796,6 +4956,153 @@ nitela\n\ "glossia body should decode correctly with combined signature block"); } + // ============================================= + // Multi-recipient / agreement armor (spec §§10–11) + // ============================================= + + // hex pubkey for a generated keypair (verify_signature_bytes accepts hex/npub). + fn pubkey_hex(npub: &str) -> String { + crate::agreement::normalize_pubkey_hex(npub).expect("valid npub") + } + + #[test] + fn test_split_armor_level_separates_recipients_and_consent() { + let body = "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n\ + QUJDREVG\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n\ + signer aa bb\n\ + self cc dd\n\ + ----- BEGIN NOSTR CONSENT -----\n\ + agreement deadbeef\n\ + signer aa\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n\ + sig pub\n\ + ----- END NOSTR MESSAGE -----"; + let parts = split_armor_level(body).expect("should split"); + assert_eq!(parts.body_text, "QUJDREVG"); + assert_eq!(parts.recipients_text.as_deref(), Some("signer aa bb\nself cc dd")); + assert_eq!(parts.consent_text.as_deref(), Some("agreement deadbeef\nsigner aa")); + assert!(parts.nested_armor.is_none()); + } + + #[test] + fn test_split_armor_level_no_blocks_matches_legacy_body() { + // A plain single-recipient message: body_text is exactly the ciphertext, + // and there are no recipients/consent blocks. + let body = "----- BEGIN NOSTR NIP-44 ENCRYPTED BODY -----\n\ + SGVsbG8=\n\ + ----- END NOSTR MESSAGE -----"; + let parts = split_armor_level(body).expect("should split"); + assert_eq!(parts.body_text, "SGVsbG8="); + assert!(parts.recipients_text.is_none()); + assert!(parts.consent_text.is_none()); + assert!(parts.nested_armor.is_none()); + } + + #[test] + fn test_extract_ciphertext_binary_includes_recipients_and_consent() { + // Section 4.2: level(L) = decode(body) || canonical(recipients) || canonical(consent) + let body_bytes = vec![0xDEu8, 0xAD, 0xBE, 0xEF]; + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = "signer aa bb\nself cc dd"; + let consent = "agreement H\nsigner aa"; + let body = format!( + "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, consent + ); + let got = extract_ciphertext_binary(&body); + let expected = crate::agreement::level_signing_bytes( + &body_bytes, + &crate::agreement::canonicalize_block(recipients), + &crate::agreement::canonicalize_block(consent), + ); + assert_eq!(got, expected); + } + + #[test] + fn test_multi_recipient_signed_message_verifies_section_4_2() { + // Build a multi-recipient HYBRID message and sign over the Section 4.2 target, + // then confirm in-body verification succeeds and tampering breaks it. + let alice = crypto::generate_keypair().unwrap(); + let alice_pub_hex = pubkey_hex(&alice.public_key); + + let body_bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8]; + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = format!("signer {} wrapA\nself {} wrapS", alice_pub_hex, alice_pub_hex); + + let signing_bytes = crate::agreement::level_signing_bytes( + &body_bytes, + &crate::agreement::canonicalize_block(&recipients), + "", + ); + let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); + + let body = format!( + "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n{}\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, sig_hex, alice_pub_hex + ); + + assert_eq!(verify_email_signature_inline(&body), Some(true), + "multi-recipient signature must verify over body+recipients"); + + // Tamper a recipient role (signer→viewer): the canonical recipients block + // changes, so the signature must no longer verify (membership is bound). + let tampered = body.replacen( + &format!("signer {} wrapA", alice_pub_hex), + &format!("viewer {} wrapA", alice_pub_hex), + 1, + ); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "re-labeling a signatory as viewer must invalidate the signature"); + } + + #[test] + fn test_agreement_consent_message_verifies_and_tamper_detected() { + // A signatory's consenting reply: CONSENT block bound by the level signature. + let alice = crypto::generate_keypair().unwrap(); + let alice_pub_hex = pubkey_hex(&alice.public_key); + + let body_bytes = b"This Mutual NDA is entered into as of 2026-06-13.".to_vec(); + let b64 = general_purpose::STANDARD.encode(&body_bytes); + let recipients = format!("signer {} wrapA\nself {} wrapS", alice_pub_hex, alice_pub_hex); + let canon_recipients = crate::agreement::canonicalize_block(&recipients); + + // H = SHA-256(body || canonical(recipients)) — excludes consent (§11.3.1). + let h = crate::agreement::document_hash(&body_bytes, &canon_recipients); + let consent = format!("agreement {}\nsigner {}", hex::encode(h), alice_pub_hex); + let canon_consent = crate::agreement::canonicalize_block(&consent); + + let signing_bytes = crate::agreement::level_signing_bytes( + &body_bytes, &canon_recipients, &canon_consent); + let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); + + let body = format!( + "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n\ + @Alice\n{}\n{}\n\ + ----- END NOSTR MESSAGE -----", + b64, recipients, consent, sig_hex, alice_pub_hex + ); + + assert_eq!(verify_email_signature_inline(&body), Some(true), + "consent message must verify over body+recipients+consent"); + + // Tamper the consented document hash → signature must fail. + let tampered = body.replacen(&hex::encode(h), &"00".repeat(32), 1); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "altering the consented document hash must invalidate the signature"); + } + // ============================================= // parse_armor_components tests // ============================================= From d17d8075e5aeae95c92bd04390af408afd276470 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 15:33:06 +0000 Subject: [PATCH 03/44] feat(agreement): add hybrid multi-recipient message encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the CC/agreements spec: encode_hybrid_agreement composes a complete group-encrypted armor message (spec Sections 10–11): * AES-256-GCM-encrypts the body once under a fresh CEK, NIP-44-wraps the CEK to each To:/Cc: recipient plus a trailing self stanza (§10.1, §10.4) * emits body → RECIPIENTS → optional CONSENT → SIGNATURE in §11.3.2 order * when originator_consents, includes the originator's CONSENT over the document hash H (§11.2, §11.3.1) * signs the §4.2 per-level target (body || recipients || consent), so membership, roles, and consent are tamper-evident Tests: * two-recipient round-trip — every party (incl. self) unwraps the CEK and AES-decrypts the exact body * consent message — emitted H matches SHA-256(ciphertext || canonical recipients); originator is the consenting signer * empty-recipient rejection * cross-module: encoder output verifies through the email §4.2 signature path, yields one valid signature, and leaves body_text clean of the RECIPIENTS/CONSENT blocks Full lib suite: 232 passed. --- tauri-app/backend/src/agreement.rs | 242 ++++++++++++++++++++++++++++- tauri-app/backend/src/email.rs | 31 ++++ 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index e098cea..3de0b33 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -13,11 +13,14 @@ //! * agreement completion accounting — "M of N signatories signed" //! (Section 11.5). //! -//! These are pure functions over already-extracted block text; the actual +//! Most of these are pure functions over already-extracted block text; the //! envelope encryption/decryption uses the primitives in [`crate::crypto`] -//! (`generate_cek`, `aes_gcm_encrypt_raw`, `wrap_cek`, `unwrap_cek`). Wiring -//! these into the recursive armor parser/encoder lives in `email.rs`. +//! (`generate_cek`, `aes_gcm_encrypt_raw`, `wrap_cek`, `unwrap_cek`). +//! [`encode_hybrid_agreement`] composes them into a complete multi-recipient +//! armor message. Wiring into the recursive armor *parser* lives in `email.rs`. +use anyhow::Result; +use base64::{engine::general_purpose, Engine as _}; use sha2::{Digest, Sha256}; /// Workflow role tokens used in a RECIPIENTS stanza (spec Section 10.2 / 11.1). @@ -286,6 +289,122 @@ pub fn compute_completion(required: &[String], consented: &[String]) -> Agreemen } } +/// A cryptographic recipient for [`encode_hybrid_agreement`]: a `To:`/`Cc:` +/// party with a role (`signer` for `To:`, `viewer` for `Cc:`; spec Section 6.3) +/// and their Nostr pubkey (hex or npub). The `self` stanza is added automatically +/// by the encoder, so it MUST NOT be passed here. +#[derive(Debug, Clone)] +pub struct AgreementRecipientInput { + pub role: String, + pub pubkey: String, +} + +/// Compose a complete multi-recipient (group-encrypted) armor message — the +/// hybrid envelope of spec Sections 10–11. +/// +/// The body is AES-256-GCM-encrypted once under a fresh CEK; the CEK is NIP-44 +/// wrapped to every recipient plus a trailing `self` stanza (Section 10.1, 10.4). +/// When `originator_consents` is true, the originator is themselves a required +/// signatory and a CONSENT block over the document hash `H` is included +/// (Section 11.2). The SIGNATURE covers body + recipients + consent per +/// Section 4.2, so membership, roles, and consent are all tamper-evident. +/// +/// `recipients_in` should already be ordered `To:` (signers) then `Cc:` +/// (viewers) to match the deterministic ordering of Section 10.2; the `self` +/// stanza is appended last. Returns the armored `text/plain` payload. +/// +/// This composes the *cryptographic* envelope; glossia prose encoding of the +/// body/signature (Section 5) is applied by the caller's send pipeline if +/// desired — here the body is emitted as base64 and the signature/pubkey as hex, +/// both of which decoders accept. +pub fn encode_hybrid_agreement( + sender_priv: &str, + sender_pub: &str, + profile_name: &str, + body_plaintext: &[u8], + recipients_in: &[AgreementRecipientInput], + originator_consents: bool, +) -> Result { + if recipients_in.is_empty() { + return Err(anyhow::anyhow!( + "encode_hybrid_agreement requires at least one recipient (use the pairwise format for single-recipient messages)" + )); + } + let sender_pub_hex = normalize_pubkey_hex(sender_pub) + .ok_or_else(|| anyhow::anyhow!("invalid sender pubkey"))?; + + // 1–2. Encrypt the body once under a fresh CEK (Section 10.1 steps 1–2). + let cek = crate::crypto::generate_cek(); + let ciphertext = crate::crypto::aes_gcm_encrypt_raw(&cek, body_plaintext)?; + let body_b64 = general_purpose::STANDARD.encode(&ciphertext); + + // 3. Wrap the CEK to each recipient, then to self (Section 10.1 step 3, 10.4). + let mut recipients: Vec = Vec::with_capacity(recipients_in.len() + 1); + for r in recipients_in { + let wrapped = crate::crypto::wrap_cek(sender_priv, &r.pubkey, &cek)?; + recipients.push(Recipient { + role: r.role.to_ascii_lowercase(), + pubkey: r.pubkey.clone(), + wrapped_cek: wrapped, + }); + } + let self_wrapped = crate::crypto::wrap_cek(sender_priv, &sender_pub_hex, &cek)?; + recipients.push(Recipient { + role: ROLE_SELF.to_string(), + pubkey: sender_pub_hex.clone(), + wrapped_cek: self_wrapped, + }); + + let recipients_body = serialize_recipients(&recipients); + let canon_recipients = canonicalize_block(&recipients_body); + + // The signed body bytes are the decoded armor body = the ciphertext bytes + // (decode_armor_section base64-decodes the emitted body), matching §4/§4.2. + let body_decoded = &ciphertext; + + // 4. Optional originator CONSENT over H (Sections 11.2, 11.3.1). + let (consent_body, canon_consent) = if originator_consents { + let h = document_hash(body_decoded, &canon_recipients); + let consent = Consent { + agreement_hash: hex::encode(h), + signer: sender_pub_hex.clone(), + }; + let body = consent.to_block_body(); + let canon = canonicalize_block(&body); + (Some(body), canon) + } else { + (None, String::new()) + }; + + // 5. Sign the §4.2 per-level target: body || recipients || consent. + let signing_bytes = level_signing_bytes(body_decoded, &canon_recipients, &canon_consent); + let sig_hex = crate::crypto::sign_data_bytes(sender_priv, &signing_bytes)?; + + // 6. Assemble the armor (body → RECIPIENTS → [CONSENT] → SIGNATURE; §11.3.2). + let mut out = String::new(); + out.push_str("----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n"); + out.push_str(&body_b64); + out.push('\n'); + out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); + out.push_str(&recipients_body); + out.push('\n'); + if let Some(ref cbody) = consent_body { + out.push_str("----- BEGIN NOSTR CONSENT -----\n"); + out.push_str(cbody); + out.push('\n'); + } + out.push_str("----- BEGIN NOSTR SIGNATURE -----\n"); + out.push('@'); + out.push_str(profile_name); + out.push('\n'); + out.push_str(&sig_hex); + out.push('\n'); + out.push_str(&sender_pub_hex); + out.push('\n'); + out.push_str("----- END NOSTR MESSAGE -----"); + Ok(out) +} + /// Normalize each pubkey to hex (dropping any that fail to parse) and dedup, /// preserving first-seen order. fn dedup_normalized(pubkeys: &[String]) -> Vec { @@ -501,4 +620,121 @@ mod tests { assert_eq!((status.m, status.n), (0, 0)); assert!(!status.complete); } + + // ── encode_hybrid_agreement round-trips ────────────────────────────── + + /// Recover the body from an encoded message as a given reader (by unwrapping + /// the matching RECIPIENTS stanza and AES-GCM-decrypting). Returns the bytes. + fn reader_recovers_body(armor: &str, reader_priv: &str, reader_pub_hex: &str, sender_pub_hex: &str) -> Vec { + // Extract the base64 body (line after the HYBRID BEGIN). + let body_b64 = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR HYBRID")) + .nth(1) + .expect("body line") + .trim() + .to_string(); + let ciphertext = general_purpose::STANDARD.decode(body_b64).expect("b64 body"); + + // Extract the RECIPIENTS block body. + let recip_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let recips = parse_recipients_block(&recip_body); + let stanza = recips + .iter() + .find(|r| normalize_pubkey_hex(&r.pubkey).as_deref() == Some(reader_pub_hex)) + .expect("reader has a stanza"); + let cek = crate::crypto::unwrap_cek(reader_priv, sender_pub_hex, &stanza.wrapped_cek).unwrap(); + crate::crypto::aes_gcm_decrypt_raw(&cek, &ciphertext).unwrap() + } + + #[test] + fn test_encode_hybrid_agreement_roundtrip_two_recipients() { + let sender = crate::crypto::generate_keypair().unwrap(); + let sender_pub_hex = crate::crypto::get_public_key_from_private(&sender.private_key).unwrap(); + let sender_pub_hex = normalize_pubkey_hex(&sender_pub_hex).unwrap(); + let alice = crate::crypto::generate_keypair().unwrap(); + let bob = crate::crypto::generate_keypair().unwrap(); + let alice_hex = normalize_pubkey_hex(&alice.public_key).unwrap(); + let bob_hex = normalize_pubkey_hex(&bob.public_key).unwrap(); + + let body = b"This Mutual NDA is entered into as of 2026-06-13."; + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone() }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, "Originator", body, &recips, false, + ).unwrap(); + + // Structure: hybrid body, recipients (incl. self last), no consent. + assert!(armor.contains("BEGIN NOSTR HYBRID ENCRYPTED BODY")); + assert!(armor.contains("BEGIN NOSTR RECIPIENTS")); + assert!(!armor.contains("BEGIN NOSTR CONSENT")); + let recip_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let parsed = parse_recipients_block(&recip_body); + assert_eq!(parsed.len(), 3, "two recipients + self"); + assert_eq!(parsed[2].role, ROLE_SELF, "self stanza is last"); + + // Each party (incl. self) recovers the exact body. + assert_eq!(reader_recovers_body(&armor, &alice.private_key, &alice_hex, &sender_pub_hex), body); + assert_eq!(reader_recovers_body(&armor, &bob.private_key, &bob_hex, &sender_pub_hex), body); + assert_eq!(reader_recovers_body(&armor, &sender.private_key, &sender_pub_hex, &sender_pub_hex), body); + } + + #[test] + fn test_encode_hybrid_agreement_with_consent_has_matching_h() { + let sender = crate::crypto::generate_keypair().unwrap(); + let sender_pub_hex = normalize_pubkey_hex( + &crate::crypto::get_public_key_from_private(&sender.private_key).unwrap()).unwrap(); + let alice = crate::crypto::generate_keypair().unwrap(); + + let body = b"terms"; + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, "Originator", body, &recips, true, + ).unwrap(); + + assert!(armor.contains("BEGIN NOSTR CONSENT")); + let consent_body: String = armor + .lines() + .skip_while(|l| !l.contains("BEGIN NOSTR CONSENT")) + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")) + .collect::>() + .join("\n"); + let consent = parse_consent_block(&consent_body).expect("consent parses"); + assert_eq!(normalize_pubkey_hex(&consent.signer).unwrap(), sender_pub_hex); + + // The consent's H must equal SHA-256(ciphertext || canonical(recipients)). + let body_b64 = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR HYBRID")).nth(1).unwrap().trim(); + let ciphertext = general_purpose::STANDARD.decode(body_b64).unwrap(); + let recip_body: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")).collect::>().join("\n"); + let h = document_hash(&ciphertext, &canonicalize_block(&recip_body)); + assert_eq!(consent.agreement_hash, hex::encode(h)); + } + + #[test] + fn test_encode_hybrid_agreement_rejects_empty_recipients() { + let sender = crate::crypto::generate_keypair().unwrap(); + let err = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, "X", b"x", &[], false); + assert!(err.is_err()); + } } diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index efd4841..efe35db 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -5103,6 +5103,37 @@ nitela\n\ "altering the consented document hash must invalidate the signature"); } + #[test] + fn test_encoded_hybrid_agreement_verifies_through_email_path() { + // Cross-module round-trip: the agreement encoder's output must verify + // through the email signature path (spec §4.2). + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + let sender = crypto::generate_keypair().unwrap(); + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone() }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, "Originator", + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, + ).unwrap(); + + assert_eq!(verify_email_signature_inline(&armor), Some(true), + "encoder output must verify through the §4.2 email signature path"); + + let sigs = verify_all_signatures_inline(&armor); + assert_eq!(sigs.len(), 1); + assert!(sigs[0].is_valid); + + // The parser must keep the body clean of the RECIPIENTS/CONSENT blocks. + let parsed = parse_armor_components(&armor).expect("parses"); + assert!(!parsed.body_text.contains("signer ")); + assert!(!parsed.body_text.contains("agreement ")); + } + // ============================================= // parse_armor_components tests // ============================================= From de9fb0a6d7b8ee86505dea066a3b18a2ece8827b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:12:57 +0000 Subject: [PATCH 04/44] spec+impl: drop HYBRID keyword; select envelope path by RECIPIENTS presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated HYBRID body keyword was redundant: its only job was to tell the decoder to take the CEK-envelope path, but the presence of a RECIPIENTS block already signals that unambiguously — mirroring how the existing attachment manifest rides inside an ordinary encrypted body and is detected by content, not by a special tag. Spec (docs/nostr-mail-spec.md): * multi-recipient bodies use the generic `BEGIN NOSTR ENCRYPTED BODY` tag (AES-256-GCM under a CEK); no HYBRID keyword * §8 / §10.5: decoders select pairwise vs envelope path by the presence of a RECIPIENTS block, not by body tag * §2.1 documents the rationale; examples updated Implementation: * level_is_begin_body recognizes the generic `ENCRYPTED BODY` tag * encode_hybrid_agreement emits `ENCRYPTED BODY` * tests/comments updated "Hybrid" is retained only where it names the AES+NIP-44 cryptographic construction (the same one attachments use), not as a wire keyword. Full lib suite: 232 passed. --- docs/nostr-mail-spec.md | 28 +++++++++++++++------------- tauri-app/backend/src/agreement.rs | 25 ++++++++++++++++--------- tauri-app/backend/src/email.rs | 14 +++++++------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 4ffe3fb..7d9d61d 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -19,7 +19,7 @@ All Nostr-Mail content is enclosed in ASCII armor blocks using `-----` delimiter | `BEGIN NOSTR SIGNED BODY` | Signed plaintext | Plaintext body content | | `BEGIN NOSTR SIGNATURE` | Proof of authorship + identity | Schnorr signature (64 bytes) followed by sender's pubkey (32 bytes) | | `BEGIN NOSTR SEAL` | Identity declaration | Sender's Nostr public key (unsigned messages only) | -| `BEGIN NOSTR HYBRID ENCRYPTED BODY` | Multi-recipient encrypted content | AES-256-GCM ciphertext under a per-message Content Encryption Key (CEK) | +| `BEGIN NOSTR ENCRYPTED BODY` | Multi-recipient encrypted content | AES-256-GCM ciphertext under a per-message Content Encryption Key (CEK). Identified as the envelope (CEK) path by the accompanying RECIPIENTS block — there is no distinct keyword | | `BEGIN NOSTR RECIPIENTS` | Recipient key-wrap + roles | Per-recipient NIP-44-wrapped CEK, pubkey, and role | | `BEGIN NOSTR CONSENT` | Explicit consent marker | A signatory's binding consent to a specific agreement (document hash + signer pubkey); see Section 11.3 | | `END NOSTR MESSAGE` | Closing tag | Terminates the outermost block | @@ -29,6 +29,8 @@ All Nostr-Mail content is enclosed in ASCII armor blocks using `-----` delimiter The encryption type is embedded directly in the BEGIN tag (e.g., `BEGIN NOSTR NIP-44 ENCRYPTED BODY`), keeping the format self-describing without metadata lines. +**No `HYBRID` keyword.** The multi-recipient envelope reuses the generic `BEGIN NOSTR ENCRYPTED BODY` tag (AES-256-GCM under a CEK) rather than a dedicated keyword. This mirrors how the existing attachment manifest rides inside an ordinary encrypted body: a decoder does not need the tag to announce the scheme. The **presence of a RECIPIENTS block** is the unambiguous signal to take the CEK-envelope decryption path (unwrap the CEK, then AES-256-GCM-decrypt) instead of the pairwise NIP-04/NIP-44 path; see Sections 8 and 10.5. + ### 2.2 Legacy Tag Names (Backwards Compatibility) Decoders MUST accept the following legacy tag names: @@ -203,7 +205,7 @@ Decoders MUST also accept armor block delimiters preceded by `> ` quote prefixes When a message has more than one cryptographic recipient — for example multiple `To:` signatories and/or `Cc:` viewers — the body cannot be encrypted with a single pairwise NIP-44 shared secret, because each recipient derives a different secret. Instead the body is encrypted **once** under a random Content Encryption Key (CEK), and the CEK is wrapped to each recipient with NIP-44. This is the same hybrid construction already used for attachments (AES-256 for payload, NIP-44 for the key), generalized to N recipients. The envelope mechanics are normative in Section 10. ``` ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- +----- BEGIN NOSTR ENCRYPTED BODY ----- ----- BEGIN NOSTR RECIPIENTS ----- signer @@ -226,13 +228,13 @@ A multi-recipient message SHOULD be signed; the SIGNATURE then covers both the b Each encrypted level in a reply chain has its own independent CEK, and therefore its own RECIPIENTS block. The recipients block is **not** inherited from an enclosing or enclosed level. This both is cryptographically required (a single block cannot hand out different per-level CEKs) and records the recipient/role set as it stood at each point in the thread. ``` ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- +----- BEGIN NOSTR ENCRYPTED BODY ----- ----- BEGIN NOSTR RECIPIENTS ----- signer signer self ------ BEGIN NOSTR HYBRID ENCRYPTED BODY ----- +----- BEGIN NOSTR ENCRYPTED BODY ----- ----- BEGIN NOSTR RECIPIENTS ----- signer @@ -505,10 +507,10 @@ Because email headers are spoofable and may be rewritten on forward (Section 7), - Single-recipient / plaintext: `SHA-256(decoded_body_bytes)` (Section 4) - Multi-recipient (RECIPIENTS block present): include the canonicalized recipients block per Section 4.2 - For NIP-04: signature MUST be present and MUST verify. If the signature is missing or invalid, the decoder MUST reject the message **without proceeding to step 8** (see Section 4.1) - - For NIP-44 / hybrid: signature verification is recommended but not required (NIP-44 and AES-256-GCM provide their own authentication via HMAC / GCM tag) -8. **Decrypt** if encrypted: - - **Pairwise** (`NIP-04`/`NIP-44 ENCRYPTED BODY`, no RECIPIENTS): use the recipient's private key and the sender's pubkey - - **Multi-recipient** (`HYBRID ENCRYPTED BODY` + RECIPIENTS): locate the stanza whose pubkey matches the reader's own pubkey, NIP-44-unwrap the CEK using the reader's private key and the sender's pubkey, then AES-256-GCM-decrypt the body with the CEK. If no stanza matches the reader's pubkey, the reader is not a recipient of that level and cannot decrypt it (this is expected for levels predating the reader's addition to the thread — see Section 10.8) + - For NIP-44 / CEK-envelope: signature verification is recommended but not required (NIP-44 and AES-256-GCM provide their own authentication via HMAC / GCM tag) +8. **Decrypt** if encrypted. The path is selected by the **presence of a RECIPIENTS block**, not by a distinct body tag: + - **Pairwise** (`NIP-04`/`NIP-44 ENCRYPTED BODY`, **no** RECIPIENTS block): use the recipient's private key and the sender's pubkey + - **Multi-recipient** (generic `ENCRYPTED BODY` **with** a RECIPIENTS block): locate the stanza whose pubkey matches the reader's own pubkey, NIP-44-unwrap the CEK using the reader's private key and the sender's pubkey, then AES-256-GCM-decrypt the body with the CEK. If no stanza matches the reader's pubkey, the reader is not a recipient of that level and cannot decrypt it (this is expected for levels predating the reader's addition to the thread — see Section 10.8) ## 9. Spam Rescue & Folder Handling @@ -552,11 +554,11 @@ NIP-44 is pairwise: a ciphertext encrypted with the shared secret of `(senderPri ``` 1. Generate a random 256-bit Content Encryption Key (CEK). -2. Encrypt the body ONCE with AES-256-GCM under the CEK → one HYBRID ENCRYPTED BODY +2. Encrypt the body ONCE with AES-256-GCM under the CEK → one ENCRYPTED BODY (attachments are encrypted under the same CEK, as today). 3. For each pubkey P in { To ∪ Cc ∪ sender-self }: wrapped[P] = NIP44_encrypt(CEK, senderPriv, P) → one RECIPIENTS stanza per P -4. Emit: [HYBRID ENCRYPTED BODY] + [RECIPIENTS block] + optional [SIGNATURE] +4. Emit: [ENCRYPTED BODY] + [RECIPIENTS block] + optional [SIGNATURE] ``` To read the message, a recipient locates the stanza addressed to their own pubkey, NIP-44-unwraps the CEK with their private key and the sender's pubkey, then AES-256-GCM-decrypts the body. The body ciphertext is produced once regardless of recipient count; only the 32-byte CEK is wrapped per recipient. @@ -595,8 +597,8 @@ Every multi-recipient message MUST include a `self` stanza wrapping the CEK to t The envelope format is used **only** when a message has more than one cryptographic recipient (i.e. more than one of `To ∪ Cc`, excluding `self`). A message to a single recipient continues to use the pairwise `BEGIN NOSTR NIP-44 ENCRYPTED BODY` format with no RECIPIENTS block, so existing decoders are unaffected. -- Encoders MUST emit the pairwise format for single-recipient messages and the hybrid format for multi-recipient messages. -- Decoders MUST select the decryption path by block type: `HYBRID ENCRYPTED BODY` ⇒ envelope path (Section 8 step 8, multi-recipient); `NIP-44`/`NIP-04 ENCRYPTED BODY` ⇒ pairwise path. +- Encoders MUST emit the pairwise format for single-recipient messages and the envelope format (generic `ENCRYPTED BODY` + RECIPIENTS block) for multi-recipient messages. +- Decoders MUST select the decryption path by the **presence of a RECIPIENTS block**: a RECIPIENTS block present ⇒ envelope (CEK) path (Section 8 step 8, multi-recipient); absent ⇒ pairwise path keyed on the `NIP-44`/`NIP-04` body tag. There is no `HYBRID` keyword. - NIP-04 MUST NOT be used as the body cipher for multi-recipient messages (the body cipher is always AES-256-GCM under the CEK, which is authenticated; see Section 4.1 for why unauthenticated CBC is disallowed). ### 10.6 Signature Coverage @@ -640,7 +642,7 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: -- **Body**: the agreement cover text / terms, in a signed `HYBRID ENCRYPTED BODY`. +- **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword). - **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index 3de0b33..baa1ba3 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -300,10 +300,13 @@ pub struct AgreementRecipientInput { } /// Compose a complete multi-recipient (group-encrypted) armor message — the -/// hybrid envelope of spec Sections 10–11. +/// envelope of spec Sections 10–11. /// -/// The body is AES-256-GCM-encrypted once under a fresh CEK; the CEK is NIP-44 -/// wrapped to every recipient plus a trailing `self` stanza (Section 10.1, 10.4). +/// The body is AES-256-GCM-encrypted once under a fresh CEK and emitted in a +/// generic `ENCRYPTED BODY` block (there is no `HYBRID` keyword — the presence +/// of the RECIPIENTS block selects the CEK-envelope path; Sections 8, 10.5). +/// The CEK is NIP-44 wrapped to every recipient plus a trailing `self` stanza +/// (Section 10.1, 10.4). /// When `originator_consents` is true, the originator is themselves a required /// signatory and a CONSENT block over the document hash `H` is included /// (Section 11.2). The SIGNATURE covers body + recipients + consent per @@ -381,8 +384,12 @@ pub fn encode_hybrid_agreement( let sig_hex = crate::crypto::sign_data_bytes(sender_priv, &signing_bytes)?; // 6. Assemble the armor (body → RECIPIENTS → [CONSENT] → SIGNATURE; §11.3.2). + // The body uses the generic `ENCRYPTED BODY` tag (AES-256-GCM under a CEK); + // there is no dedicated keyword — the presence of the RECIPIENTS block is + // what tells a decoder to take the CEK-envelope path rather than pairwise + // NIP-44 (spec Sections 8, 10.5). let mut out = String::new(); - out.push_str("----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n"); + out.push_str("----- BEGIN NOSTR ENCRYPTED BODY -----\n"); out.push_str(&body_b64); out.push('\n'); out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); @@ -626,10 +633,10 @@ mod tests { /// Recover the body from an encoded message as a given reader (by unwrapping /// the matching RECIPIENTS stanza and AES-GCM-decrypting). Returns the bytes. fn reader_recovers_body(armor: &str, reader_priv: &str, reader_pub_hex: &str, sender_pub_hex: &str) -> Vec { - // Extract the base64 body (line after the HYBRID BEGIN). + // Extract the base64 body (line after the ENCRYPTED BODY BEGIN). let body_b64 = armor .lines() - .skip_while(|l| !l.contains("BEGIN NOSTR HYBRID")) + .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")) .nth(1) .expect("body line") .trim() @@ -672,8 +679,8 @@ mod tests { &sender.private_key, &sender.public_key, "Originator", body, &recips, false, ).unwrap(); - // Structure: hybrid body, recipients (incl. self last), no consent. - assert!(armor.contains("BEGIN NOSTR HYBRID ENCRYPTED BODY")); + // Structure: generic encrypted body, recipients (incl. self last), no consent. + assert!(armor.contains("BEGIN NOSTR ENCRYPTED BODY")); assert!(armor.contains("BEGIN NOSTR RECIPIENTS")); assert!(!armor.contains("BEGIN NOSTR CONSENT")); let recip_body: String = armor @@ -721,7 +728,7 @@ mod tests { // The consent's H must equal SHA-256(ciphertext || canonical(recipients)). let body_b64 = armor.lines() - .skip_while(|l| !l.contains("BEGIN NOSTR HYBRID")).nth(1).unwrap().trim(); + .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")).nth(1).unwrap().trim(); let ciphertext = general_purpose::STANDARD.decode(body_b64).unwrap(); let recip_body: String = armor.lines() .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")).skip(1) diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index efe35db..bcb234b 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -1772,9 +1772,9 @@ struct ArmorLevelParts { fn level_is_begin_body(l: &str) -> bool { let t = l.trim().trim_matches('-').trim(); - t.starts_with("BEGIN NOSTR NIP-") + t.starts_with("BEGIN NOSTR NIP-") // pairwise NIP-04 / NIP-44 + || t.starts_with("BEGIN NOSTR ENCRYPTED") // generic AES-256-GCM under a CEK (multi-recipient envelope) || t.starts_with("BEGIN NOSTR SIGNED") - || t.starts_with("BEGIN NOSTR HYBRID") } fn level_is_begin_recipients(l: &str) -> bool { l.trim().trim_matches('-').trim() == "BEGIN NOSTR RECIPIENTS" @@ -4967,7 +4967,7 @@ nitela\n\ #[test] fn test_split_armor_level_separates_recipients_and_consent() { - let body = "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n\ + let body = "----- BEGIN NOSTR ENCRYPTED BODY -----\n\ QUJDREVG\n\ ----- BEGIN NOSTR RECIPIENTS -----\n\ signer aa bb\n\ @@ -5008,7 +5008,7 @@ nitela\n\ let recipients = "signer aa bb\nself cc dd"; let consent = "agreement H\nsigner aa"; let body = format!( - "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ ----- BEGIN NOSTR CONSENT -----\n{}\n\ ----- END NOSTR MESSAGE -----", @@ -5025,7 +5025,7 @@ nitela\n\ #[test] fn test_multi_recipient_signed_message_verifies_section_4_2() { - // Build a multi-recipient HYBRID message and sign over the Section 4.2 target, + // Build a multi-recipient (group-encrypted) message and sign over the Section 4.2 target, // then confirm in-body verification succeeds and tampering breaks it. let alice = crypto::generate_keypair().unwrap(); let alice_pub_hex = pubkey_hex(&alice.public_key); @@ -5042,7 +5042,7 @@ nitela\n\ let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); let body = format!( - "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ ----- BEGIN NOSTR SIGNATURE -----\n\ @Alice\n{}\n{}\n\ @@ -5085,7 +5085,7 @@ nitela\n\ let sig_hex = crypto::sign_data_bytes(&alice.private_key, &signing_bytes).unwrap(); let body = format!( - "----- BEGIN NOSTR HYBRID ENCRYPTED BODY -----\n{}\n\ + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ ----- BEGIN NOSTR RECIPIENTS -----\n{}\n\ ----- BEGIN NOSTR CONSENT -----\n{}\n\ ----- BEGIN NOSTR SIGNATURE -----\n\ From 65e25e65b06780643dfacd4a84294a3e933b4e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:25:24 +0000 Subject: [PATCH 05/44] feat(agreement): bind email in RECIPIENTS stanza (issue #102 prerequisite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #102 (SecureJoin-style email↔npub binding) requires the (npub, email) pairing to live *inside* the signed RECIPIENTS block rather than only in the spoofable To/Cc headers, so the originator's signature authenticates it. Spec (docs/nostr-mail-spec.md §10.2, §6.3, §10.9): * stanza grammar extended to ` []` — the email is an optional fourth, trailing token * placed after wrapped-cek (not before) so a three-token decoder still recovers role/pubkey/cek and can decrypt; the '@' makes it unambiguous against the base64 wrapped-cek * §6.3: when present, the in-block email (not the header) is the authoritative (pubkey, email) pairing, authenticated via §10.6 * §10.9: notes it restates addresses already in To/Cc, so no new disclosure Implementation: * Recipient gains `email: Option`; to_line appends it when present * parse_recipients_block captures the 4th token as email only if it contains '@' (else treated as an ignorable forward-compat token) * AgreementRecipientInput gains `email`; encode_hybrid_agreement takes `sender_email` for the self stanza and threads recipient emails through Tests: email token parse/roundtrip, non-email trailing token ignored, encoder carries the pairing, and a tamper test proving that re-pairing an npub to a different email breaks the originator's signature. Full lib suite: 234 passed. Scopes only the spec prerequisite; the handshake, challenge store, and binding-verified trust UI (the rest of #102, gated on #101) are follow-ups. --- docs/nostr-mail-spec.md | 10 ++-- tauri-app/backend/src/agreement.rs | 86 ++++++++++++++++++++++-------- tauri-app/backend/src/email.rs | 31 +++++++++-- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 7d9d61d..97908ad 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -485,6 +485,8 @@ Both `To:` and `Cc:` recipients receive a NIP-44-wrapped CEK and can decrypt the Because email headers are spoofable and may be rewritten on forward (Section 7), the authoritative role for each recipient is the `role` token inside the signed RECIPIENTS block, **not** the header. The headers are a transport convenience and a mirror for non-Nostr-Mail clients. Where the two disagree, decoders MUST trust the signed RECIPIENTS block. +To make the `(pubkey, email)` pairing itself authenticated rather than inferred from the spoofable header order, an encoder MAY include the delivered address as the optional fourth `email` token of each stanza (Section 10.2). Because the signature covers the canonicalized RECIPIENTS block (Section 10.6), an in-block `email` binds that address to its `pubkey` tamper-evidently — this is what the email↔npub binding handshake (issue #102) relies on. When a stanza carries an `email`, decoders MUST treat the in-block value, not the header, as the authoritative pairing. + `Bcc:` recipients, if supported, MUST NOT appear in the RECIPIENTS block of the copy sent to other recipients (doing so would disclose the blind recipient); each Bcc recipient instead receives a separately addressed copy. ## 7. Identity Model @@ -567,10 +569,10 @@ The sender's pubkey (needed to unwrap the CEK) is taken from the SIGNATURE block ### 10.2 RECIPIENTS Block Format -The RECIPIENTS block contains one entry per line. Each entry is exactly three space-separated tokens: +The RECIPIENTS block contains one entry per line. Each entry is three or four space-separated tokens — the fourth (`email`) is optional: ``` - + [] ``` | Field | Encoding | Notes | @@ -578,11 +580,13 @@ The RECIPIENTS block contains one entry per line. Each entry is exactly three sp | `role` | `signer` \| `viewer` \| `self` (lowercase token) | See Section 11.1. Unknown roles MUST be ignored for workflow purposes but still treated as recipients for decryption. | | `pubkey` | hex (64 chars) or npub (bech32, `npub1…`) | The recipient's Nostr public key. Glossia is **not** used here, to keep entries single-token and line-parseable. | | `wrapped-cek` | base64 (NIP-44 payload) | `NIP44_encrypt(CEK)` to `pubkey`. Glossia is not used here. | +| `email` (optional) | RFC 5322 addr-spec, no display name | The address this stanza was delivered to. Binds the recipient's `(pubkey, email)` pairing **inside** the signed block (Section 10.6), so it cannot be altered or re-paired without breaking the signature — the prerequisite for the email↔npub binding handshake (issue #102). Omitted when the recipient is known only by pubkey. | Rules: - Entries SHOULD be ordered deterministically: `To:` recipients in header order, then `Cc:` recipients in header order, then the `self` stanza last. Deterministic ordering keeps the signed canonical form stable across encoders. - Display names are intentionally **omitted**; clients resolve names from the Nostr social registry / profile cache by pubkey. +- The optional `email` is the **fourth** token and is placed after `wrapped-cek` (not between `pubkey` and `wrapped-cek`) so that a decoder which only knows the three-token grammar still recovers `role`/`pubkey`/`wrapped-cek` and can decrypt; it simply ignores the trailing token. An email contains an `@` and is therefore unambiguous against the base64 `wrapped-cek` (which never does). Email addresses contain no spaces, so the entry stays line-parseable. - Decoders MUST tolerate additional trailing tokens on a line (forward compatibility) and MUST ignore blank lines and `> ` quote prefixes. ### 10.3 Roles @@ -620,7 +624,7 @@ Because each level is sealed under its own CEK wrapped only to that level's list ### 10.9 Privacy Considerations -The RECIPIENTS block lists each participant's pubkey in the (cleartext) `text/plain` body, so relays/mail servers that see the message learn the participant set. This is no worse than the `To:`/`Cc:` headers, which already expose the email addresses, but it is strictly less private than NIP-59 gift wrap, which hides recipients behind an ephemeral key. A future version MAY define a metadata-private mode that omits pubkey labels and requires readers to trial-decrypt each stanza (as the `age` format does); this is out of scope for v0.4. +The RECIPIENTS block lists each participant's pubkey in the (cleartext) `text/plain` body, so relays/mail servers that see the message learn the participant set. When the optional `email` token (Section 10.2) is included, the block also restates the addresses — but those already appear in the `To:`/`Cc:` headers, so this discloses nothing new to the transport. Overall this is no worse than the `To:`/`Cc:` headers, which already expose the email addresses, but it is strictly less private than NIP-59 gift wrap, which hides recipients behind an ephemeral key. The metadata-private mode noted below would omit both the pubkey labels and the `email` tokens. A future version MAY define a metadata-private mode that omits pubkey labels and requires readers to trial-decrypt each stanza (as the `age` format does); this is out of scope for v0.4. A planned future direction is a **Nostr-only agreement transport** that folds in SIGit's privacy model (Section 11.7): instead of carrying the agreement in the email body, the message body and document(s) would be sealed and NIP-59 gift-wrapped to each counterparty (optionally with large files on Blossom), hiding the participant set behind ephemeral keys. The consent/chain-of-custody semantics defined here (the document hash `H`, per-signatory consent, and signature chaining) are transport-independent and would carry over unchanged; only the envelope and recipient-addressing would differ. This is out of scope for v0.4 and noted here so the consent model is not specialized to the cleartext email envelope. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index baa1ba3..4c16556 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -28,7 +28,7 @@ pub const ROLE_SIGNER: &str = "signer"; pub const ROLE_VIEWER: &str = "viewer"; pub const ROLE_SELF: &str = "self"; -/// A single entry in a `RECIPIENTS` block: ` ` +/// A single entry in a `RECIPIENTS` block: ` []` /// (spec Section 10.2). `pubkey` and `wrapped_cek` are retained exactly as they /// appear on the wire (hex/npub for the key, base64 NIP-44 payload for the CEK). #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,12 +39,21 @@ pub struct Recipient { pub pubkey: String, /// `NIP44_encrypt(CEK)` to `pubkey`, base64. pub wrapped_cek: String, + /// Optional fourth token: the address this stanza was delivered to. Because + /// the signature covers the canonicalized RECIPIENTS block (Section 10.6), + /// an in-block email binds the `(pubkey, email)` pairing tamper-evidently — + /// the basis for the email↔npub binding handshake (issue #102). + pub email: Option, } impl Recipient { - /// Serialize this entry as its canonical single line: `role pubkey wrapped-cek`. + /// Serialize this entry as its canonical line: `role pubkey wrapped-cek [email]`. + /// The `email` is appended as the trailing token only when present. pub fn to_line(&self) -> String { - format!("{} {} {}", self.role, self.pubkey, self.wrapped_cek) + match &self.email { + Some(email) => format!("{} {} {} {}", self.role, self.pubkey, self.wrapped_cek, email), + None => format!("{} {} {}", self.role, self.pubkey, self.wrapped_cek), + } } /// True for the `signer` role (a required signatory; spec Section 11.1). @@ -108,9 +117,12 @@ pub fn canonicalize_block(block_body: &str) -> String { /// and the following delimiter) into entries (spec Section 10.2). /// /// Each non-blank line must have at least three space-separated tokens -/// ` `; additional trailing tokens are tolerated -/// (forward compatibility) and ignored. Blank lines and `> ` quote prefixes are -/// ignored. Malformed lines (fewer than 3 tokens) are skipped. +/// ` `, with an optional fourth `email` token; any +/// further trailing tokens are tolerated (forward compatibility) and ignored. +/// Blank lines and `> ` quote prefixes are ignored. Malformed lines (fewer than +/// 3 tokens) are skipped. The fourth token is treated as the email only if it +/// contains `@` — which a base64 `wrapped-cek` never does — so a non-email +/// trailing token is not misread (spec Section 10.2). pub fn parse_recipients_block(block_body: &str) -> Vec { let mut out = Vec::new(); for raw in block_body.split('\n') { @@ -131,7 +143,8 @@ pub fn parse_recipients_block(block_body: &str) -> Vec { Some(t) => t.to_string(), None => continue, }; - out.push(Recipient { role, pubkey, wrapped_cek }); + let email = toks.next().filter(|t| t.contains('@')).map(|t| t.to_string()); + out.push(Recipient { role, pubkey, wrapped_cek, email }); } out } @@ -297,6 +310,11 @@ pub fn compute_completion(required: &[String], consented: &[String]) -> Agreemen pub struct AgreementRecipientInput { pub role: String, pub pubkey: String, + /// The address this recipient was delivered to. When present it is written + /// as the stanza's fourth token, binding `(pubkey, email)` under the + /// signature (spec Sections 10.2, 10.6) — required for the binding handshake + /// (issue #102). `None` when the recipient is known only by pubkey. + pub email: Option, } /// Compose a complete multi-recipient (group-encrypted) armor message — the @@ -314,7 +332,10 @@ pub struct AgreementRecipientInput { /// /// `recipients_in` should already be ordered `To:` (signers) then `Cc:` /// (viewers) to match the deterministic ordering of Section 10.2; the `self` -/// stanza is appended last. Returns the armored `text/plain` payload. +/// stanza is appended last. Each recipient's optional `email` is written as the +/// stanza's fourth token, binding `(pubkey, email)` under the signature; pass +/// `sender_email` to do the same for the `self` stanza. Returns the armored +/// `text/plain` payload. /// /// This composes the *cryptographic* envelope; glossia prose encoding of the /// body/signature (Section 5) is applied by the caller's send pipeline if @@ -323,6 +344,7 @@ pub struct AgreementRecipientInput { pub fn encode_hybrid_agreement( sender_priv: &str, sender_pub: &str, + sender_email: Option<&str>, profile_name: &str, body_plaintext: &[u8], recipients_in: &[AgreementRecipientInput], @@ -349,6 +371,7 @@ pub fn encode_hybrid_agreement( role: r.role.to_ascii_lowercase(), pubkey: r.pubkey.clone(), wrapped_cek: wrapped, + email: r.email.clone(), }); } let self_wrapped = crate::crypto::wrap_cek(sender_priv, &sender_pub_hex, &cek)?; @@ -356,6 +379,7 @@ pub fn encode_hybrid_agreement( role: ROLE_SELF.to_string(), pubkey: sender_pub_hex.clone(), wrapped_cek: self_wrapped, + email: sender_email.map(|s| s.to_string()), }); let recipients_body = serialize_recipients(&recipients); @@ -453,22 +477,36 @@ mod tests { ); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 3); - assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "wrapcekA".into() }); + assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "wrapcekA".into(), email: None }); assert_eq!(recips[1].role, "viewer"); assert_eq!(recips[2].role, "self"); + assert!(recips.iter().all(|r| r.email.is_none())); } #[test] - fn test_parse_recipients_tolerates_extra_tokens_blanks_and_quotes() { + fn test_parse_recipients_email_token_and_tolerates_quotes() { let block = format!( - "> signer {} wrapcekA extratoken\n\n>> viewer {} wrapcekB\n", + "> signer {} wrapcekA bob@example.com\n\n>> viewer {} wrapcekB\n", HEX_A, HEX_B ); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 2); - // Extra trailing token is ignored, not folded into wrapped_cek. + // The 4th token (contains '@') is captured as the bound email. assert_eq!(recips[0].wrapped_cek, "wrapcekA"); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + // A stanza without an email leaves it None. assert_eq!(recips[1].role, "viewer"); + assert!(recips[1].email.is_none()); + } + + #[test] + fn test_parse_recipients_non_email_trailing_token_ignored() { + // A 4th token without '@' is a future/unknown token, not an email. + let block = format!("signer {} wrapcekA futurefield extra", HEX_A); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 1); + assert_eq!(recips[0].wrapped_cek, "wrapcekA"); + assert!(recips[0].email.is_none()); } #[test] @@ -482,11 +520,12 @@ mod tests { #[test] fn test_serialize_recipients_roundtrip() { let recips = vec![ - Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "cekA".into() }, - Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: "cekS".into() }, + Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "cekA".into(), email: Some("a@x.io".into()) }, + Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: "cekS".into(), email: None }, ]; let body = serialize_recipients(&recips); - assert_eq!(body, format!("signer {} cekA\nself {} cekS", HEX_A, HEX_B)); + // The email is the trailing token only on stanzas that carry one. + assert_eq!(body, format!("signer {} cekA a@x.io\nself {} cekS", HEX_A, HEX_B)); assert_eq!(parse_recipients_block(&body), recips); } @@ -672,11 +711,11 @@ mod tests { let body = b"This Mutual NDA is entered into as of 2026-06-13."; let recips = vec![ - AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, - AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone() }, + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, ]; let armor = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, "Originator", body, &recips, false, + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", body, &recips, false, ).unwrap(); // Structure: generic encrypted body, recipients (incl. self last), no consent. @@ -694,6 +733,11 @@ mod tests { assert_eq!(parsed.len(), 3, "two recipients + self"); assert_eq!(parsed[2].role, ROLE_SELF, "self stanza is last"); + // The (pubkey, email) pairing is carried inside the signed block (§10.2/#102). + assert_eq!(parsed[0].email.as_deref(), Some("alice@example.com")); + assert_eq!(parsed[1].email.as_deref(), Some("bob@example.org")); + assert_eq!(parsed[2].email.as_deref(), Some("me@example.net")); + // Each party (incl. self) recovers the exact body. assert_eq!(reader_recovers_body(&armor, &alice.private_key, &alice_hex, &sender_pub_hex), body); assert_eq!(reader_recovers_body(&armor, &bob.private_key, &bob_hex, &sender_pub_hex), body); @@ -709,10 +753,10 @@ mod tests { let body = b"terms"; let recips = vec![ - AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: None }, ]; let armor = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, "Originator", body, &recips, true, + &sender.private_key, &sender.public_key, None, "Originator", body, &recips, true, ).unwrap(); assert!(armor.contains("BEGIN NOSTR CONSENT")); @@ -741,7 +785,7 @@ mod tests { fn test_encode_hybrid_agreement_rejects_empty_recipients() { let sender = crate::crypto::generate_keypair().unwrap(); let err = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, "X", b"x", &[], false); + &sender.private_key, &sender.public_key, None, "X", b"x", &[], false); assert!(err.is_err()); } } diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index bcb234b..37b5ff8 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -5113,11 +5113,11 @@ nitela\n\ let bob = crypto::generate_keypair().unwrap(); let recips = vec![ - AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone() }, - AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone() }, + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, ]; let armor = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, "Originator", + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, ).unwrap(); @@ -5134,6 +5134,31 @@ nitela\n\ assert!(!parsed.body_text.contains("agreement ")); } + #[test] + fn test_bound_email_in_recipients_is_tamper_evident() { + // Issue #102: the (pubkey, email) pairing lives inside the signed + // RECIPIENTS block, so re-pairing an npub to a different address must + // break the originator's signature (§10.6). + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let sender = crypto::generate_keypair().unwrap(); + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: Some("alice@example.com".into()) }, + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", + b"terms", &recips, false, + ).unwrap(); + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + + // Swap alice's bound address to an attacker-controlled mailbox. + let tampered = armor.replacen("alice@example.com", "attacker@evil.test", 1); + assert_eq!(verify_email_signature_inline(&tampered), Some(false), + "re-pairing an npub to a different email must invalidate the signature"); + } + // ============================================= // parse_armor_components tests // ============================================= From 5a6f2cd924bd046ce4a5684098783335f2a850e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 16:56:09 +0000 Subject: [PATCH 06/44] =?UTF-8?q?feat(agreement):=20stateless=20email?= =?UTF-8?q?=E2=86=94npub=20binding=20verifier=20(issue=20#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify a binding purely from a self-contained thread — no outstanding-challenge store. Because a reply nests the issuer's own signed challenge, "I issued this" is re-derived by checking the email-asserting level is signed by my_pubkey, so the verdict is a pure function of mailbox contents and a fresh client rebuilds all bindings by re-scanning (mirroring the stateless §9.1 spam-rescue design). * agreement::Binding — serde type recording (pubkey, email, issuer_pubkey) * email::verify_email_binding(thread, my_pubkey) -> Vec: 1. every signature in the chain must verify (§4.2 integrity) 2. a level I signed must pair R with an email in its RECIPIENTS stanza 3. an outer (quoting) level must be signed by R ⇒ R controls the npub and demonstrated read access to that address * collect_level_recipients — depth-keyed recipient walk aligned with verify_all_signatures_inline Tests: completed handshake yields the binding; a lone challenge does not (a single message is never sufficient); a tampered (npub,email) pairing voids the proof; and the check is issuer-side (wrong perspective yields nothing). Full lib suite: 238 passed. --- tauri-app/backend/src/agreement.rs | 22 ++++ tauri-app/backend/src/email.rs | 174 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index 4c16556..4ed63d1 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -21,6 +21,7 @@ use anyhow::Result; use base64::{engine::general_purpose, Engine as _}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; /// Workflow role tokens used in a RECIPIENTS stanza (spec Section 10.2 / 11.1). @@ -269,6 +270,27 @@ pub struct AgreementStatus { pub consented_signers: Vec, } +/// A proven email↔npub binding (issue #102): the `pubkey` is demonstrably +/// controlled by a party who also demonstrated read access to `email`. +/// +/// The proof is a self-contained thread, so this verdict is **stateless** — +/// re-derivable from the message alone, with no outstanding-challenge store. The +/// issuer asserted the `(pubkey, email)` pairing in a RECIPIENTS stanza of a +/// level *they* signed; the holder of `pubkey` proved control + read access by +/// signing an outer level that quotes (nests) that signed challenge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Binding { + /// The bound party's Nostr pubkey, hex — proven controlled (reply signature). + pub pubkey: String, + /// The address bound to `pubkey` — asserted by the issuer in a signed + /// RECIPIENTS stanza, proven by the reply's delivery/read access. + pub email: String, + /// The pubkey (hex) of the party who issued the challenge and asserted the + /// pairing (the verifier's own key, for an issuer-side verification). + pub issuer_pubkey: String, +} + /// Compute "M of N signed" completion (spec Section 11.5). /// /// `required` is the required signatory set (the `signer` stanzas of the diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 37b5ff8..749bff2 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -3861,6 +3861,96 @@ fn verify_all_signatures_recursive(body: &str, depth: usize) -> Vec)>) { + if let Some(parts) = split_armor_level(armor) { + let recips = parts.recipients_text.as_deref() + .map(crate::agreement::parse_recipients_block) + .unwrap_or_default(); + out.push((depth, recips)); + if let Some(nested) = parts.nested_armor { + collect_level_recipients(&nested, depth + 1, out); + } + } +} + +/// Verify email↔npub bindings provable from a single self-contained thread — +/// **statelessly** (issue #102). There is no outstanding-challenge store: because +/// a reply nests the issuer's own signed challenge, "I issued this" is re-derived +/// by checking that the email-asserting level is signed by `my_pubkey`. This makes +/// the verdict a pure function of mailbox contents, so a fresh client reconstructs +/// all bindings by re-scanning (mirroring the stateless §9.1 spam-rescue design). +/// +/// A binding `(R, email)` is returned iff, in the same thread: +/// 1. every signature in the chain verifies (chain integrity, §4.2); and +/// 2. some level signed by `my_pubkey` carries a RECIPIENTS stanza pairing `R` +/// with `email` (the issuer's authenticated assertion); and +/// 3. an **outer** level (nesting that challenge) is signed by `R` — proving +/// `R` controls the npub and received/read the challenge. +/// +/// `my_pubkey` is the verifier's own key (hex or npub) — this is the issuer-side +/// check; the reverse binding (when the counterparty asserted *your* address in a +/// level they signed) is the symmetric call with their key. +pub fn verify_email_binding(thread: &str, my_pubkey: &str) -> Vec { + let me = match crate::agreement::normalize_pubkey_hex(my_pubkey) { + Some(h) => h, + None => return Vec::new(), + }; + + // 1. Chain integrity: every in-thread signature must verify (§4.2). A single + // broken signature (e.g. a tampered (npub,email) pairing) voids the proof. + let sigs = verify_all_signatures_inline(thread); + if sigs.is_empty() || sigs.iter().any(|s| !s.is_valid) { + return Vec::new(); + } + let signer_at = |depth: usize| -> Option { + sigs.iter() + .find(|s| s.depth == depth) + .and_then(|s| s.pubkey_hex.as_deref()) + .and_then(crate::agreement::normalize_pubkey_hex) + }; + + // 2. Per-level recipient stanzas. + let mut levels: Vec<(usize, Vec)> = Vec::new(); + collect_level_recipients(thread, 0, &mut levels); + + // 3. For each level I signed, each emailed recipient R who also signed an + // outer (quoting) level ⇒ proven binding. + let mut out: Vec = Vec::new(); + for (depth, recips) in &levels { + if signer_at(*depth).as_deref() != Some(me.as_str()) { + continue; // not a level I (the issuer) signed + } + for r in recips { + let email = match &r.email { + Some(e) => e, + None => continue, + }; + let r_hex = match crate::agreement::normalize_pubkey_hex(&r.pubkey) { + Some(h) => h, + None => continue, + }; + if r_hex == me { + continue; // skip the issuer's own (self) stanza + } + let replied = (0..*depth).any(|d| signer_at(d).as_deref() == Some(r_hex.as_str())); + if replied { + let binding = crate::agreement::Binding { + pubkey: r_hex.clone(), + email: email.clone(), + issuer_pubkey: me.clone(), + }; + if !out.contains(&binding) { + out.push(binding); + } + } + } + } + out +} + /// Extract message ID from email headers pub fn extract_message_id_from_headers(raw_headers: &str) -> Option { // Try multiple patterns to find Message-ID @@ -5159,6 +5249,90 @@ nitela\n\ "re-pairing an npub to a different email must invalidate the signature"); } + // ============================================= + // Stateless email↔npub binding verification (issue #102) + // ============================================= + + /// Build Bob's signed reply nesting Alice's challenge unchanged, signing over + /// the §4.2 chain target (extract_ciphertext_binary ignores signature blocks, + /// so we can compute the target from a placeholder armor then substitute). + fn build_signed_reply(responder: &crate::types::KeyPair, challenge_armor: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let reply_b64 = general_purpose::STANDARD.encode(b"Confirmed - same terms."); + let template = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_b64, challenge_armor, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + fn issue_challenge(alice: &crate::types::KeyPair, bob_pub: &str, bob_email: &str) -> String { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: Some(bob_email.into()), + }]; + encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@issuer.example"), + "Alice", b"Please confirm you control this address.", &recips, false, + ).unwrap() + } + + #[test] + fn test_verify_email_binding_completed_handshake() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_hex = crate::agreement::normalize_pubkey_hex(&alice.public_key).unwrap(); + let bob_hex = crate::agreement::normalize_pubkey_hex(&bob.public_key).unwrap(); + + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + + let bindings = verify_email_binding(&reply, &alice.public_key); + assert_eq!(bindings.len(), 1, "exactly one binding proven"); + assert_eq!(bindings[0], crate::agreement::Binding { + pubkey: bob_hex, + email: "bob@example.com".into(), + issuer_pubkey: alice_hex, + }); + } + + #[test] + fn test_verify_email_binding_requires_a_reply() { + // A single inbound challenge is never a sufficient proof — no reply, no binding. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + assert!(verify_email_binding(&challenge, &alice.public_key).is_empty()); + } + + #[test] + fn test_verify_email_binding_tampered_pairing_voids_proof() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + + // Re-pair Bob's npub to an attacker mailbox inside the (signed) challenge. + let tampered = reply.replacen("bob@example.com", "attacker@evil.test", 1); + assert!(verify_email_binding(&tampered, &alice.public_key).is_empty(), + "a tampered (npub,email) pairing breaks the chain and yields no binding"); + } + + #[test] + fn test_verify_email_binding_wrong_issuer_perspective() { + // From Bob's perspective there is no level *he* signed asserting an email, + // so the same thread proves no binding for him (it's an issuer-side check). + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let challenge = issue_challenge(&alice, &bob.public_key, "bob@example.com"); + let reply = build_signed_reply(&bob, &challenge); + assert!(verify_email_binding(&reply, &bob.public_key).is_empty()); + } + // ============================================= // parse_armor_components tests // ============================================= From b9d32450ed6008f5e20052be75b55719a3681b83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 17:04:25 +0000 Subject: [PATCH 07/44] =?UTF-8?q?feat(agreement):=20thread-wide=20completi?= =?UTF-8?q?on=20status=20(spec=20=C2=A711.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_agreement_status(thread) computes "M of N signed" offline from a self-contained thread: * the innermost (originating) level fixes H = SHA-256(decode(body₁) || canonical(recipients₁)) (§11.3.1) and the required signatory set — its `signer` stanzas plus the originator iff that level itself carries a CONSENT block (§11.2) * a signatory counts only via a CONSENT over this H whose `signer` equals that level's *verified* SIGNATURE pubkey; a signed comment without consent, or a consent over the wrong H / bound by an invalid signature, never counts * returns None when the thread isn't an agreement (no originating RECIPIENTS) * collect_thread_levels — depth-keyed body/recipients/consent walk aligned with verify_all_signatures_inline * AgreementStatus gains serde + a document_hash field (set here; empty for the bare compute_completion primitive) Tests: all-consent ⇒ 2 of 2 complete; a comment reply ⇒ 1 of 2; a consent over the wrong H ⇒ not counted; a non-agreement ⇒ None. Full lib suite: 242 passed. --- tauri-app/backend/src/agreement.rs | 9 +- tauri-app/backend/src/email.rs | 197 +++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index 4ed63d1..dd7ab9d 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -256,7 +256,8 @@ pub fn level_signing_bytes( } /// Agreement completion summary (spec Section 11.5). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AgreementStatus { /// Number of required signatories with a verified consent over `H`. pub m: usize, @@ -268,6 +269,11 @@ pub struct AgreementStatus { pub required_signers: Vec, /// The subset of required signatories that have consented, normalized to hex. pub consented_signers: Vec, + /// The agreement's document hash `H` (hex), set when computed over a thread + /// (Section 11.3.1). Empty for the bare [`compute_completion`] primitive, + /// which is given the signatory sets directly and does not see the document. + #[serde(default)] + pub document_hash: String, } /// A proven email↔npub binding (issue #102): the `pubkey` is demonstrably @@ -321,6 +327,7 @@ pub fn compute_completion(required: &[String], consented: &[String]) -> Agreemen complete: n > 0 && m == n, required_signers, consented_signers, + document_hash: String::new(), } } diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 749bff2..58d242a 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -3951,6 +3951,111 @@ pub fn verify_email_binding(thread: &str, my_pubkey: &str) -> Vec, + recipients: Vec, + consent: Option, +} + +/// Recursively collect each level's body / RECIPIENTS / CONSENT (0 = outermost), +/// using the same `split_armor_level` walk as signing so depths line up with +/// [`verify_all_signatures_inline`]. +fn collect_thread_levels(armor: &str, depth: usize, out: &mut Vec) { + if let Some(parts) = split_armor_level(armor) { + out.push(ThreadLevel { + depth, + body_text: parts.body_text.clone(), + recipients: parts.recipients_text.as_deref() + .map(crate::agreement::parse_recipients_block) + .unwrap_or_default(), + recipients_text: parts.recipients_text.clone(), + consent: parts.consent_text.as_deref() + .and_then(crate::agreement::parse_consent_block), + }); + if let Some(nested) = parts.nested_armor { + collect_thread_levels(&nested, depth + 1, out); + } + } +} + +/// Compute an agreement's "M of N signed" status from a self-contained thread +/// (spec Section 11.5), offline and server-free. +/// +/// Returns `None` when the thread is not an agreement (its originating level has +/// no RECIPIENTS block). Otherwise: +/// * the **originating** (innermost) level fixes the document hash +/// `H = SHA-256(decode(body₁) || canonical(recipients₁))` (Section 11.3.1) +/// and the required signatory set — its `signer` stanzas, plus the +/// originator iff the originating level itself carries a CONSENT block +/// (Section 11.2); +/// * a signatory counts as consented only via a CONSENT block over this `H` +/// whose `signer` equals that level's **verified** SIGNATURE pubkey +/// (Sections 11.3, 11.5) — a signed comment without consent never counts. +pub fn verify_agreement_status(thread: &str) -> Option { + let mut levels: Vec = Vec::new(); + collect_thread_levels(thread, 0, &mut levels); + + // The originating message is the innermost (deepest) level. + let orig = levels.iter().max_by_key(|l| l.depth)?; + let orig_recipients_text = orig.recipients_text.as_deref()?; // not an agreement otherwise + + // Per-level verified signer pubkey (hex), keyed by depth. + let sigs = verify_all_signatures_inline(thread); + let signer_at = |depth: usize| -> Option<(String, bool)> { + sigs.iter().find(|s| s.depth == depth).and_then(|s| { + s.pubkey_hex.as_deref() + .and_then(crate::agreement::normalize_pubkey_hex) + .map(|h| (h, s.is_valid)) + }) + }; + + // H over the originating body + canonicalized recipients (excludes consent). + let body_bytes = decode_armor_section(&orig.body_text)?; + let canon_recipients = crate::agreement::canonicalize_block(orig_recipients_text); + let h = hex::encode(crate::agreement::document_hash(&body_bytes, &canon_recipients)); + + // Required signatories: originating `signer` stanzas (+ originator if the + // originating level itself consented). + let mut required: Vec = orig.recipients.iter() + .filter(|r| r.is_signer()) + .map(|r| r.pubkey.clone()) + .collect(); + if orig.consent.is_some() { + if let Some((orig_signer, _valid)) = signer_at(orig.depth) { + required.push(orig_signer); + } + } + + // Consents: a CONSENT over H, whose signer matches that level's *verified* + // signature pubkey. + let mut consented: Vec = Vec::new(); + for level in &levels { + let consent = match &level.consent { Some(c) => c, None => continue }; + if !consent.agreement_hash.eq_ignore_ascii_case(&h) { + continue; + } + let (signer_hex, valid) = match signer_at(level.depth) { + Some(s) => s, + None => continue, + }; + if !valid { + continue; // consent bound by an invalid signature does not count + } + if crate::agreement::normalize_pubkey_hex(&consent.signer).as_deref() == Some(signer_hex.as_str()) { + consented.push(signer_hex); + } + } + + let mut status = crate::agreement::compute_completion(&required, &consented); + status.document_hash = h; + Some(status) +} + /// Extract message ID from email headers pub fn extract_message_id_from_headers(raw_headers: &str) -> Option { // Try multiple patterns to find Message-ID @@ -5333,6 +5438,98 @@ nitela\n\ assert!(verify_email_binding(&reply, &bob.public_key).is_empty()); } + // ============================================= + // Agreement completion status (spec §11.5) + // ============================================= + + /// Alice originates an agreement she's also a signatory of (she consents), + /// with Bob as the other required signer. + fn originate_agreement(alice: &crate::types::KeyPair, bob_pub: &str) -> String { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: None, + }]; + encode_hybrid_agreement( + &alice.private_key, &alice.public_key, None, "Alice", + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, + ).unwrap() + } + + /// Read the document hash H out of a message's CONSENT block. + fn hash_from_consent(armor: &str) -> String { + let body: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR CONSENT")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR")).collect::>().join("\n"); + crate::agreement::parse_consent_block(&body).unwrap().agreement_hash + } + + /// Build a consenting reply: nests the prior message, adds the responder's + /// CONSENT over H, signed over the §4.2 chain target. + fn build_consent_reply(responder: &crate::types::KeyPair, prior_armor: &str, h: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let reply_b64 = general_purpose::STANDARD.encode(b"I agree to the terms."); + let template = format!( + "----- BEGIN NOSTR ENCRYPTED BODY -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\nagreement {}\nsigner {}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_b64, h, responder_hex, prior_armor, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + #[test] + fn test_agreement_status_complete_when_all_consent() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let h = hash_from_consent(&agreement); + let reply = build_consent_reply(&bob, &agreement, &h); + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (2, 2), "originator + signer both consented"); + assert!(status.complete); + assert_eq!(status.document_hash, h); + } + + #[test] + fn test_agreement_status_comment_does_not_count() { + // Bob replies without a CONSENT block (a comment): only Alice has consented. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let reply = build_signed_reply(&bob, &agreement); // no consent block + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2), "comment without consent is not counted"); + assert!(!status.complete); + } + + #[test] + fn test_agreement_status_tampered_consent_hash_not_counted() { + // Bob consents to the wrong document hash → his consent must not count. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_agreement(&alice, &bob.public_key); + let wrong_h = "00".repeat(32); + let reply = build_consent_reply(&bob, &agreement, &wrong_h); + + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2), "consent over the wrong H is ignored"); + assert!(!status.complete); + } + + #[test] + fn test_agreement_status_none_for_non_agreement() { + // A plain pairwise message (no RECIPIENTS block) is not an agreement. + let body = "----- BEGIN NOSTR NIP-44 ENCRYPTED BODY -----\n\ + SGVsbG8gV29ybGQ=\n\ + ----- END NOSTR MESSAGE -----"; + assert!(verify_agreement_status(body).is_none()); + } + // ============================================= // parse_armor_components tests // ============================================= From 818019e439fa6b89b835e33dd03020e8f4018a35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 17:09:54 +0000 Subject: [PATCH 08/44] feat(email): decrypt multi-recipient envelopes + expose recipients/consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b read path: incoming group-encrypted messages now decrypt, and the parsed struct surfaces recipients/consent for the frontend. * ParsedArmorMessage gains recipients / recipients_text / consent, populated by attach_agreement_blocks (a split_armor_level walk in lock-step with the quoted chain) — outside capnp, like prefix_text/quoted_armor_text. Recipient and Consent gain serde derives. * decrypt_single_block takes the level's RECIPIENTS and, when present, takes the envelope path (spec §10, §8 step 8): locate the reader's stanza, NIP-44-unwrap the CEK against the sender's pubkey (SIGNATURE/SEAL/fallback), then AES-256-GCM-decrypt the body. Path selection is by RECIPIENTS presence, not a body tag. Self stanza handles the Sent copy (self-DH). * A reader with no matching stanza gets a clear "not a recipient" error (expected for levels predating their addition to the thread, §10.8). Tests (full pipeline): To-signer, Cc-viewer, and the sender's self copy all recover the exact body; a non-recipient is denied; parse_armor_components exposes recipients + originator consent. Full lib suite: 245 passed. --- tauri-app/backend/src/agreement.rs | 6 +- tauri-app/backend/src/email.rs | 172 +++++++++++++++++++++++++++++ tauri-app/backend/src/types.rs | 12 ++ 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index dd7ab9d..f4bb97f 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -32,7 +32,8 @@ pub const ROLE_SELF: &str = "self"; /// A single entry in a `RECIPIENTS` block: ` []` /// (spec Section 10.2). `pubkey` and `wrapped_cek` are retained exactly as they /// appear on the wire (hex/npub for the key, base64 NIP-44 payload for the CEK). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Recipient { /// `signer` | `viewer` | `self`, or an unknown future token (lowercased). pub role: String, @@ -66,7 +67,8 @@ impl Recipient { /// A `CONSENT` block: a signatory's binding consent to document `H` /// (spec Section 11.3). The block carries no signature of its own — the level's /// existing SIGNATURE binds it. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Consent { /// The document hash `H` being consented to, hex (64 chars). pub agreement_hash: String, diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 58d242a..4189c85 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -2157,6 +2157,11 @@ pub fn parse_armor_components(armor_text: &str) -> Option Option (crate::types::DecryptedBlock, Option) { use base64::Engine; @@ -3060,6 +3091,63 @@ fn decrypt_single_block( return (block, None); } + // Multi-recipient envelope path (spec §10): selected by the presence of a + // RECIPIENTS block, not a special body tag. Find the reader's stanza, + // NIP-44-unwrap the per-message CEK against the sender's pubkey, then + // AES-256-GCM-decrypt the body. + if !recipients.is_empty() { + let me = crate::agreement::normalize_pubkey_hex(user_pubkey_hex) + .unwrap_or_else(|| user_pubkey_hex.to_string()); + // The sender (who wrapped the CEK) is the message author: SIGNATURE, + // then SEAL, then the fallback (other-party) pubkey. + let sender_pub = match sig_pubkey_hex.or(seal_pubkey_hex) + .map(|s| s.to_string()) + .or_else(|| (!fallback_pubkey.is_empty()).then(|| fallback_pubkey.to_string())) + { + Some(p) => p, + None => { + block.error = Some("Envelope message has no sender pubkey for CEK unwrap".to_string()); + return (block, None); + } + }; + let stanza = recipients.iter().find(|r| { + crate::agreement::normalize_pubkey_hex(&r.pubkey).as_deref() == Some(me.as_str()) + }); + let stanza = match stanza { + Some(s) => s, + None => { + // Expected when reading a level predating the reader's addition + // to the thread (spec §10.8). + block.error = Some("Not a recipient of this message level (no matching RECIPIENTS stanza)".to_string()); + return (block, None); + } + }; + let cek = match crate::crypto::unwrap_cek(private_key, &sender_pub, &stanza.wrapped_cek) { + Ok(c) => c, + Err(e) => { + block.error = Some(format!("Envelope CEK unwrap failed: {}", e)); + return (block, None); + } + }; + let ciphertext = match decode_armor_section(body_text) { + Some(b) => b, + None => { + block.error = Some("Envelope body decode failed".to_string()); + return (block, None); + } + }; + match crate::crypto::aes_gcm_decrypt_raw(&cek, &ciphertext) { + Ok(pt) => { + debug_log!("[RUST] decrypt_single_block: envelope decrypt SUCCESS, {} bytes", pt.len()); + block.decrypted_text = Some(String::from_utf8_lossy(&pt).into_owned()); + } + Err(e) => { + block.error = Some(format!("Envelope AES-GCM decrypt failed: {}", e)); + } + } + return (block, None); + } + let nip = encryption_nip.unwrap_or("nip44"); // Step 1: Glossia-decode body text → ciphertext string @@ -3361,6 +3449,7 @@ fn decrypt_armor_tree( private_key, user_pubkey_hex, fallback_pubkey, + &parsed.recipients, ); let block_ms = perf_block.elapsed().as_millis(); if manifest.is_some() { @@ -5530,6 +5619,88 @@ nitela\n\ assert!(verify_agreement_status(body).is_none()); } + // ============================================= + // Multi-recipient envelope decryption (spec §10, §8 step 8) + // ============================================= + + #[test] + fn test_envelope_decrypts_for_recipient_and_self() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To: signer + let carol = crypto::generate_keypair().unwrap(); // Cc: viewer + let plaintext = "This Mutual NDA is entered into as of 2026-06-13."; + + let recips = vec![ + AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()) }, + AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: carol.public_key.clone(), email: Some("carol@example.org".into()) }, + ]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@issuer.example"), + "Alice", plaintext.as_bytes(), &recips, false, + ).unwrap(); + + // Bob (signer) recovers the body. + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "recipient decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + + // Carol (viewer) also recovers the body — role is workflow, not access. + let r = decrypt_email_body_pipeline( + &carol.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "viewer decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + + // Alice's own Sent copy decrypts via her self stanza. + let r = decrypt_email_body_pipeline( + &alice.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success, "self decrypt failed: {:?}", r.error); + assert_eq!(r.body, plaintext); + } + + #[test] + fn test_envelope_non_recipient_cannot_decrypt() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let mallory = crypto::generate_keypair().unwrap(); // not a recipient + + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: None, + }]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, None, "Alice", b"secret terms", &recips, false, + ).unwrap(); + + let r = decrypt_email_body_pipeline( + &mallory.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(!r.success, "a non-recipient must not decrypt the envelope"); + } + + #[test] + fn test_parse_armor_components_exposes_recipients_and_consent() { + use crate::agreement::{encode_hybrid_agreement, AgreementRecipientInput, ROLE_SIGNER}; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()), + }]; + let armor = encode_hybrid_agreement( + &alice.private_key, &alice.public_key, Some("alice@x.example"), "Alice", b"terms", &recips, true, + ).unwrap(); + + let parsed = parse_armor_components(&armor).expect("parses"); + // Recipients (incl. self) and the originator's consent are surfaced on the struct. + assert_eq!(parsed.recipients.len(), 2); + assert!(parsed.recipients.iter().any(|r| r.is_signer() && r.email.as_deref() == Some("bob@example.com"))); + assert!(parsed.consent.is_some(), "originator consent surfaced"); + } + // ============================================= // parse_armor_components tests // ============================================= @@ -5897,6 +6068,7 @@ nitela\n\ "plain", None, None, None, None, "nsec1fake", "aabb", "", + &[], ); assert!(!block.was_encrypted); assert_eq!(block.decrypted_text.as_deref(), Some("Hello world")); diff --git a/tauri-app/backend/src/types.rs b/tauri-app/backend/src/types.rs index c275e33..ae301eb 100644 --- a/tauri-app/backend/src/types.rs +++ b/tauri-app/backend/src/types.rs @@ -274,6 +274,18 @@ pub struct ParsedArmorMessage { pub quoted_armor_text: Option, /// Decoded body bytes as base64 (for signature verification without re-decoding) pub body_bytes_b64: Option, + /// Parsed RECIPIENTS block entries for this level (spec §10.2). Non-empty + /// marks the multi-recipient envelope (CEK) decryption path. Populated + /// outside the capnp schema, like `prefix_text`/`quoted_armor_text`. + #[serde(default)] + pub recipients: Vec, + /// Raw RECIPIENTS block body, retained verbatim so the canonical form (and + /// thus the document hash `H`) can be recomputed (spec §4.2, §11.3.1). + #[serde(default)] + pub recipients_text: Option, + /// Parsed CONSENT block for this level, if any (spec §11.3). + #[serde(default)] + pub consent: Option, } /// Per-signature verification result (one per nesting level in the armor chain). From 8a0a36ca1b90343dde96a6ea4a1587ed30224d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:32:29 +0000 Subject: [PATCH 09/44] =?UTF-8?q?feat:=20plaintext=20(public)=20agreements?= =?UTF-8?q?=20(spec=20=C2=A711.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transparent, unencrypted agreements: terms readable by anyone, completion verifiable offline by third parties (not just recipients). Open letters, public multi-party statements, on-the-record contracts. The consent / H / §4.2 signing / completion / binding machinery already operates on decoded bytes, so it works over a SIGNED BODY unchanged — the only additions are a CEK-free stanza convention and a plaintext encoder: * agreement::NO_CEK ("-") — sentinel for a stanza with no key wrap; the signatory's (role, pubkey[, email]) is still authenticated by the signature * email::encode_signed_agreement — terms in a glossia SIGNED BODY (plus the plaintext above the armor, §3.2), RECIPIENTS with "-" ceks, no self stanza, optional originator CONSENT; signs the §4.2 target * envelope decrypt rejects an ENCRYPTED BODY paired with a "-" stanza * spec §11.8 + §10.2 sentinel + §11.2 note Tests: a public agreement is readable above the armor, uses SIGNED BODY, signs and verifies, exposes "-" signatory stanzas, tracks 1-of-2 then completes to 2-of-2 after a plaintext counter-signature, and breaks verification when the signed terms are mutated. Full lib suite: 248 passed. --- docs/nostr-mail-spec.md | 18 ++- tauri-app/backend/src/agreement.rs | 7 + tauri-app/backend/src/email.rs | 202 +++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 97908ad..befd8ff 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -579,7 +579,7 @@ The RECIPIENTS block contains one entry per line. Each entry is three or four sp |-------|----------|-------| | `role` | `signer` \| `viewer` \| `self` (lowercase token) | See Section 11.1. Unknown roles MUST be ignored for workflow purposes but still treated as recipients for decryption. | | `pubkey` | hex (64 chars) or npub (bech32, `npub1…`) | The recipient's Nostr public key. Glossia is **not** used here, to keep entries single-token and line-parseable. | -| `wrapped-cek` | base64 (NIP-44 payload) | `NIP44_encrypt(CEK)` to `pubkey`. Glossia is not used here. | +| `wrapped-cek` | base64 (NIP-44 payload), or `-` | `NIP44_encrypt(CEK)` to `pubkey`. Glossia is not used here. The sentinel `-` means **no key wrap** — used by plaintext (public) agreements (Section 11.8), whose `SIGNED BODY` is not encrypted; decoders MUST NOT attempt to unwrap a `-`. | | `email` (optional) | RFC 5322 addr-spec, no display name | The address this stanza was delivered to. Binds the recipient's `(pubkey, email)` pairing **inside** the signed block (Section 10.6), so it cannot be altered or re-paired without breaking the signature — the prerequisite for the email↔npub binding handshake (issue #102). Omitted when the recipient is known only by pubkey. | Rules: @@ -646,7 +646,7 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: -- **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword). +- **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword) — or, for a **plaintext (public) agreement**, a signed `SIGNED BODY` with the terms in the clear (Section 11.8). - **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. @@ -728,6 +728,20 @@ This agreement model is conceptually aligned with [SIGit](https://sigit.io)'s do The deliberate difference is transport and privacy. SIGit hides the participant set by gift-wrapping (NIP-59) metadata to ephemeral keys and storing encrypted files on Blossom; nostr-mail keeps the whole agreement in one email thread, which is more self-contained and offline-verifiable but exposes the participant set in the cleartext RECIPIENTS block (Section 10.9). A future **Nostr-only agreement transport** is planned that folds in SIGit's gift-wrap/Blossom approach for stronger metadata privacy; because the consent semantics here (`H`, per-signatory CONSENT, signature chaining) are transport-independent, that mode is expected to reuse this section's model with a different envelope. SIGit's own event-kind numbering is **not** adopted; nostr-mail consent lives in armor blocks, not relay-published Nostr events. +### 11.8 Plaintext (Public) Agreements + +An agreement need not be confidential. A **plaintext (public) agreement** carries its terms in the clear so that anyone — not only a cryptographic recipient — can read them and verify completion offline. This suits open letters, public multi-party statements, and on-the-record contracts, where transparency is the point. + +A plaintext agreement is identical to the encrypted agreement (Sections 10–11) except for the body and the key-wrap: + +- **Body**: a signed `SIGNED BODY` (Section 3.2) instead of an `ENCRYPTED BODY`. The terms are glossia-encoded inside the armor (the signed payload) and also appear above the armor for non-Nostr-Mail clients. There is no CEK and nothing is encrypted. +- **RECIPIENTS**: the same block declares the signatories (`signer`) and viewers (`viewer`), but each stanza's `wrapped-cek` token is the sentinel `-` (Section 10.2), since there is no CEK to wrap. The optional `email` token still binds the `(pubkey, email)` pairing (Sections 10.2, 6.3). There is **no `self` stanza** — nothing is encrypted, so the originator needs no self-wrap; the originator is identified by the SIGNATURE. +- **CONSENT**, **document hash `H`**, **signature coverage (§4.2)**, and **completion (§11.5)** are unchanged. `H = SHA-256(decode(body₁) || canonical(recipients₁))` is computed over the *decoded* (glossia → plaintext) body bytes, so the same machinery applies regardless of cipher. A counter-signature is a `SIGNED BODY` reply that nests the prior message and adds the signatory's CONSENT (Section 11.4). + +Path selection is unambiguous: a `SIGNED BODY` is never decrypted (it has no ciphertext), so the `-` sentinel is never unwrapped. A decoder MUST treat an `ENCRYPTED BODY` whose stanza carries `-` as malformed. + +The trade-off versus the encrypted agreement is **confidentiality**: the terms and the participant set (pubkeys, and any `email` tokens) are public to anyone who sees the message. In return, the agreement is readable and its completion is verifiable by third parties who are not signatories — the same offline, self-contained verification of Section 11.6, without needing to be a recipient. + ## 12. Versioning This specification may be extended with additional block types in future versions. Decoders SHOULD ignore unrecognized block types rather than failing. Unknown `role` tokens in a RECIPIENTS block (Section 10.2) MUST be treated as recipients for decryption while being ignored for workflow accounting, so that future roles do not break older clients. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index f4bb97f..ef946f1 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -29,6 +29,13 @@ pub const ROLE_SIGNER: &str = "signer"; pub const ROLE_VIEWER: &str = "viewer"; pub const ROLE_SELF: &str = "self"; +/// Sentinel for the `wrapped-cek` token of a **plaintext** (public) agreement's +/// RECIPIENTS stanza (spec Section 11.8): the body is a signed `SIGNED BODY`, not +/// encrypted, so there is no CEK to wrap. The stanza still authenticates the +/// signatory's `(role, pubkey[, email])` under the signature; only the key-wrap +/// is absent. Decoders MUST NOT attempt to unwrap a `-` token. +pub const NO_CEK: &str = "-"; + /// A single entry in a `RECIPIENTS` block: ` []` /// (spec Section 10.2). `pubkey` and `wrapped_cek` are retained exactly as they /// appear on the wire (hex/npub for the key, base64 NIP-44 payload for the CEK). diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 4189c85..f8d292f 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -3122,6 +3122,12 @@ fn decrypt_single_block( return (block, None); } }; + if stanza.wrapped_cek == crate::agreement::NO_CEK { + // Plaintext (public) agreement stanza carries no CEK; an ENCRYPTED + // BODY paired with such a stanza is malformed (spec §11.8). + block.error = Some("RECIPIENTS stanza has no CEK ('-') but the body is encrypted".to_string()); + return (block, None); + } let cek = match crate::crypto::unwrap_cek(private_key, &sender_pub, &stanza.wrapped_cek) { Ok(c) => c, Err(e) => { @@ -3805,6 +3811,112 @@ pub fn extract_ciphertext_binary(body: &str) -> Vec { body.as_bytes().to_vec() } +/// Glossia-encode plaintext for a `SIGNED BODY` (spec §3.2): returns the encoded +/// words and the canonical decoded bytes the signature is computed over (§4). +/// Uses the english/bip39 body dialect, matching the rest of the decode path. +fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { + let hex_input = glossia::hex_encode(text.as_bytes()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + glossia::encode_into_language( + &hex_input, "english", "bip39", "body", + None, 42, false, None, None, None, None, + ) + })); + let encoded = match result { + Ok(Ok((enc, _, _, _))) => enc, + _ => return None, + }; + let canonical = try_glossia_decode_to_bytes(&encoded)?; + Some((encoded, canonical)) +} + +/// Compose a **plaintext (public) agreement** — the unencrypted counterpart to +/// `agreement::encode_hybrid_agreement` (spec §11.8). The terms are carried in a +/// signed `SIGNED BODY` (readable by any client, with the plaintext also above +/// the armor per §3.2), and the RECIPIENTS block declares the signatories/viewers +/// with the `-` no-CEK sentinel (`agreement::NO_CEK`) since nothing is encrypted. +/// +/// The document hash `H`, consent binding, signature coverage (§4.2), and +/// completion accounting (§11.5) are identical to the encrypted case — they +/// operate on the decoded body bytes regardless of cipher — so the agreement is +/// verifiable offline by **anyone**, not just recipients. The trade-off vs the +/// encrypted form is confidentiality: the terms and participant set are public. +/// +/// `recipients_in` are the signatories (`signer`) and viewers (`viewer`); no +/// `self` stanza is emitted (there is nothing to decrypt). When +/// `originator_consents` is true the originator's CONSENT over `H` is included +/// (the originator is then a required signatory, §11.2). +pub fn encode_signed_agreement( + sender_priv: &str, + sender_pub: &str, + profile_name: &str, + terms_plaintext: &str, + recipients_in: &[crate::agreement::AgreementRecipientInput], + originator_consents: bool, +) -> Result { + use crate::agreement::{ + canonicalize_block, document_hash, level_signing_bytes, serialize_recipients, + Consent, Recipient, NO_CEK, + }; + if recipients_in.is_empty() { + return Err("encode_signed_agreement requires at least one signatory".to_string()); + } + let sender_pub_hex = crate::agreement::normalize_pubkey_hex(sender_pub) + .ok_or_else(|| "invalid sender pubkey".to_string())?; + + let (encoded_body, canonical_bytes) = glossia_encode_signed_body(terms_plaintext) + .ok_or_else(|| "glossia encode of agreement terms failed".to_string())?; + + // Signatories/viewers with no key wrap (plaintext): role pubkey - [email]. + let recipients: Vec = recipients_in.iter().map(|r| Recipient { + role: r.role.to_ascii_lowercase(), + pubkey: r.pubkey.clone(), + wrapped_cek: NO_CEK.to_string(), + email: r.email.clone(), + }).collect(); + let recipients_body = serialize_recipients(&recipients); + let canon_recipients = canonicalize_block(&recipients_body); + + let (consent_body, canon_consent) = if originator_consents { + let h = document_hash(&canonical_bytes, &canon_recipients); + let consent = Consent { agreement_hash: hex::encode(h), signer: sender_pub_hex.clone() }; + let body = consent.to_block_body(); + let canon = canonicalize_block(&body); + (Some(body), canon) + } else { + (None, String::new()) + }; + + let signing_bytes = level_signing_bytes(&canonical_bytes, &canon_recipients, &canon_consent); + let sig_hex = crate::crypto::sign_data_bytes(sender_priv, &signing_bytes) + .map_err(|e| e.to_string())?; + + let mut out = String::new(); + // Plaintext above the armor for non-Nostr-Mail clients (spec §3.2). + out.push_str(terms_plaintext); + out.push_str("\n\n"); + out.push_str("----- BEGIN NOSTR SIGNED BODY -----\n"); + out.push_str(&encoded_body); + out.push('\n'); + out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); + out.push_str(&recipients_body); + out.push('\n'); + if let Some(ref c) = consent_body { + out.push_str("----- BEGIN NOSTR CONSENT -----\n"); + out.push_str(c); + out.push('\n'); + } + out.push_str("----- BEGIN NOSTR SIGNATURE -----\n@"); + out.push_str(profile_name); + out.push('\n'); + out.push_str(&sig_hex); + out.push('\n'); + out.push_str(&sender_pub_hex); + out.push('\n'); + out.push_str("----- END NOSTR MESSAGE -----"); + Ok(out) +} + /// Verify email signature using binary ciphertext extraction. /// Extracts the binary payload from ASCII armor (or uses raw text bytes), /// then verifies the schnorr signature against SHA-256(binary). @@ -5701,6 +5813,96 @@ nitela\n\ assert!(parsed.consent.is_some(), "originator consent surfaced"); } + // ============================================= + // Plaintext (public) agreements (spec §11.8) + // ============================================= + + fn originate_plaintext_agreement(alice: &crate::types::KeyPair, bob_pub: &str, terms: &str) -> String { + use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER}; + let recips = vec![AgreementRecipientInput { + role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: None, + }]; + encode_signed_agreement(&alice.private_key, &alice.public_key, "Alice", terms, &recips, true).unwrap() + } + + fn build_plaintext_consent_reply(responder: &crate::types::KeyPair, prior_armor: &str, h: &str) -> String { + let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); + let (reply_glossia, _) = glossia_encode_signed_body("I agree to the terms.").unwrap(); + let prior_only = &prior_armor[prior_armor.find("----- BEGIN NOSTR").unwrap()..]; + let template = format!( + "I agree to the terms.\n\n----- BEGIN NOSTR SIGNED BODY -----\n{}\n\ + ----- BEGIN NOSTR CONSENT -----\nagreement {}\nsigner {}\n{}\n\ + ----- BEGIN NOSTR SIGNATURE -----\n@Responder\nSIGPLACEHOLDER\n{}\n\ + ----- END NOSTR MESSAGE -----", + reply_glossia, h, responder_hex, prior_only, responder_hex + ); + let bytes = extract_ciphertext_binary(&template); + let sig = crypto::sign_data_bytes(&responder.private_key, &bytes).unwrap(); + template.replace("SIGPLACEHOLDER", &sig) + } + + #[test] + fn test_plaintext_agreement_is_readable_signed_and_tracked() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let terms = "This public statement is agreed to by the undersigned, dated 2026-06-13."; + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, terms); + + // Terms are in the clear above the armor (readable by any client, §3.2). + assert!(agreement.starts_with(terms)); + // Public agreement uses a SIGNED BODY, not an ENCRYPTED BODY. + assert!(agreement.contains("BEGIN NOSTR SIGNED BODY")); + assert!(!agreement.contains("ENCRYPTED BODY")); + + // The originator's signature verifies over the §4.2 target. + assert_eq!(verify_email_signature_inline(&agreement), Some(true)); + + // Signatories are declared with the no-CEK sentinel. + let parsed = parse_armor_components(&agreement).expect("parses"); + assert_eq!(parsed.recipients.len(), 1); + assert_eq!(parsed.recipients[0].wrapped_cek, crate::agreement::NO_CEK); + assert!(parsed.recipients[0].is_signer()); + + // Completion is tracked: originator consented, the other signatory pending. + let status = verify_agreement_status(&agreement).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2)); + assert!(!status.complete); + } + + #[test] + fn test_plaintext_agreement_completes_with_countersignature() { + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let terms = "We, the undersigned, commit to the following terms."; + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, terms); + let h = hash_from_consent(&agreement); + let reply = build_plaintext_consent_reply(&bob, &agreement, &h); + + // The whole plaintext signature chain verifies and completion reaches 2 of 2. + let sigs = verify_all_signatures_inline(&reply); + assert!(sigs.iter().all(|s| s.is_valid), "plaintext reply chain must verify"); + let status = verify_agreement_status(&reply).expect("is an agreement"); + assert_eq!((status.m, status.n), (2, 2)); + assert!(status.complete); + assert_eq!(status.document_hash, h); + } + + #[test] + fn test_plaintext_agreement_terms_tamper_breaks_signature() { + // The signed payload is the glossia SIGNED BODY (not the human-readable + // preface). Mutating it must break the originator's signature. + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let agreement = originate_plaintext_agreement(&alice, &bob.public_key, "Pay 100 on delivery."); + assert_eq!(verify_email_signature_inline(&agreement), Some(true)); + + let parsed = parse_armor_components(&agreement).unwrap(); + let body = parsed.body_text; + let tampered = agreement.replacen(&body, &format!("{} extra", body), 1); + assert_ne!(tampered, agreement, "body must appear verbatim in the armor"); + assert_eq!(verify_email_signature_inline(&tampered), Some(false)); + } + // ============================================= // parse_armor_components tests // ============================================= From c36a9e681e01cb2f98a7915247e0daa4885f505f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 23:23:49 +0000 Subject: [PATCH 10/44] refactor: content-typed optional RECIPIENTS tokens + reserve gift-wrap reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed-position stanza (with the "-" no-CEK sentinel) with optional, content-typed tokens, and reserve a reference token for the metadata-private gift-wrap mode (spec §11.7.1). Stanza is now ` ` + any subset, in any order, classified by shape: * `@` → email (binding, #102) * `:` → reference (scheme:value, e.g. evt:; §11.7.1) * else → wrapped-cek (base64; never contains @ or :) Both cek and email are co-equal optional fields (cek decrypts, email completes binding validation) — neither is privileged. Absence means "not carried in the email": plaintext agreements omit the cek; gift-wrap mode carries only a reference (cek + binding travel in the referenced DM). Code: * Recipient.wrapped_cek: String → Option; add reference: Option; drop the NO_CEK sentinel * parse_recipients_block classifies trailing tokens by content (min is role+pubkey; bare/optional stanzas now valid) * to_line emits only present fields; encoders set Some/None accordingly * envelope decrypt: no CEK → clear error (distinguishes plaintext vs a gift-wrap reference whose DM delivery isn't wired yet) Spec: * §10.2 rewritten for content-typed optional tokens + the reference token * §11.7.1 "Gift-Wrap Mode": email keeps the signed pubkey/role set + reference; CEK and (npub,email) binding move to a NIP-59 gift-wrapped DM; documents the three privacy gradations and the self-containedness trade-off * §11.8 plaintext: omit the cek token (no sentinel) NIP-59 delivery + DM-aware decrypt/verify remain a later milestone. Full lib suite: 249 passed. --- docs/nostr-mail-spec.md | 51 ++++++++-- tauri-app/backend/src/agreement.rs | 156 +++++++++++++++++++---------- tauri-app/backend/src/email.rs | 38 ++++--- 3 files changed, 169 insertions(+), 76 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index befd8ff..355665d 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -569,25 +569,28 @@ The sender's pubkey (needed to unwrap the CEK) is taken from the SIGNATURE block ### 10.2 RECIPIENTS Block Format -The RECIPIENTS block contains one entry per line. Each entry is three or four space-separated tokens — the fourth (`email`) is optional: +The RECIPIENTS block contains one entry per line. Each entry begins with the two required tokens `role` and `pubkey`, followed by any subset of the **optional, content-typed** tokens below, in any order: ``` - [] + [] [] [] ``` | Field | Encoding | Notes | |-------|----------|-------| | `role` | `signer` \| `viewer` \| `self` (lowercase token) | See Section 11.1. Unknown roles MUST be ignored for workflow purposes but still treated as recipients for decryption. | | `pubkey` | hex (64 chars) or npub (bech32, `npub1…`) | The recipient's Nostr public key. Glossia is **not** used here, to keep entries single-token and line-parseable. | -| `wrapped-cek` | base64 (NIP-44 payload), or `-` | `NIP44_encrypt(CEK)` to `pubkey`. Glossia is not used here. The sentinel `-` means **no key wrap** — used by plaintext (public) agreements (Section 11.8), whose `SIGNED BODY` is not encrypted; decoders MUST NOT attempt to unwrap a `-`. | -| `email` (optional) | RFC 5322 addr-spec, no display name | The address this stanza was delivered to. Binds the recipient's `(pubkey, email)` pairing **inside** the signed block (Section 10.6), so it cannot be altered or re-paired without breaking the signature — the prerequisite for the email↔npub binding handshake (issue #102). Omitted when the recipient is known only by pubkey. | +| `wrapped-cek` (optional) | base64 (NIP-44 payload) | `NIP44_encrypt(CEK)` to `pubkey`. **Absent** when the CEK is not carried in the email: a plaintext (public) agreement has no CEK at all (Section 11.8), and gift-wrap mode delivers it in the referenced DM (Section 11.7). | +| `email` (optional) | RFC 5322 addr-spec, no display name | The address this stanza was delivered to. Binds the recipient's `(pubkey, email)` pairing **inside** the signed block (Section 10.6), so it cannot be altered or re-paired without breaking the signature — the prerequisite for the email↔npub binding handshake (issue #102). | +| `reference` (optional) | `scheme:value` | A pointer to out-of-band per-recipient material — e.g. `evt:` for a NIP-59 gift-wrapped DM carrying this recipient's CEK and/or `(npub, email)` binding (Section 11.7). | + +**Tokens are typed by content, not position.** A decoder classifies each token after `pubkey` by inspection: a token containing `@` is the `email`; a token containing `:` is a `reference`; any other token is the base64 `wrapped-cek` (which never contains `@` or `:`). This makes every field independently optional and order-independent, with no sentinel or positional ambiguity. The first token of each type wins. Rules: -- Entries SHOULD be ordered deterministically: `To:` recipients in header order, then `Cc:` recipients in header order, then the `self` stanza last. Deterministic ordering keeps the signed canonical form stable across encoders. +- Entries SHOULD be ordered deterministically: `To:` recipients in header order, then `Cc:` recipients in header order, then the `self` stanza last. Within a stanza, encoders SHOULD emit fields in `wrapped-cek`, `email`, `reference` order. Deterministic ordering keeps the signed canonical form stable across encoders. - Display names are intentionally **omitted**; clients resolve names from the Nostr social registry / profile cache by pubkey. -- The optional `email` is the **fourth** token and is placed after `wrapped-cek` (not between `pubkey` and `wrapped-cek`) so that a decoder which only knows the three-token grammar still recovers `role`/`pubkey`/`wrapped-cek` and can decrypt; it simply ignores the trailing token. An email contains an `@` and is therefore unambiguous against the base64 `wrapped-cek` (which never does). Email addresses contain no spaces, so the entry stays line-parseable. -- Decoders MUST tolerate additional trailing tokens on a line (forward compatibility) and MUST ignore blank lines and `> ` quote prefixes. +- A stanza with neither a `wrapped-cek` nor a `reference` is workflow-only (it declares a `pubkey` and `role` but carries no key material) — valid for a plaintext agreement or a participant known by pubkey alone. +- Decoders MUST tolerate additional unrecognized tokens on a line (forward compatibility) and MUST ignore blank lines and `> ` quote prefixes. Future positional extensions SHOULD use a distinguishable `key:value` or `key=value` shape so they are never mistaken for a bare `wrapped-cek`. ### 10.3 Roles @@ -726,7 +729,35 @@ This agreement model is conceptually aligned with [SIGit](https://sigit.io)'s do | Sign event (Kind 938) added to `docSignatures` | the CONSENT block bound by the level's signature (Section 11.3) | | Offline-verifiable from the encrypted zip | offline-verifiable from the email thread (Section 11.6) | -The deliberate difference is transport and privacy. SIGit hides the participant set by gift-wrapping (NIP-59) metadata to ephemeral keys and storing encrypted files on Blossom; nostr-mail keeps the whole agreement in one email thread, which is more self-contained and offline-verifiable but exposes the participant set in the cleartext RECIPIENTS block (Section 10.9). A future **Nostr-only agreement transport** is planned that folds in SIGit's gift-wrap/Blossom approach for stronger metadata privacy; because the consent semantics here (`H`, per-signatory CONSENT, signature chaining) are transport-independent, that mode is expected to reuse this section's model with a different envelope. SIGit's own event-kind numbering is **not** adopted; nostr-mail consent lives in armor blocks, not relay-published Nostr events. +The deliberate difference is transport and privacy. SIGit hides the participant set by gift-wrapping (NIP-59) metadata to ephemeral keys and storing encrypted files on Blossom; nostr-mail keeps the whole agreement in one email thread, which is more self-contained and offline-verifiable but exposes the participant set in the cleartext RECIPIENTS block (Section 10.9). The **gift-wrap mode** below folds in SIGit's gift-wrap approach for stronger metadata privacy; because the consent semantics here (`H`, per-signatory CONSENT, signature chaining) are transport-independent, that mode reuses this section's model with a different addressing channel. SIGit's own event-kind numbering is **not** adopted; nostr-mail consent lives in armor blocks, not relay-published Nostr events. + +#### 11.7.1 Gift-Wrap Mode (metadata-private delivery) + +> **Status:** designed; the wire format (the `reference` token, Section 10.2) is reserved now, but the NIP-59 delivery and DM-aware decrypt/verify paths are a later milestone. + +The cleartext RECIPIENTS block exposes two things to relays and mail servers that the participants may wish to keep private: the per-recipient **wrapped CEKs** and the **`(npub, email)` bindings**. Gift-wrap mode moves this sensitive per-recipient material off the email and into a **NIP-59 gift-wrapped DM**, one per recipient, leaving the email to carry the document plus a minimal, signed reference. + +**What moves, what stays.** The split is chosen so the *workflow* stays tamper-evident and offline-checkable while the *sensitive* material becomes private: + +| Stays in the signed email | Moves to the per-recipient gift wrap | +|---|---| +| the body (`ENCRYPTED BODY` ciphertext, or `SIGNED BODY`) | the recipient's `wrapped-cek` | +| each recipient's `role` + `pubkey` + a `reference` token | the recipient's `(npub, email)` binding | +| the originator's SIGNATURE (covers the above) | — | + +So a gift-wrap-mode stanza is `signer evt:` — role and pubkey remain in the signed block (so the required-signatory set, the document hash `H`, and completion accounting are still authenticated and computable from the email), while the CEK and the address binding travel in the referenced DM. + +**The reference.** The `reference` token (`evt:`, Section 10.2) points to the gift wrap addressed to that recipient. The gift wrap's inner *rumor* SHOULD carry: the agreement reference (the email `Message-ID` and/or the document hash `H`), this recipient's `wrapped-cek` (if the body is encrypted), and the `(npub, email)` binding. The gift wrap is sealed and wrapped per NIP-59 (ephemeral outer key), so relays learn neither the sender nor the recipient. + +**Verification with the DM in hand.** A recipient: (1) verifies the email's signature chain and reads the signed `role`/`pubkey` set and `H` as usual (Section 11.6); (2) unwraps their gift wrap, confirms it references this `H`/`Message-ID`, and takes the CEK (to decrypt) and the `(npub, email)` binding; (3) completion is computed exactly as in Section 11.5 — the consents still live in the (email or DM) CONSENT blocks bound by signatures over `H`. + +**Privacy gradations.** This is the middle of three points on the privacy/self-containedness curve: + +1. **Cleartext (Sections 10–11):** everything in the email thread; fully self-contained and offline-verifiable by anyone; participant set, CEKs, and bindings are public. +2. **Gift-wrap mode (this section):** CEKs and `(npub, email)` bindings are private; the **pubkey + role set is still public** in the signed email (so the document and signatory set remain tamper-evident and the agreement is still anchored in one email). Decryption and binding-verification now also require the recipient's DM. +3. **Full metadata-private (future):** omit the RECIPIENTS block entirely; all addressing — including pubkeys — lives in gift wraps, and readers trial-decrypt (as the `age` format does). Maximum privacy, least self-contained; out of scope here. + +**Trade-off.** Gift-wrap mode trades the Section 11.6 property — *a third party can verify completion offline from the email alone* — for metadata privacy: a participant now needs their gift-wrapped DM (and thus a relay) to obtain the CEK and binding. The email still anchors the document, the signed signatory set, and `H`, so tampering is still detectable from the email; only the private openings require the DM. `Bcc:`-style blind participants are naturally expressible here, since a gift wrap to a recipient need not appear in any other recipient's view. ### 11.8 Plaintext (Public) Agreements @@ -735,10 +766,10 @@ An agreement need not be confidential. A **plaintext (public) agreement** carrie A plaintext agreement is identical to the encrypted agreement (Sections 10–11) except for the body and the key-wrap: - **Body**: a signed `SIGNED BODY` (Section 3.2) instead of an `ENCRYPTED BODY`. The terms are glossia-encoded inside the armor (the signed payload) and also appear above the armor for non-Nostr-Mail clients. There is no CEK and nothing is encrypted. -- **RECIPIENTS**: the same block declares the signatories (`signer`) and viewers (`viewer`), but each stanza's `wrapped-cek` token is the sentinel `-` (Section 10.2), since there is no CEK to wrap. The optional `email` token still binds the `(pubkey, email)` pairing (Sections 10.2, 6.3). There is **no `self` stanza** — nothing is encrypted, so the originator needs no self-wrap; the originator is identified by the SIGNATURE. +- **RECIPIENTS**: the same block declares the signatories (`signer`) and viewers (`viewer`), but each stanza simply **omits the `wrapped-cek` token** (Section 10.2), since there is no CEK to wrap — a stanza is then `signer []`. The optional `email` token still binds the `(pubkey, email)` pairing (Sections 10.2, 6.3). There is **no `self` stanza** — nothing is encrypted, so the originator needs no self-wrap; the originator is identified by the SIGNATURE. - **CONSENT**, **document hash `H`**, **signature coverage (§4.2)**, and **completion (§11.5)** are unchanged. `H = SHA-256(decode(body₁) || canonical(recipients₁))` is computed over the *decoded* (glossia → plaintext) body bytes, so the same machinery applies regardless of cipher. A counter-signature is a `SIGNED BODY` reply that nests the prior message and adds the signatory's CONSENT (Section 11.4). -Path selection is unambiguous: a `SIGNED BODY` is never decrypted (it has no ciphertext), so the `-` sentinel is never unwrapped. A decoder MUST treat an `ENCRYPTED BODY` whose stanza carries `-` as malformed. +Path selection is unambiguous: a `SIGNED BODY` is never decrypted (it has no ciphertext), so the absent CEK is never an issue. A decoder MUST treat an `ENCRYPTED BODY` whose stanza carries no `wrapped-cek` (and no `reference`) as malformed. The trade-off versus the encrypted agreement is **confidentiality**: the terms and the participant set (pubkeys, and any `email` tokens) are public to anyone who sees the message. In return, the agreement is readable and its completion is verifiable by third parties who are not signatories — the same offline, self-contained verification of Section 11.6, without needing to be a recipient. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index ef946f1..298c03b 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -29,16 +29,18 @@ pub const ROLE_SIGNER: &str = "signer"; pub const ROLE_VIEWER: &str = "viewer"; pub const ROLE_SELF: &str = "self"; -/// Sentinel for the `wrapped-cek` token of a **plaintext** (public) agreement's -/// RECIPIENTS stanza (spec Section 11.8): the body is a signed `SIGNED BODY`, not -/// encrypted, so there is no CEK to wrap. The stanza still authenticates the -/// signatory's `(role, pubkey[, email])` under the signature; only the key-wrap -/// is absent. Decoders MUST NOT attempt to unwrap a `-` token. -pub const NO_CEK: &str = "-"; - -/// A single entry in a `RECIPIENTS` block: ` []` -/// (spec Section 10.2). `pubkey` and `wrapped_cek` are retained exactly as they -/// appear on the wire (hex/npub for the key, base64 NIP-44 payload for the CEK). +/// A single entry in a `RECIPIENTS` block (spec Section 10.2). After the fixed +/// `role` and `pubkey`, the remaining tokens are **optional and typed by +/// content**, in any order: +/// +/// * `wrapped-cek` — base64 (no `@`, no `:`): `NIP44_encrypt(CEK)` to `pubkey` +/// * `email` — contains `@`: the address this stanza was delivered to +/// * `reference` — `scheme:value` (contains `:`): pointer to out-of-band +/// material, e.g. a NIP-59 gift-wrapped DM (`evt:`) +/// +/// Any field may be absent: a plaintext (public) agreement omits the cek; a +/// gift-wrap-mode stanza carries only a `reference` (cek + email travel in the +/// referenced DM); a bare `role pubkey` stanza is workflow-only. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Recipient { @@ -46,23 +48,39 @@ pub struct Recipient { pub role: String, /// Recipient's Nostr public key, hex (64 chars) or npub (bech32). pub pubkey: String, - /// `NIP44_encrypt(CEK)` to `pubkey`, base64. - pub wrapped_cek: String, - /// Optional fourth token: the address this stanza was delivered to. Because - /// the signature covers the canonicalized RECIPIENTS block (Section 10.6), - /// an in-block email binds the `(pubkey, email)` pairing tamper-evidently — - /// the basis for the email↔npub binding handshake (issue #102). + /// `NIP44_encrypt(CEK)` to `pubkey` (base64). `None` when the CEK is not + /// carried in the email — a plaintext agreement (no CEK) or gift-wrap mode + /// (the CEK travels in the referenced DM; see `reference`). + pub wrapped_cek: Option, + /// The address this stanza was delivered to (contains `@`). Authenticated by + /// the signature (§10.6), binding `(pubkey, email)` for the handshake (#102). + /// `None` when not carried in the email (gift-wrap mode delivers it in the DM). pub email: Option, + /// A `scheme:value` pointer to out-of-band per-recipient material — e.g. a + /// NIP-59 gift-wrapped DM (`evt:`) carrying this recipient's CEK and/or + /// `(npub, email)` binding (spec §11.7 gift-wrap mode). `None` when all + /// material is in the email. + pub reference: Option, } impl Recipient { - /// Serialize this entry as its canonical line: `role pubkey wrapped-cek [email]`. - /// The `email` is appended as the trailing token only when present. + /// Serialize as its canonical line: `role pubkey [cek] [email] [reference]`, + /// emitting only the present fields. pub fn to_line(&self) -> String { - match &self.email { - Some(email) => format!("{} {} {} {}", self.role, self.pubkey, self.wrapped_cek, email), - None => format!("{} {} {}", self.role, self.pubkey, self.wrapped_cek), + let mut s = format!("{} {}", self.role, self.pubkey); + if let Some(cek) = &self.wrapped_cek { + s.push(' '); + s.push_str(cek); + } + if let Some(email) = &self.email { + s.push(' '); + s.push_str(email); } + if let Some(reference) = &self.reference { + s.push(' '); + s.push_str(reference); + } + s } /// True for the `signer` role (a required signatory; spec Section 11.1). @@ -126,13 +144,14 @@ pub fn canonicalize_block(block_body: &str) -> String { /// Parse a `RECIPIENTS` block body (the lines between `BEGIN NOSTR RECIPIENTS` /// and the following delimiter) into entries (spec Section 10.2). /// -/// Each non-blank line must have at least three space-separated tokens -/// ` `, with an optional fourth `email` token; any -/// further trailing tokens are tolerated (forward compatibility) and ignored. -/// Blank lines and `> ` quote prefixes are ignored. Malformed lines (fewer than -/// 3 tokens) are skipped. The fourth token is treated as the email only if it -/// contains `@` — which a base64 `wrapped-cek` never does — so a non-email -/// trailing token is not misread (spec Section 10.2). +/// Each non-blank line is ` ` followed by any subset of the typed +/// optional tokens, in any order (spec Section 10.2): an `email` (contains `@`), +/// a `reference` (`scheme:value`, contains `:`), and a `wrapped-cek` (base64 — +/// neither `@` nor `:`). Classification is by content, so the cek is never +/// confused with an email or a reference. The first token of each type wins; +/// further unrecognized tokens are tolerated (forward compatibility) and ignored. +/// Blank lines and `> ` quote prefixes are ignored. Lines without a `pubkey` +/// (fewer than 2 tokens) are skipped. pub fn parse_recipients_block(block_body: &str) -> Vec { let mut out = Vec::new(); for raw in block_body.split('\n') { @@ -149,12 +168,19 @@ pub fn parse_recipients_block(block_body: &str) -> Vec { Some(t) => t.to_string(), None => continue, }; - let wrapped_cek = match toks.next() { - Some(t) => t.to_string(), - None => continue, - }; - let email = toks.next().filter(|t| t.contains('@')).map(|t| t.to_string()); - out.push(Recipient { role, pubkey, wrapped_cek, email }); + let mut wrapped_cek = None; + let mut email = None; + let mut reference = None; + for tok in toks { + if tok.contains('@') { + email.get_or_insert_with(|| tok.to_string()); + } else if tok.contains(':') { + reference.get_or_insert_with(|| tok.to_string()); + } else { + wrapped_cek.get_or_insert_with(|| tok.to_string()); + } + } + out.push(Recipient { role, pubkey, wrapped_cek, email, reference }); } out } @@ -408,16 +434,18 @@ pub fn encode_hybrid_agreement( recipients.push(Recipient { role: r.role.to_ascii_lowercase(), pubkey: r.pubkey.clone(), - wrapped_cek: wrapped, + wrapped_cek: Some(wrapped), email: r.email.clone(), + reference: None, }); } let self_wrapped = crate::crypto::wrap_cek(sender_priv, &sender_pub_hex, &cek)?; recipients.push(Recipient { role: ROLE_SELF.to_string(), pubkey: sender_pub_hex.clone(), - wrapped_cek: self_wrapped, + wrapped_cek: Some(self_wrapped), email: sender_email.map(|s| s.to_string()), + reference: None, }); let recipients_body = serialize_recipients(&recipients); @@ -515,22 +543,22 @@ mod tests { ); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 3); - assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "wrapcekA".into(), email: None }); + assert_eq!(recips[0], Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: Some("wrapcekA".into()), email: None, reference: None }); assert_eq!(recips[1].role, "viewer"); assert_eq!(recips[2].role, "self"); - assert!(recips.iter().all(|r| r.email.is_none())); + assert!(recips.iter().all(|r| r.email.is_none() && r.reference.is_none())); } #[test] - fn test_parse_recipients_email_token_and_tolerates_quotes() { + fn test_parse_recipients_typed_tokens_and_tolerates_quotes() { let block = format!( "> signer {} wrapcekA bob@example.com\n\n>> viewer {} wrapcekB\n", HEX_A, HEX_B ); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 2); - // The 4th token (contains '@') is captured as the bound email. - assert_eq!(recips[0].wrapped_cek, "wrapcekA"); + // base64 token → cek; '@' token → email. + assert_eq!(recips[0].wrapped_cek.as_deref(), Some("wrapcekA")); assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); // A stanza without an email leaves it None. assert_eq!(recips[1].role, "viewer"); @@ -538,18 +566,39 @@ mod tests { } #[test] - fn test_parse_recipients_non_email_trailing_token_ignored() { - // A 4th token without '@' is a future/unknown token, not an email. - let block = format!("signer {} wrapcekA futurefield extra", HEX_A); + fn test_parse_recipients_classifies_by_content_any_order() { + // email (@), reference (scheme:value), cek (base64) — given out of order. + let block = format!("signer {} bob@example.com evt:deadbeef wrapcekA", HEX_A); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 1); - assert_eq!(recips[0].wrapped_cek, "wrapcekA"); - assert!(recips[0].email.is_none()); + assert_eq!(recips[0].wrapped_cek.as_deref(), Some("wrapcekA")); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + assert_eq!(recips[0].reference.as_deref(), Some("evt:deadbeef")); + } + + #[test] + fn test_parse_recipients_optional_fields() { + // Plaintext (no cek), gift-wrap (reference only), and bare stanzas. + let block = format!( + "signer {} bob@example.com\nviewer {} evt:abc123\nself {}", + HEX_A, HEX_B, PK_HEX + ); + let recips = parse_recipients_block(&block); + assert_eq!(recips.len(), 3); + // plaintext signatory: email, no cek + assert!(recips[0].wrapped_cek.is_none()); + assert_eq!(recips[0].email.as_deref(), Some("bob@example.com")); + // gift-wrap stanza: reference only + assert!(recips[1].wrapped_cek.is_none() && recips[1].email.is_none()); + assert_eq!(recips[1].reference.as_deref(), Some("evt:abc123")); + // bare workflow-only stanza + assert!(recips[2].wrapped_cek.is_none() && recips[2].email.is_none() && recips[2].reference.is_none()); } #[test] - fn test_parse_recipients_skips_malformed_lines() { - let block = format!("signer {}\njustonetoken\nsigner {} wrapcekB", HEX_A, HEX_B); + fn test_parse_recipients_skips_lines_without_pubkey() { + // `role pubkey` is the minimum; a lone token has no pubkey and is skipped. + let block = format!("justonetoken\nsigner {} wrapcekB", HEX_B); let recips = parse_recipients_block(&block); assert_eq!(recips.len(), 1); assert_eq!(recips[0].pubkey, HEX_B); @@ -558,12 +607,13 @@ mod tests { #[test] fn test_serialize_recipients_roundtrip() { let recips = vec![ - Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: "cekA".into(), email: Some("a@x.io".into()) }, - Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: "cekS".into(), email: None }, + Recipient { role: "signer".into(), pubkey: HEX_A.into(), wrapped_cek: Some("cekA".into()), email: Some("a@x.io".into()), reference: None }, + Recipient { role: "self".into(), pubkey: HEX_B.into(), wrapped_cek: Some("cekS".into()), email: None, reference: None }, + Recipient { role: "viewer".into(), pubkey: PK_HEX.into(), wrapped_cek: None, email: None, reference: Some("evt:abc".into()) }, ]; let body = serialize_recipients(&recips); - // The email is the trailing token only on stanzas that carry one. - assert_eq!(body, format!("signer {} cekA a@x.io\nself {} cekS", HEX_A, HEX_B)); + // Only present fields are emitted, in cek/email/reference order. + assert_eq!(body, format!("signer {} cekA a@x.io\nself {} cekS\nviewer {} evt:abc", HEX_A, HEX_B, PK_HEX)); assert_eq!(parse_recipients_block(&body), recips); } @@ -733,7 +783,7 @@ mod tests { .iter() .find(|r| normalize_pubkey_hex(&r.pubkey).as_deref() == Some(reader_pub_hex)) .expect("reader has a stanza"); - let cek = crate::crypto::unwrap_cek(reader_priv, sender_pub_hex, &stanza.wrapped_cek).unwrap(); + let cek = crate::crypto::unwrap_cek(reader_priv, sender_pub_hex, stanza.wrapped_cek.as_deref().unwrap()).unwrap(); crate::crypto::aes_gcm_decrypt_raw(&cek, &ciphertext).unwrap() } diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index f8d292f..b8dd4b0 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -3122,13 +3122,23 @@ fn decrypt_single_block( return (block, None); } }; - if stanza.wrapped_cek == crate::agreement::NO_CEK { - // Plaintext (public) agreement stanza carries no CEK; an ENCRYPTED - // BODY paired with such a stanza is malformed (spec §11.8). - block.error = Some("RECIPIENTS stanza has no CEK ('-') but the body is encrypted".to_string()); - return (block, None); - } - let cek = match crate::crypto::unwrap_cek(private_key, &sender_pub, &stanza.wrapped_cek) { + let wrapped = match &stanza.wrapped_cek { + Some(w) => w, + None => { + // No CEK in the email: a plaintext (public) agreement stanza + // (§11.8) — malformed for an ENCRYPTED body — or gift-wrap mode + // (§11.7), where the CEK arrives in the referenced DM (not yet + // wired). Either way this body can't be decrypted from the email. + let why = if stanza.reference.is_some() { + "CEK is delivered out-of-band (gift-wrap reference); DM delivery not yet supported" + } else { + "RECIPIENTS stanza carries no CEK but the body is encrypted" + }; + block.error = Some(why.to_string()); + return (block, None); + } + }; + let cek = match crate::crypto::unwrap_cek(private_key, &sender_pub, wrapped) { Ok(c) => c, Err(e) => { block.error = Some(format!("Envelope CEK unwrap failed: {}", e)); @@ -3834,7 +3844,8 @@ fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { /// `agreement::encode_hybrid_agreement` (spec §11.8). The terms are carried in a /// signed `SIGNED BODY` (readable by any client, with the plaintext also above /// the armor per §3.2), and the RECIPIENTS block declares the signatories/viewers -/// with the `-` no-CEK sentinel (`agreement::NO_CEK`) since nothing is encrypted. +/// with no `wrapped-cek` token (`Recipient.wrapped_cek == None`) since nothing +/// is encrypted. /// /// The document hash `H`, consent binding, signature coverage (§4.2), and /// completion accounting (§11.5) are identical to the encrypted case — they @@ -3856,7 +3867,7 @@ pub fn encode_signed_agreement( ) -> Result { use crate::agreement::{ canonicalize_block, document_hash, level_signing_bytes, serialize_recipients, - Consent, Recipient, NO_CEK, + Consent, Recipient, }; if recipients_in.is_empty() { return Err("encode_signed_agreement requires at least one signatory".to_string()); @@ -3867,12 +3878,13 @@ pub fn encode_signed_agreement( let (encoded_body, canonical_bytes) = glossia_encode_signed_body(terms_plaintext) .ok_or_else(|| "glossia encode of agreement terms failed".to_string())?; - // Signatories/viewers with no key wrap (plaintext): role pubkey - [email]. + // Signatories/viewers with no key wrap (plaintext): role pubkey [email]. let recipients: Vec = recipients_in.iter().map(|r| Recipient { role: r.role.to_ascii_lowercase(), pubkey: r.pubkey.clone(), - wrapped_cek: NO_CEK.to_string(), + wrapped_cek: None, email: r.email.clone(), + reference: None, }).collect(); let recipients_body = serialize_recipients(&recipients); let canon_recipients = canonicalize_block(&recipients_body); @@ -5857,10 +5869,10 @@ nitela\n\ // The originator's signature verifies over the §4.2 target. assert_eq!(verify_email_signature_inline(&agreement), Some(true)); - // Signatories are declared with the no-CEK sentinel. + // Signatories are declared with no CEK token. let parsed = parse_armor_components(&agreement).expect("parses"); assert_eq!(parsed.recipients.len(), 1); - assert_eq!(parsed.recipients[0].wrapped_cek, crate::agreement::NO_CEK); + assert!(parsed.recipients[0].wrapped_cek.is_none()); assert!(parsed.recipients[0].is_signer()); // Completion is tracked: originator consented, the other signatory pending. From 038f62ff83758936199dfd9595e661fc457878c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 04:01:55 +0000 Subject: [PATCH 11/44] feat(send): agreement compose/send path + IPC commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the agreement encoders into a send path and expose them over IPC. email.rs: * compose_agreement_armor — maps To→signer / Cc→viewer (§6.3) and builds the armor via encode_hybrid_agreement (encrypted) or encode_signed_agreement (plaintext/public); unit-testable (no SMTP) * send_agreement_email — composes, then sends ONE message to all signatories (To:) and viewers (Cc:); adds the X-Nostr-Agreement marker (§6.1) and the X-Nostr-Pubkey/Sig headers mirroring the in-body SIGNATURE * X-Nostr-Agreement header type; build_mailer / send_message_with_timeout helpers factored out of the SMTP path types.rs: AgreementParty { email, pubkey } IPC arg. lib.rs — four Tauri commands (registered): * compose_agreement — returns the armor for preview (no send) * send_agreement — compose + multi-recipient send * agreement_status — "M of N signed" completion from armor (§11.5) * verify_email_binding — stateless (npub,email) binding check (#102) Tests: encrypted compose maps To/Cc to signer/viewer (+ self stanza + consent) and the To: party decrypts via the pipeline; plaintext compose is public and signed and tracks 1-of-2; empty recipients rejected. Full lib suite: 252 passed. Note: the agreement subject is sent in cleartext for now (the terms/body are encrypted in envelope mode); per-recipient subject encryption is a follow-up. The frontend compose UI / status rendering is the next layer. --- tauri-app/backend/src/email.rs | 231 +++++++++++++++++++++++++++++++++ tauri-app/backend/src/lib.rs | 91 +++++++++++++ tauri-app/backend/src/types.rs | 11 ++ 3 files changed, 333 insertions(+) diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index b8dd4b0..5acf458 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -144,6 +144,24 @@ impl Header for XNostrRecipient { } } +/// `X-Nostr-Agreement` — a non-authoritative marker that a thread is an +/// agreement, so IMAP can surface agreement threads without decrypting bodies +/// (spec §6.1, §11.2). The authoritative state always comes from the armor. +#[derive(Debug, Clone)] +struct XNostrAgreement(String); + +impl Header for XNostrAgreement { + fn name() -> HeaderName { + HeaderName::new_from_ascii_str("X-Nostr-Agreement") + } + fn parse(s: &str) -> Result> { + Ok(XNostrAgreement(s.to_string())) + } + fn display(&self) -> HeaderValue { + HeaderValue::new(Self::name(), self.0.clone()) + } +} + /// Construct email headers without sending the email pub fn construct_email_headers( config: &EmailConfig, @@ -593,6 +611,154 @@ pub async fn send_email( } } +/// Build an SMTP transport from the config (shared TLS/credentials setup). +fn build_mailer(config: &EmailConfig) -> Result { + let creds = Credentials::new(config.email_address.clone(), config.password.clone()); + let mut mailer_builder = SmtpTransport::relay(&config.smtp_host)? + .port(config.smtp_port) + .credentials(creds); + if config.use_tls { + let tls_params = lettre::transport::smtp::client::TlsParameters::new(config.smtp_host.clone())?; + mailer_builder = mailer_builder.tls(lettre::transport::smtp::client::Tls::Required(tls_params)); + } else { + mailer_builder = mailer_builder.tls(lettre::transport::smtp::client::Tls::None); + } + Ok(mailer_builder.build()) +} + +/// Send a built message via SMTP with the standard 60s timeout / blocking-thread +/// handling. +async fn send_message_with_timeout(mailer: SmtpTransport, email: Message) -> Result<()> { + let send_future = task::spawn_blocking(move || mailer.send(&email)); + match timeout(Duration::from_secs(60), send_future).await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err(e))) => Err(anyhow::anyhow!("Failed to send email: {}", e)), + Ok(Err(e)) => Err(anyhow::anyhow!("Task join error: {}", e)), + Err(_) => Err(anyhow::anyhow!("SMTP send operation timed out after 60 seconds")), + } +} + +/// Build the armored `text/plain` body for an agreement (spec §§10–11). +/// +/// `to` parties become `signer` signatories and `cc` parties become `viewer`s +/// (spec §6.3). `encrypted` selects the multi-recipient envelope (CEK) form vs. +/// the plaintext (public) form (§11.8). `originator_consents` includes the +/// originator's own CONSENT over `H` (§11.2). Returns the armor string; +/// SMTP/MIME assembly is the caller's job, so this is unit-testable. +pub fn compose_agreement_armor( + private_key: &str, + sender_pub: &str, + sender_email: Option<&str>, + profile_name: &str, + body: &str, + to: &[crate::types::AgreementParty], + cc: &[crate::types::AgreementParty], + encrypted: bool, + originator_consents: bool, +) -> Result { + use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; + + let mut recips: Vec = Vec::with_capacity(to.len() + cc.len()); + for p in to { + recips.push(AgreementRecipientInput { + role: ROLE_SIGNER.to_string(), + pubkey: p.pubkey.clone(), + email: Some(p.email.clone()), + }); + } + for p in cc { + recips.push(AgreementRecipientInput { + role: ROLE_VIEWER.to_string(), + pubkey: p.pubkey.clone(), + email: Some(p.email.clone()), + }); + } + if recips.is_empty() { + return Err("an agreement needs at least one To: or Cc: recipient".to_string()); + } + + if encrypted { + crate::agreement::encode_hybrid_agreement( + private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, + ).map_err(|e| e.to_string()) + } else { + encode_signed_agreement( + private_key, sender_pub, profile_name, body, &recips, originator_consents, + ) + } +} + +/// Compose an agreement and send it to all signatories (`To:`) and viewers +/// (`Cc:`) in one message (spec §6.3). Adds the `X-Nostr-Agreement` marker +/// (§6.1) and, when enabled, the `X-Nostr-Pubkey`/`X-Nostr-Sig` headers. The +/// body's armor already carries the authoritative SIGNATURE. +pub async fn send_agreement_email( + config: &EmailConfig, + subject: &str, + profile_name: &str, + body: &str, + to: &[crate::types::AgreementParty], + cc: &[crate::types::AgreementParty], + encrypted: bool, + originator_consents: bool, + message_id: Option<&str>, + in_reply_to: Option<&str>, + references: Option<&str>, + include_pubkey_header: bool, + include_sig_header: bool, +) -> Result { + let private_key = config.private_key.as_deref() + .ok_or_else(|| anyhow::anyhow!("no private key configured; cannot sign the agreement"))?; + let sender_pub = crypto::get_public_key_from_private(private_key) + .map_err(|e| anyhow::anyhow!("could not derive sender pubkey: {}", e))?; + + let armor = compose_agreement_armor( + private_key, &sender_pub, Some(&config.email_address), profile_name, body, to, cc, encrypted, originator_consents, + ).map_err(|e| anyhow::anyhow!("{}", e))?; + + let mut builder = Message::builder() + .from(config.email_address.parse()?) + .reply_to(config.email_address.parse()?) + .subject(subject); + for p in to { + builder = builder.to(p.email.parse()?); + } + for p in cc { + builder = builder.cc(p.email.parse()?); + } + if let Some(m) = message_id { + builder = builder.message_id(Some(m.to_string())); + } + if let Some(r) = in_reply_to { + builder = builder.in_reply_to(r.to_string()); + } + if let Some(r) = references { + builder = builder.references(r.to_string()); + } + + // Agreement marker for decrypt-free IMAP filtering (§6.1); non-authoritative. + builder = builder.header(XNostrAgreement("1".to_string())); + + // X-Nostr-Pubkey / X-Nostr-Sig mirror the in-body SIGNATURE (secondary path). + if include_pubkey_header { + builder = builder.header(XNostrPubkey(sender_pub.clone())); + if include_sig_header { + let binary = extract_ciphertext_binary(&armor); + if let Ok(signature) = crypto::sign_data_bytes(private_key, &binary) { + builder = builder.header(XNostrSig(signature)); + } + } + } + + let email = builder.body(armor)?; + let mailer = build_mailer(config)?; + send_message_with_timeout(mailer, email).await?; + Ok(format!( + "Agreement sent to {} signatory(ies) and {} viewer(s)", + to.len(), cc.len() + )) +} + /// Delete a sent email from the IMAP server by moving it to Trash /// For Gmail, moves to [Gmail]/Trash /// For other providers, tries common trash folder names @@ -5915,6 +6081,71 @@ nitela\n\ assert_eq!(verify_email_signature_inline(&tampered), Some(false)); } + // ============================================= + // compose_agreement_armor (send-path) — To→signer, Cc→viewer (§6.3) + // ============================================= + + #[test] + fn test_compose_agreement_encrypted_maps_to_and_cc() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); // sender + let bob = crypto::generate_keypair().unwrap(); // To + let carol = crypto::generate_keypair().unwrap(); // Cc + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; + let armor = compose_agreement_armor( + &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", + "Mutual NDA terms.", &to, &cc, true, true, + ).unwrap(); + + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + let parsed = parse_armor_components(&armor).expect("parses"); + // bob is a signer, carol a viewer, plus the sender's self stanza. + assert!(parsed.recipients.iter().any(|r| r.role == "signer" && r.email.as_deref() == Some("bob@example.com"))); + assert!(parsed.recipients.iter().any(|r| r.role == "viewer" && r.email.as_deref() == Some("carol@example.org"))); + assert!(parsed.recipients.iter().any(|r| r.role == "self")); + assert!(parsed.consent.is_some(), "originator consent included"); + + // The To: signatory can decrypt. + let r = decrypt_email_body_pipeline( + &bob.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Mutual NDA terms."); + } + + #[test] + fn test_compose_agreement_plaintext_is_public_and_signed() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + let armor = compose_agreement_armor( + &alice.private_key, &alice_pub, None, "Alice", + "We agree to the public terms.", &to, &[], false, true, + ).unwrap(); + + assert!(armor.starts_with("We agree to the public terms.")); + assert!(armor.contains("BEGIN NOSTR SIGNED BODY")); + assert!(!armor.contains("ENCRYPTED BODY")); + assert_eq!(verify_email_signature_inline(&armor), Some(true)); + let status = verify_agreement_status(&armor).expect("is an agreement"); + assert_eq!((status.m, status.n), (1, 2)); + } + + #[test] + fn test_compose_agreement_rejects_no_recipients() { + let alice = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let err = compose_agreement_armor( + &alice.private_key, &alice_pub, None, "Alice", "terms", &[], &[], true, false); + assert!(err.is_err()); + } + // ============================================= // parse_armor_components tests // ============================================= diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index e7f8077..210b2f3 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -2083,6 +2083,93 @@ async fn construct_email_headers(mut email_config: EmailConfig, to_address: Stri .map_err(|e| e.to_string()) } +/// Build the armored body for an agreement without sending it (compose preview). +/// `to` parties become signatories, `cc` parties become viewers (spec §6.3). +/// `encrypted` selects the multi-recipient envelope vs the plaintext/public form. +#[tauri::command] +async fn compose_agreement( + mut email_config: EmailConfig, + body: String, + to: Vec, + cc: Vec, + encrypted: bool, + originator_consents: bool, + profile_name: Option, + state: tauri::State<'_, AppState>, +) -> Result { + email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); + let private_key = email_config.private_key.as_deref().unwrap(); + let sender_pub = crypto::get_public_key_from_private(private_key).map_err(|e| e.to_string())?; + email::compose_agreement_armor( + private_key, + &sender_pub, + Some(&email_config.email_address), + profile_name.as_deref().unwrap_or(""), + &body, + &to, + &cc, + encrypted, + originator_consents, + ) +} + +/// Compose and send an agreement to all signatories (`To:`) and viewers (`Cc:`) +/// in one message (spec §6.3). The armored body carries the authoritative +/// SIGNATURE; an `X-Nostr-Agreement` marker enables decrypt-free IMAP filtering. +#[tauri::command] +async fn send_agreement( + mut email_config: EmailConfig, + subject: String, + body: String, + to: Vec, + cc: Vec, + encrypted: bool, + originator_consents: bool, + profile_name: Option, + message_id: Option, + in_reply_to: Option, + references: Option, + include_pubkey_header: Option, + include_sig_header: Option, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); + let include_pubkey = include_pubkey_header.unwrap_or(true); + let include_sig = include_sig_header.unwrap_or(true); + email::send_agreement_email( + &email_config, + &subject, + profile_name.as_deref().unwrap_or(""), + &body, + &to, + &cc, + encrypted, + originator_consents, + message_id.as_deref(), + in_reply_to.as_deref(), + references.as_deref(), + include_pubkey, + include_sig, + ) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Compute an agreement's "M of N signed" completion status from a message/thread +/// armor (spec §11.5). Returns `None` when the armor is not an agreement. +#[tauri::command] +fn agreement_status(armor: String) -> Option { + email::verify_agreement_status(&armor) +} + +/// Verify email↔npub bindings provable from a self-contained thread (issue #102), +/// from the verifier's own pubkey's perspective. Stateless — no challenge store. +#[tauri::command] +fn verify_email_binding(armor: String, my_pubkey: String) -> Vec { + email::verify_email_binding(&armor, &my_pubkey) +} + #[tauri::command] async fn fetch_image(url: String) -> Result { nostr::fetch_image_as_data_url(&url).await.map_err(|e| e.to_string()) @@ -7484,6 +7571,10 @@ pub fn run() { publish_nostr_event, send_email, construct_email_headers, + compose_agreement, + send_agreement, + agreement_status, + verify_email_binding, fetch_image, fetch_multiple_images, fetch_profiles, diff --git a/tauri-app/backend/src/types.rs b/tauri-app/backend/src/types.rs index ae301eb..88c62a8 100644 --- a/tauri-app/backend/src/types.rs +++ b/tauri-app/backend/src/types.rs @@ -8,6 +8,17 @@ pub struct KeyPair { pub public_key: String, } +/// A party on an agreement, as supplied by the compose UI. `To:` parties become +/// `signer` signatories and `Cc:` parties become `viewer`s (spec §6.3). The +/// `pubkey` is hex or npub; the `email` is the transport address (and is bound +/// inside the signed RECIPIENTS block, spec §10.2 / #102). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgreementParty { + pub email: String, + pub pubkey: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountInfo { pub public_key: String, From 16c56d55e1575f6356ec22984f08dc12233853b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 05:20:33 +0000 Subject: [PATCH 12/44] feat(agreement): encrypt the subject under the message CEK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an encrypted (envelope) agreement, the Subject header is now AES-256-GCM -encrypted under the same CEK as the body, so it is readable by exactly the recipients and never leaks in cleartext. Plaintext (public) agreements keep a cleartext subject. * agreement::encode_hybrid_agreement_with_cek — CEK-taking variant so one key covers both the body and the subject (encode_hybrid_agreement wraps it) * email::compose_agreement now takes the subject and returns ComposedAgreement { armor, subject } — subject base64(AES-GCM(CEK)) in envelope mode, cleartext in plaintext mode * decrypt_subject_envelope — unwraps the CEK from the reader's RECIPIENTS stanza and decrypts the subject; the pipeline routes here whenever a RECIPIENTS block is present (falls back gracefully to cleartext) * compose_agreement / send_agreement IPC + send_agreement_email updated to carry the (encrypted) subject; ComposedAgreement is the compose return type * spec §11.2: subject encrypted under the CEK (metadata, not signed/H-covered) Tests: the To: signatory recovers body AND subject; a non-recipient gets neither; plaintext agreement keeps a cleartext subject. Full lib suite: 252. --- docs/nostr-mail-spec.md | 1 + tauri-app/backend/src/agreement.rs | 29 +++++-- tauri-app/backend/src/email.rs | 121 ++++++++++++++++++++++++----- tauri-app/backend/src/lib.rs | 6 +- tauri-app/backend/src/types.rs | 11 +++ 5 files changed, 140 insertions(+), 28 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 355665d..04e042c 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -650,6 +650,7 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: - **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword) — or, for a **plaintext (public) agreement**, a signed `SIGNED BODY` with the terms in the clear (Section 11.8). +- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body (base64), so it is readable by exactly the recipients and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity), matching the body/subject split of Section 5. - **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index 298c03b..ae0434a 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -413,6 +413,26 @@ pub fn encode_hybrid_agreement( body_plaintext: &[u8], recipients_in: &[AgreementRecipientInput], originator_consents: bool, +) -> Result { + let cek = crate::crypto::generate_cek(); + encode_hybrid_agreement_with_cek( + &cek, sender_priv, sender_pub, sender_email, profile_name, body_plaintext, recipients_in, originator_consents, + ) +} + +/// Like [`encode_hybrid_agreement`] but uses a caller-supplied CEK, so the same +/// key can also encrypt out-of-band material (e.g. the email subject) under one +/// envelope. The caller is responsible for generating a fresh random CEK +/// ([`crate::crypto::generate_cek`]) per message. +pub fn encode_hybrid_agreement_with_cek( + cek: &[u8; 32], + sender_priv: &str, + sender_pub: &str, + sender_email: Option<&str>, + profile_name: &str, + body_plaintext: &[u8], + recipients_in: &[AgreementRecipientInput], + originator_consents: bool, ) -> Result { if recipients_in.is_empty() { return Err(anyhow::anyhow!( @@ -422,15 +442,14 @@ pub fn encode_hybrid_agreement( let sender_pub_hex = normalize_pubkey_hex(sender_pub) .ok_or_else(|| anyhow::anyhow!("invalid sender pubkey"))?; - // 1–2. Encrypt the body once under a fresh CEK (Section 10.1 steps 1–2). - let cek = crate::crypto::generate_cek(); - let ciphertext = crate::crypto::aes_gcm_encrypt_raw(&cek, body_plaintext)?; + // 1–2. Encrypt the body once under the CEK (Section 10.1 steps 1–2). + let ciphertext = crate::crypto::aes_gcm_encrypt_raw(cek, body_plaintext)?; let body_b64 = general_purpose::STANDARD.encode(&ciphertext); // 3. Wrap the CEK to each recipient, then to self (Section 10.1 step 3, 10.4). let mut recipients: Vec = Vec::with_capacity(recipients_in.len() + 1); for r in recipients_in { - let wrapped = crate::crypto::wrap_cek(sender_priv, &r.pubkey, &cek)?; + let wrapped = crate::crypto::wrap_cek(sender_priv, &r.pubkey, cek)?; recipients.push(Recipient { role: r.role.to_ascii_lowercase(), pubkey: r.pubkey.clone(), @@ -439,7 +458,7 @@ pub fn encode_hybrid_agreement( reference: None, }); } - let self_wrapped = crate::crypto::wrap_cek(sender_priv, &sender_pub_hex, &cek)?; + let self_wrapped = crate::crypto::wrap_cek(sender_priv, &sender_pub_hex, cek)?; recipients.push(Recipient { role: ROLE_SELF.to_string(), pubkey: sender_pub_hex.clone(), diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 5acf458..d7919c0 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -638,24 +638,30 @@ async fn send_message_with_timeout(mailer: SmtpTransport, email: Message) -> Res } } -/// Build the armored `text/plain` body for an agreement (spec §§10–11). +/// Compose an agreement: the armored `text/plain` body plus the subject to put +/// in the header (spec §§10–11). /// /// `to` parties become `signer` signatories and `cc` parties become `viewer`s /// (spec §6.3). `encrypted` selects the multi-recipient envelope (CEK) form vs. /// the plaintext (public) form (§11.8). `originator_consents` includes the -/// originator's own CONSENT over `H` (§11.2). Returns the armor string; +/// originator's own CONSENT over `H` (§11.2). +/// +/// In encrypted mode the **subject is AES-256-GCM-encrypted under the same CEK +/// as the body** (base64), so it is readable by exactly the recipients and never +/// leaks in cleartext; in plaintext mode the subject is returned unchanged. /// SMTP/MIME assembly is the caller's job, so this is unit-testable. -pub fn compose_agreement_armor( +pub fn compose_agreement( private_key: &str, sender_pub: &str, sender_email: Option<&str>, profile_name: &str, + subject: &str, body: &str, to: &[crate::types::AgreementParty], cc: &[crate::types::AgreementParty], encrypted: bool, originator_consents: bool, -) -> Result { +) -> Result { use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; let mut recips: Vec = Vec::with_capacity(to.len() + cc.len()); @@ -678,13 +684,60 @@ pub fn compose_agreement_armor( } if encrypted { - crate::agreement::encode_hybrid_agreement( - private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, - ).map_err(|e| e.to_string()) + // One CEK for the body and the subject. + let cek = crate::crypto::generate_cek(); + let armor = crate::agreement::encode_hybrid_agreement_with_cek( + &cek, private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, + ).map_err(|e| e.to_string())?; + let enc_subject = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) + .map(|ct| general_purpose::STANDARD.encode(ct)) + .map_err(|e| format!("subject encrypt failed: {}", e))?; + Ok(crate::types::ComposedAgreement { armor, subject: enc_subject }) } else { - encode_signed_agreement( + let armor = encode_signed_agreement( private_key, sender_pub, profile_name, body, &recips, originator_consents, - ) + )?; + // Public agreement: subject stays in the clear. + Ok(crate::types::ComposedAgreement { armor, subject: subject.to_string() }) + } +} + +/// Decrypt an agreement subject that was AES-256-GCM-encrypted under the message +/// CEK (the envelope path; counterpart to [`compose_agreement`]). Unwraps the CEK +/// from the reader's RECIPIENTS stanza, then decrypts. Returns the subject +/// unchanged if it isn't CEK-encrypted (e.g. a plaintext-agreement subject) or +/// if anything fails. The second tuple element is the ciphertext (for DM↔email +/// hash matching), mirroring [`decrypt_subject`]. +fn decrypt_subject_envelope( + subject: &str, + recipients: &[crate::agreement::Recipient], + sender_pub: &str, + private_key: &str, + user_pubkey_hex: &str, +) -> (String, Option) { + if subject.is_empty() { + return (subject.to_string(), None); + } + let me = crate::agreement::normalize_pubkey_hex(user_pubkey_hex) + .unwrap_or_else(|| user_pubkey_hex.to_string()); + let stanza = recipients.iter().find(|r| { + crate::agreement::normalize_pubkey_hex(&r.pubkey).as_deref() == Some(me.as_str()) + }); + let wrapped = match stanza.and_then(|s| s.wrapped_cek.as_deref()) { + Some(w) => w, + None => return (subject.to_string(), None), // plaintext subject or not a recipient + }; + let cek = match crate::crypto::unwrap_cek(private_key, sender_pub, wrapped) { + Ok(c) => c, + Err(_) => return (subject.to_string(), None), + }; + let ct = match general_purpose::STANDARD.decode(subject.trim()) { + Ok(b) => b, + Err(_) => return (subject.to_string(), None), // not base64 ⇒ cleartext + }; + match crate::crypto::aes_gcm_decrypt_raw(&cek, &ct) { + Ok(pt) => (String::from_utf8_lossy(&pt).into_owned(), Some(subject.to_string())), + Err(_) => (subject.to_string(), Some(subject.to_string())), } } @@ -712,14 +765,15 @@ pub async fn send_agreement_email( let sender_pub = crypto::get_public_key_from_private(private_key) .map_err(|e| anyhow::anyhow!("could not derive sender pubkey: {}", e))?; - let armor = compose_agreement_armor( - private_key, &sender_pub, Some(&config.email_address), profile_name, body, to, cc, encrypted, originator_consents, + let composed = compose_agreement( + private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, ).map_err(|e| anyhow::anyhow!("{}", e))?; + let armor = composed.armor; let mut builder = Message::builder() .from(config.email_address.parse()?) .reply_to(config.email_address.parse()?) - .subject(subject); + .subject(composed.subject); for p in to { builder = builder.to(p.email.parse()?); } @@ -3780,7 +3834,16 @@ pub fn decrypt_email_body_pipeline( // Decrypt subject — use armor's embedded pubkey as fallback when sender_pubkey wasn't provided let perf_subject = std::time::Instant::now(); - let (decrypted_subject, subject_ciphertext) = if parsed.body_type == "encrypted" { + let (decrypted_subject, subject_ciphertext) = if !parsed.recipients.is_empty() { + // Multi-recipient envelope: the subject is AES-256-GCM-encrypted under the + // same CEK as the body (or cleartext for a plaintext agreement). The + // sender (who wrapped the CEK) is the message author. + let sender_pub = parsed.sig_pubkey_hex.as_deref() + .or(parsed.seal_pubkey_hex.as_deref()) + .map(|s| s.to_string()) + .unwrap_or_else(|| fallback.to_string()); + decrypt_subject_envelope(subject, &parsed.recipients, &sender_pub, private_key, &user_pubkey_hex) + } else if parsed.body_type == "encrypted" { let nip_hint = parsed.encryption_nip.as_deref().unwrap_or("nip44"); let subject_fallback = if fallback.is_empty() { // The armor signature/seal block contains the sender's pubkey @@ -6095,10 +6158,15 @@ nitela\n\ let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; - let armor = compose_agreement_armor( + let composed = compose_agreement( &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", - "Mutual NDA terms.", &to, &cc, true, true, + "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, ).unwrap(); + let armor = composed.armor; + + // The subject is encrypted under the CEK (not cleartext) for envelope mode. + assert_ne!(composed.subject, "Quarterly NDA"); + assert!(!composed.subject.contains("NDA")); assert_eq!(verify_email_signature_inline(&armor), Some(true)); let parsed = parse_armor_components(&armor).expect("parses"); @@ -6108,12 +6176,20 @@ nitela\n\ assert!(parsed.recipients.iter().any(|r| r.role == "self")); assert!(parsed.consent.is_some(), "originator consent included"); - // The To: signatory can decrypt. + // The To: signatory recovers both the body and the subject. let r = decrypt_email_body_pipeline( - &bob.private_key, &armor, "Subject", Some(&alice.public_key), None, None, true, false, + &bob.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, true, false, ).unwrap(); assert!(r.success); assert_eq!(r.body, "Mutual NDA terms."); + assert_eq!(r.subject, "Quarterly NDA", "subject decrypts under the CEK"); + + // A non-recipient cannot read the subject either. + let mallory = crypto::generate_keypair().unwrap(); + let rm = decrypt_email_body_pipeline( + &mallory.private_key, &armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert_ne!(rm.subject, "Quarterly NDA", "non-recipient must not read the subject"); } #[test] @@ -6124,11 +6200,14 @@ nitela\n\ let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; - let armor = compose_agreement_armor( + let composed = compose_agreement( &alice.private_key, &alice_pub, None, "Alice", - "We agree to the public terms.", &to, &[], false, true, + "Public Statement", "We agree to the public terms.", &to, &[], false, true, ).unwrap(); + let armor = composed.armor; + // Public agreement: subject stays in the clear. + assert_eq!(composed.subject, "Public Statement"); assert!(armor.starts_with("We agree to the public terms.")); assert!(armor.contains("BEGIN NOSTR SIGNED BODY")); assert!(!armor.contains("ENCRYPTED BODY")); @@ -6141,8 +6220,8 @@ nitela\n\ fn test_compose_agreement_rejects_no_recipients() { let alice = crypto::generate_keypair().unwrap(); let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); - let err = compose_agreement_armor( - &alice.private_key, &alice_pub, None, "Alice", "terms", &[], &[], true, false); + let err = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false); assert!(err.is_err()); } diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index 210b2f3..8ecd835 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -2089,6 +2089,7 @@ async fn construct_email_headers(mut email_config: EmailConfig, to_address: Stri #[tauri::command] async fn compose_agreement( mut email_config: EmailConfig, + subject: String, body: String, to: Vec, cc: Vec, @@ -2096,15 +2097,16 @@ async fn compose_agreement( originator_consents: bool, profile_name: Option, state: tauri::State<'_, AppState>, -) -> Result { +) -> Result { email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); let private_key = email_config.private_key.as_deref().unwrap(); let sender_pub = crypto::get_public_key_from_private(private_key).map_err(|e| e.to_string())?; - email::compose_agreement_armor( + email::compose_agreement( private_key, &sender_pub, Some(&email_config.email_address), profile_name.as_deref().unwrap_or(""), + &subject, &body, &to, &cc, diff --git a/tauri-app/backend/src/types.rs b/tauri-app/backend/src/types.rs index 88c62a8..25267b8 100644 --- a/tauri-app/backend/src/types.rs +++ b/tauri-app/backend/src/types.rs @@ -19,6 +19,17 @@ pub struct AgreementParty { pub pubkey: String, } +/// Output of composing an agreement: the armored `text/plain` body and the +/// subject to place in the header. For an encrypted (envelope) agreement the +/// `subject` is AES-256-GCM-encrypted under the same CEK as the body; for a +/// plaintext (public) agreement it is the cleartext subject. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComposedAgreement { + pub armor: String, + pub subject: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountInfo { pub public_key: String, From a603850784a63ac52a11ef592c7902746b6da7df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 06:44:54 +0000 Subject: [PATCH 13/44] fix(agreement): glossia-encode envelope body + subject (transport integrity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base64 is wrong for a signed reply chain over email: when a counterparty's client quotes the prior message it adds "> " prefixes and re-wraps lines, which corrupts base64 — so the decoded bytes, and every nested signature over them, would fail to verify. Glossia's word tokens survive quoting/wrapping/reflow (decoders ignore "> " and non-payload whitespace), so the canonical bytes are recovered intact. This is why email-native document signing can't use base64. * glossia_encode_bytes (generalized from glossia_encode_signed_body) — encode raw bytes for an armor body, returning the encoded words + canonical bytes * encode_hybrid_agreement_with_cek now glossia-encodes the body ciphertext and signs over the canonical decoded bytes (not the raw base64) * compose_agreement glossia-encodes the subject ciphertext; the subject reads as prose and survives header folding * decrypt_subject_envelope decodes via decode_armor_section (glossia or base64) * spec §3.6 / §11.2: glossia required for the signed body; rationale documented Test: a glossia body decodes to identical bytes after quote-prefixing AND re-wrapping (base64 would break). Full lib suite: 253 passed. --- docs/nostr-mail-spec.md | 8 +++-- tauri-app/backend/src/agreement.rs | 47 ++++++++++++++------------ tauri-app/backend/src/email.rs | 54 ++++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 04e042c..0244986 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -206,7 +206,7 @@ When a message has more than one cryptographic recipient — for example multipl ``` ----- BEGIN NOSTR ENCRYPTED BODY ----- - + ----- BEGIN NOSTR RECIPIENTS ----- signer signer @@ -219,7 +219,9 @@ self ----- END NOSTR MESSAGE ----- ``` -Each RECIPIENTS entry is a single line of three space-separated tokens: ` ` (see Section 10.2). The sender's own `self` stanza makes the Sent copy decryptable on any device, mirroring the "wrap twice" behavior of NIP-17 DMs. +**Body encoding — glossia, not base64.** The body ciphertext MUST be glossia-encoded (Section 5), not base64. This is not merely steganographic: an agreement is a signed reply chain, and when a counterparty's email client quotes the prior message it prefixes lines with `> ` and re-wraps them. base64 is corrupted by those mutations, so the decoded bytes — and therefore every nested signature over them — would change and fail to verify. Glossia's word tokens survive quoting, wrapping, and reflow (decoders ignore `> ` and non-payload whitespace, Section 3.5.4), so the canonical decoded bytes are recovered intact. This is why email-native document signing cannot use base64 for the signed payload. (Large bodies/attachments still ride the base64 manifest path, which is not part of the signed reply chain.) + +Each RECIPIENTS entry is a single line of three or four space-separated tokens: ` [] []` (see Section 10.2). The sender's own `self` stanza makes the Sent copy decryptable on any device, mirroring the "wrap twice" behavior of NIP-17 DMs. A multi-recipient message SHOULD be signed; the SIGNATURE then covers both the body and the recipients block (Section 4.2), making the membership and role set tamper-evident. An unsigned multi-recipient message MAY instead carry a SEAL block to supply the sender's pubkey for CEK unwrapping, but in that case the role set is unauthenticated and MUST NOT be relied upon to designate required signatories. @@ -650,7 +652,7 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: - **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword) — or, for a **plaintext (public) agreement**, a signed `SIGNED BODY` with the terms in the clear (Section 11.8). -- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body (base64), so it is readable by exactly the recipients and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity), matching the body/subject split of Section 5. +- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body and **glossia-encoded** (so it reads as prose and survives header folding), so it is readable by exactly the recipients and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity), matching the body/subject split of Section 5. - **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index ae0434a..873b91b 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -20,7 +20,6 @@ //! armor message. Wiring into the recursive armor *parser* lives in `email.rs`. use anyhow::Result; -use base64::{engine::general_purpose, Engine as _}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -401,10 +400,9 @@ pub struct AgreementRecipientInput { /// `sender_email` to do the same for the `self` stanza. Returns the armored /// `text/plain` payload. /// -/// This composes the *cryptographic* envelope; glossia prose encoding of the -/// body/signature (Section 5) is applied by the caller's send pipeline if -/// desired — here the body is emitted as base64 and the signature/pubkey as hex, -/// both of which decoders accept. +/// The body ciphertext is glossia-encoded (Section 5) so it survives email +/// transport — quote prefixes, word-wrap, reflow — intact; base64 would corrupt +/// under quoting and break signed reply chains. The signature/pubkey are hex. pub fn encode_hybrid_agreement( sender_priv: &str, sender_pub: &str, @@ -442,9 +440,12 @@ pub fn encode_hybrid_agreement_with_cek( let sender_pub_hex = normalize_pubkey_hex(sender_pub) .ok_or_else(|| anyhow::anyhow!("invalid sender pubkey"))?; - // 1–2. Encrypt the body once under the CEK (Section 10.1 steps 1–2). + // 1–2. Encrypt the body once under the CEK (Section 10.1 steps 1–2), then + // glossia-encode the ciphertext so it survives email transport (quote + // prefixes / word-wrap) intact — base64 would break signed reply chains (§5). let ciphertext = crate::crypto::aes_gcm_encrypt_raw(cek, body_plaintext)?; - let body_b64 = general_purpose::STANDARD.encode(&ciphertext); + let (body_encoded, body_decoded_bytes) = crate::email::glossia_encode_bytes(&ciphertext) + .ok_or_else(|| anyhow::anyhow!("glossia encode of agreement body failed"))?; // 3. Wrap the CEK to each recipient, then to self (Section 10.1 step 3, 10.4). let mut recipients: Vec = Vec::with_capacity(recipients_in.len() + 1); @@ -470,9 +471,9 @@ pub fn encode_hybrid_agreement_with_cek( let recipients_body = serialize_recipients(&recipients); let canon_recipients = canonicalize_block(&recipients_body); - // The signed body bytes are the decoded armor body = the ciphertext bytes - // (decode_armor_section base64-decodes the emitted body), matching §4/§4.2. - let body_decoded = &ciphertext; + // The signed body bytes are the canonical decoded armor body — what + // decode_armor_section recovers (glossia-decoded), matching §4/§4.2. + let body_decoded = &body_decoded_bytes; // 4. Optional originator CONSENT over H (Sections 11.2, 11.3.1). let (consent_body, canon_consent) = if originator_consents { @@ -499,7 +500,7 @@ pub fn encode_hybrid_agreement_with_cek( // NIP-44 (spec Sections 8, 10.5). let mut out = String::new(); out.push_str("----- BEGIN NOSTR ENCRYPTED BODY -----\n"); - out.push_str(&body_b64); + out.push_str(&body_encoded); out.push('\n'); out.push_str("----- BEGIN NOSTR RECIPIENTS -----\n"); out.push_str(&recipients_body); @@ -779,15 +780,15 @@ mod tests { /// Recover the body from an encoded message as a given reader (by unwrapping /// the matching RECIPIENTS stanza and AES-GCM-decrypting). Returns the bytes. fn reader_recovers_body(armor: &str, reader_priv: &str, reader_pub_hex: &str, sender_pub_hex: &str) -> Vec { - // Extract the base64 body (line after the ENCRYPTED BODY BEGIN). - let body_b64 = armor + // Extract the (glossia-encoded) body region and decode it to ciphertext. + let body_region: String = armor .lines() .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")) - .nth(1) - .expect("body line") - .trim() - .to_string(); - let ciphertext = general_purpose::STANDARD.decode(body_b64).expect("b64 body"); + .skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR") && !l.contains("END NOSTR")) + .collect::>() + .join("\n"); + let ciphertext = crate::email::decode_armor_section(&body_region).expect("decode body"); // Extract the RECIPIENTS block body. let recip_body: String = armor @@ -877,10 +878,12 @@ mod tests { let consent = parse_consent_block(&consent_body).expect("consent parses"); assert_eq!(normalize_pubkey_hex(&consent.signer).unwrap(), sender_pub_hex); - // The consent's H must equal SHA-256(ciphertext || canonical(recipients)). - let body_b64 = armor.lines() - .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")).nth(1).unwrap().trim(); - let ciphertext = general_purpose::STANDARD.decode(body_b64).unwrap(); + // The consent's H must equal SHA-256(decode(body) || canonical(recipients)). + let body_region: String = armor.lines() + .skip_while(|l| !l.contains("BEGIN NOSTR ENCRYPTED BODY")).skip(1) + .take_while(|l| !l.contains("BEGIN NOSTR") && !l.contains("END NOSTR")) + .collect::>().join("\n"); + let ciphertext = crate::email::decode_armor_section(&body_region).unwrap(); let recip_body: String = armor.lines() .skip_while(|l| !l.contains("BEGIN NOSTR RECIPIENTS")).skip(1) .take_while(|l| !l.contains("BEGIN NOSTR")).collect::>().join("\n"); diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index d7919c0..f669060 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -689,9 +689,12 @@ pub fn compose_agreement( let armor = crate::agreement::encode_hybrid_agreement_with_cek( &cek, private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, ).map_err(|e| e.to_string())?; - let enc_subject = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) - .map(|ct| general_purpose::STANDARD.encode(ct)) + // Glossia-encode the subject ciphertext too, so the Subject header looks + // like prose and survives header folding (not an opaque base64 blob). + let subject_ct = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) .map_err(|e| format!("subject encrypt failed: {}", e))?; + let (enc_subject, _) = glossia_encode_bytes(&subject_ct) + .ok_or_else(|| "subject glossia encode failed".to_string())?; Ok(crate::types::ComposedAgreement { armor, subject: enc_subject }) } else { let armor = encode_signed_agreement( @@ -731,9 +734,12 @@ fn decrypt_subject_envelope( Ok(c) => c, Err(_) => return (subject.to_string(), None), }; - let ct = match general_purpose::STANDARD.decode(subject.trim()) { - Ok(b) => b, - Err(_) => return (subject.to_string(), None), // not base64 ⇒ cleartext + // The subject ciphertext is glossia-encoded (or base64); decode_armor_section + // handles both. A cleartext subject won't decode to valid ciphertext, so AES + // fails and we fall back to it unchanged. + let ct = match decode_armor_section(subject.trim()) { + Some(b) => b, + None => return (subject.to_string(), None), }; match crate::crypto::aes_gcm_decrypt_raw(&cek, &ct) { Ok(pt) => (String::from_utf8_lossy(&pt).into_owned(), Some(subject.to_string())), @@ -4050,11 +4056,13 @@ pub fn extract_ciphertext_binary(body: &str) -> Vec { body.as_bytes().to_vec() } -/// Glossia-encode plaintext for a `SIGNED BODY` (spec §3.2): returns the encoded -/// words and the canonical decoded bytes the signature is computed over (§4). -/// Uses the english/bip39 body dialect, matching the rest of the decode path. -fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { - let hex_input = glossia::hex_encode(text.as_bytes()); +/// Glossia-encode raw bytes for an armor body: returns the encoded words and the +/// canonical decoded bytes (what `decode_armor_section` will recover, i.e. the +/// bytes a signature is computed over). Glossia — not base64 — is used because +/// its word tokens survive email transport (quote `> ` prefixes, word-wrapping, +/// reflow) that would corrupt base64 and break signed reply chains (spec §5). +pub(crate) fn glossia_encode_bytes(data: &[u8]) -> Option<(String, Vec)> { + let hex_input = glossia::hex_encode(data); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { glossia::encode_into_language( &hex_input, "english", "bip39", "body", @@ -4069,6 +4077,12 @@ fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { Some((encoded, canonical)) } +/// Glossia-encode plaintext for a `SIGNED BODY` (spec §3.2): returns the encoded +/// words and the canonical decoded bytes the signature is computed over (§4). +fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { + glossia_encode_bytes(text.as_bytes()) +} + /// Compose a **plaintext (public) agreement** — the unencrypted counterpart to /// `agreement::encode_hybrid_agreement` (spec §11.8). The terms are carried in a /// signed `SIGNED BODY` (readable by any client, with the plaintext also above @@ -6225,6 +6239,26 @@ nitela\n\ assert!(err.is_err()); } + #[test] + fn test_glossia_body_survives_quote_prefix_and_wrap() { + // The reason agreements use glossia, not base64: a glossia-encoded body + // must decode to identical bytes after an email client quote-prefixes + // ("> ") and re-wraps it — base64 would be corrupted by the "> ". + let ciphertext: Vec = (0u8..64).collect(); + let (encoded, canonical) = glossia_encode_bytes(&ciphertext).unwrap(); + assert_eq!(canonical, ciphertext, "glossia round-trips the bytes"); + + let words: Vec<&str> = encoded.split_whitespace().collect(); + let mangled: String = words + .chunks(6) + .map(|w| format!("> {}", w.join(" "))) + .collect::>() + .join("\n"); + let recovered = decode_armor_section(&mangled) + .expect("decode quote-prefixed, re-wrapped glossia body"); + assert_eq!(recovered, ciphertext, "glossia body survives quoting + wrapping"); + } + // ============================================= // parse_armor_components tests // ============================================= From a2a813caa7e6259693105084e6a41fcb3f32acd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 07:15:15 +0000 Subject: [PATCH 14/44] feat(agreement): honor the user's Advanced glossia encoding setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agreement encoders hardcoded english/bip39; they now use the encoding scheme from the user's Advanced settings (the same glossia_encoding string the rest of the signing path uses, e.g. "latin", "english - bip39"). * glossia_lang_wordlist — shared encoding-name → (language, wordlist) map (None ⇒ english/bip39); glossia_roundtrip_to_bytes now routes through it * glossia_encode_bytes_with(data, encoding) — encode an armor body under the chosen scheme; encode_signed_agreement / encode_hybrid_agreement[_with_cek] thread an `encoding: Option<&str>` through to body AND subject * compose_agreement / send_agreement_email take the encoding; the compose_agreement / send_agreement IPC commands gain a glossia_encoding arg * decoding is unaffected — the dialect is auto-detected — so only the encode side needs the setting Tests: latin vs english produce different encodings but identical decoded bytes; a composed agreement under each scheme still verifies. Full lib suite: 255. --- tauri-app/backend/src/agreement.rs | 15 ++-- tauri-app/backend/src/email.rs | 136 +++++++++++++++++------------ tauri-app/backend/src/lib.rs | 4 + 3 files changed, 95 insertions(+), 60 deletions(-) diff --git a/tauri-app/backend/src/agreement.rs b/tauri-app/backend/src/agreement.rs index 873b91b..ae5c902 100644 --- a/tauri-app/backend/src/agreement.rs +++ b/tauri-app/backend/src/agreement.rs @@ -411,17 +411,19 @@ pub fn encode_hybrid_agreement( body_plaintext: &[u8], recipients_in: &[AgreementRecipientInput], originator_consents: bool, + encoding: Option<&str>, ) -> Result { let cek = crate::crypto::generate_cek(); encode_hybrid_agreement_with_cek( - &cek, sender_priv, sender_pub, sender_email, profile_name, body_plaintext, recipients_in, originator_consents, + &cek, sender_priv, sender_pub, sender_email, profile_name, body_plaintext, recipients_in, originator_consents, encoding, ) } /// Like [`encode_hybrid_agreement`] but uses a caller-supplied CEK, so the same /// key can also encrypt out-of-band material (e.g. the email subject) under one /// envelope. The caller is responsible for generating a fresh random CEK -/// ([`crate::crypto::generate_cek`]) per message. +/// ([`crate::crypto::generate_cek`]) per message. `encoding` is the user's +/// Advanced glossia scheme (`None` ⇒ default). pub fn encode_hybrid_agreement_with_cek( cek: &[u8; 32], sender_priv: &str, @@ -431,6 +433,7 @@ pub fn encode_hybrid_agreement_with_cek( body_plaintext: &[u8], recipients_in: &[AgreementRecipientInput], originator_consents: bool, + encoding: Option<&str>, ) -> Result { if recipients_in.is_empty() { return Err(anyhow::anyhow!( @@ -444,7 +447,7 @@ pub fn encode_hybrid_agreement_with_cek( // glossia-encode the ciphertext so it survives email transport (quote // prefixes / word-wrap) intact — base64 would break signed reply chains (§5). let ciphertext = crate::crypto::aes_gcm_encrypt_raw(cek, body_plaintext)?; - let (body_encoded, body_decoded_bytes) = crate::email::glossia_encode_bytes(&ciphertext) + let (body_encoded, body_decoded_bytes) = crate::email::glossia_encode_bytes_with(&ciphertext, encoding) .ok_or_else(|| anyhow::anyhow!("glossia encode of agreement body failed"))?; // 3. Wrap the CEK to each recipient, then to self (Section 10.1 step 3, 10.4). @@ -823,7 +826,7 @@ mod tests { AgreementRecipientInput { role: ROLE_VIEWER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.org".into()) }, ]; let armor = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", body, &recips, false, + &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", body, &recips, false, None, ).unwrap(); // Structure: generic encrypted body, recipients (incl. self last), no consent. @@ -864,7 +867,7 @@ mod tests { AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: alice.public_key.clone(), email: None }, ]; let armor = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, None, "Originator", body, &recips, true, + &sender.private_key, &sender.public_key, None, "Originator", body, &recips, true, None, ).unwrap(); assert!(armor.contains("BEGIN NOSTR CONSENT")); @@ -895,7 +898,7 @@ mod tests { fn test_encode_hybrid_agreement_rejects_empty_recipients() { let sender = crate::crypto::generate_keypair().unwrap(); let err = encode_hybrid_agreement( - &sender.private_key, &sender.public_key, None, "X", b"x", &[], false); + &sender.private_key, &sender.public_key, None, "X", b"x", &[], false, None); assert!(err.is_err()); } } diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index f669060..3d42ff7 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -647,8 +647,9 @@ async fn send_message_with_timeout(mailer: SmtpTransport, email: Message) -> Res /// originator's own CONSENT over `H` (§11.2). /// /// In encrypted mode the **subject is AES-256-GCM-encrypted under the same CEK -/// as the body** (base64), so it is readable by exactly the recipients and never -/// leaks in cleartext; in plaintext mode the subject is returned unchanged. +/// as the body** and glossia-encoded, so it is readable by exactly the recipients +/// and never leaks in cleartext; in plaintext mode the subject is returned +/// unchanged. `encoding` is the user's Advanced glossia scheme (`None` ⇒ default). /// SMTP/MIME assembly is the caller's job, so this is unit-testable. pub fn compose_agreement( private_key: &str, @@ -661,6 +662,7 @@ pub fn compose_agreement( cc: &[crate::types::AgreementParty], encrypted: bool, originator_consents: bool, + encoding: Option<&str>, ) -> Result { use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; @@ -687,18 +689,18 @@ pub fn compose_agreement( // One CEK for the body and the subject. let cek = crate::crypto::generate_cek(); let armor = crate::agreement::encode_hybrid_agreement_with_cek( - &cek, private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, + &cek, private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, encoding, ).map_err(|e| e.to_string())?; - // Glossia-encode the subject ciphertext too, so the Subject header looks - // like prose and survives header folding (not an opaque base64 blob). + // Glossia-encode the subject ciphertext too (same scheme as the body), so + // the Subject header reads as prose and survives header folding. let subject_ct = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) .map_err(|e| format!("subject encrypt failed: {}", e))?; - let (enc_subject, _) = glossia_encode_bytes(&subject_ct) + let (enc_subject, _) = glossia_encode_bytes_with(&subject_ct, encoding) .ok_or_else(|| "subject glossia encode failed".to_string())?; Ok(crate::types::ComposedAgreement { armor, subject: enc_subject }) } else { let armor = encode_signed_agreement( - private_key, sender_pub, profile_name, body, &recips, originator_consents, + private_key, sender_pub, profile_name, body, &recips, originator_consents, encoding, )?; // Public agreement: subject stays in the clear. Ok(crate::types::ComposedAgreement { armor, subject: subject.to_string() }) @@ -765,6 +767,7 @@ pub async fn send_agreement_email( references: Option<&str>, include_pubkey_header: bool, include_sig_header: bool, + encoding: Option<&str>, ) -> Result { let private_key = config.private_key.as_deref() .ok_or_else(|| anyhow::anyhow!("no private key configured; cannot sign the agreement"))?; @@ -772,7 +775,7 @@ pub async fn send_agreement_email( .map_err(|e| anyhow::anyhow!("could not derive sender pubkey: {}", e))?; let composed = compose_agreement( - private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, + private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, encoding, ).map_err(|e| anyhow::anyhow!("{}", e))?; let armor = composed.armor; @@ -1916,34 +1919,22 @@ fn try_glossia_decode_to_bytes(text: &str) -> Option> { } } +/// Map a frontend encoding-scheme name (the user's Advanced "encoding" setting) +/// to glossia `(language, wordlist)`. `None`/empty defaults to english/bip39. +pub(crate) fn glossia_lang_wordlist(encoding: Option<&str>) -> (String, String) { + match encoding.unwrap_or("").to_lowercase().as_str() { + "" | "english" | "english - bip39" | "bip39" => ("english".to_string(), "bip39".to_string()), + "latin" => ("latin".to_string(), "default".to_string()), + other => (other.to_string(), "default".to_string()), + } +} + /// Glossia round-trip: encode plaintext bytes into the given language, then decode back /// to get canonical bytes. This ensures signature verification survives transport /// (word-wrap, quote prefixes, etc.) because the signature is on the decoded binary. /// Returns None if glossia encode/decode fails (caller should fall back to raw UTF-8 bytes). pub fn glossia_roundtrip_to_bytes(text: &str, encoding: &str) -> Option> { - let hex_input = glossia::hex_encode(text.as_bytes()); - // Map frontend encoding names to glossia parameters - let encoding_lower = encoding.to_lowercase(); - let (language, wordlist) = match encoding_lower.as_str() { - "latin" => ("latin", "default"), - "english" | "english - bip39" => ("english", "bip39"), - _ => (encoding_lower.as_str(), "default"), - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - glossia::encode_into_language( - &hex_input, language, wordlist, "body", - None, 42, false, None, None, None, None, - ) - })); - let encoded = match result { - Ok(Ok((encoded_text, _, _, _))) => encoded_text, - _ => { - debug_log!("[RUST] glossia_roundtrip_to_bytes: encode failed for encoding={}", encoding); - return None; - } - }; - // Decode back to bytes - try_glossia_decode_to_bytes(&encoded) + glossia_encode_bytes_with(text.as_bytes(), Some(encoding)).map(|(_, bytes)| bytes) } /// Decode a single section of armor body content (non-quoted lines only) to bytes. @@ -4056,16 +4047,19 @@ pub fn extract_ciphertext_binary(body: &str) -> Vec { body.as_bytes().to_vec() } -/// Glossia-encode raw bytes for an armor body: returns the encoded words and the -/// canonical decoded bytes (what `decode_armor_section` will recover, i.e. the -/// bytes a signature is computed over). Glossia — not base64 — is used because -/// its word tokens survive email transport (quote `> ` prefixes, word-wrapping, -/// reflow) that would corrupt base64 and break signed reply chains (spec §5). -pub(crate) fn glossia_encode_bytes(data: &[u8]) -> Option<(String, Vec)> { +/// Glossia-encode raw bytes for an armor body using the given encoding scheme +/// (the user's Advanced "encoding" setting; `None` ⇒ english/bip39). Returns the +/// encoded words and the canonical decoded bytes (what `decode_armor_section` +/// recovers, i.e. the bytes a signature is computed over). Glossia — not base64 — +/// is used because its word tokens survive email transport (quote `> ` prefixes, +/// word-wrapping, reflow) that would corrupt base64 and break signed reply chains +/// (spec §5). Decoding auto-detects the dialect, so only encoding needs the setting. +pub(crate) fn glossia_encode_bytes_with(data: &[u8], encoding: Option<&str>) -> Option<(String, Vec)> { let hex_input = glossia::hex_encode(data); + let (language, wordlist) = glossia_lang_wordlist(encoding); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { glossia::encode_into_language( - &hex_input, "english", "bip39", "body", + &hex_input, &language, &wordlist, "body", None, 42, false, None, None, None, None, ) })); @@ -4079,8 +4073,8 @@ pub(crate) fn glossia_encode_bytes(data: &[u8]) -> Option<(String, Vec)> { /// Glossia-encode plaintext for a `SIGNED BODY` (spec §3.2): returns the encoded /// words and the canonical decoded bytes the signature is computed over (§4). -fn glossia_encode_signed_body(text: &str) -> Option<(String, Vec)> { - glossia_encode_bytes(text.as_bytes()) +fn glossia_encode_signed_body(text: &str, encoding: Option<&str>) -> Option<(String, Vec)> { + glossia_encode_bytes_with(text.as_bytes(), encoding) } /// Compose a **plaintext (public) agreement** — the unencrypted counterpart to @@ -4107,6 +4101,7 @@ pub fn encode_signed_agreement( terms_plaintext: &str, recipients_in: &[crate::agreement::AgreementRecipientInput], originator_consents: bool, + encoding: Option<&str>, ) -> Result { use crate::agreement::{ canonicalize_block, document_hash, level_signing_bytes, serialize_recipients, @@ -4118,7 +4113,7 @@ pub fn encode_signed_agreement( let sender_pub_hex = crate::agreement::normalize_pubkey_hex(sender_pub) .ok_or_else(|| "invalid sender pubkey".to_string())?; - let (encoded_body, canonical_bytes) = glossia_encode_signed_body(terms_plaintext) + let (encoded_body, canonical_bytes) = glossia_encode_signed_body(terms_plaintext, encoding) .ok_or_else(|| "glossia encode of agreement terms failed".to_string())?; // Signatories/viewers with no key wrap (plaintext): role pubkey [email]. @@ -5769,7 +5764,7 @@ nitela\n\ ]; let armor = encode_hybrid_agreement( &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", - b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, None, ).unwrap(); assert_eq!(verify_email_signature_inline(&armor), Some(true), @@ -5800,7 +5795,7 @@ nitela\n\ ]; let armor = encode_hybrid_agreement( &sender.private_key, &sender.public_key, Some("me@example.net"), "Originator", - b"terms", &recips, false, + b"terms", &recips, false, None, ).unwrap(); assert_eq!(verify_email_signature_inline(&armor), Some(true)); @@ -5838,7 +5833,7 @@ nitela\n\ }]; encode_hybrid_agreement( &alice.private_key, &alice.public_key, Some("alice@issuer.example"), - "Alice", b"Please confirm you control this address.", &recips, false, + "Alice", b"Please confirm you control this address.", &recips, false, None, ).unwrap() } @@ -5907,7 +5902,7 @@ nitela\n\ }]; encode_hybrid_agreement( &alice.private_key, &alice.public_key, None, "Alice", - b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, + b"This Mutual NDA is entered into as of 2026-06-13.", &recips, true, None, ).unwrap() } @@ -6004,7 +5999,7 @@ nitela\n\ ]; let armor = encode_hybrid_agreement( &alice.private_key, &alice.public_key, Some("alice@issuer.example"), - "Alice", plaintext.as_bytes(), &recips, false, + "Alice", plaintext.as_bytes(), &recips, false, None, ).unwrap(); // Bob (signer) recovers the body. @@ -6040,7 +6035,7 @@ nitela\n\ role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: None, }]; let armor = encode_hybrid_agreement( - &alice.private_key, &alice.public_key, None, "Alice", b"secret terms", &recips, false, + &alice.private_key, &alice.public_key, None, "Alice", b"secret terms", &recips, false, None, ).unwrap(); let r = decrypt_email_body_pipeline( @@ -6058,7 +6053,7 @@ nitela\n\ role: ROLE_SIGNER.into(), pubkey: bob.public_key.clone(), email: Some("bob@example.com".into()), }]; let armor = encode_hybrid_agreement( - &alice.private_key, &alice.public_key, Some("alice@x.example"), "Alice", b"terms", &recips, true, + &alice.private_key, &alice.public_key, Some("alice@x.example"), "Alice", b"terms", &recips, true, None, ).unwrap(); let parsed = parse_armor_components(&armor).expect("parses"); @@ -6077,12 +6072,12 @@ nitela\n\ let recips = vec![AgreementRecipientInput { role: ROLE_SIGNER.into(), pubkey: bob_pub.to_string(), email: None, }]; - encode_signed_agreement(&alice.private_key, &alice.public_key, "Alice", terms, &recips, true).unwrap() + encode_signed_agreement(&alice.private_key, &alice.public_key, "Alice", terms, &recips, true, None).unwrap() } fn build_plaintext_consent_reply(responder: &crate::types::KeyPair, prior_armor: &str, h: &str) -> String { let responder_hex = crate::agreement::normalize_pubkey_hex(&responder.public_key).unwrap(); - let (reply_glossia, _) = glossia_encode_signed_body("I agree to the terms.").unwrap(); + let (reply_glossia, _) = glossia_encode_signed_body("I agree to the terms.", None).unwrap(); let prior_only = &prior_armor[prior_armor.find("----- BEGIN NOSTR").unwrap()..]; let template = format!( "I agree to the terms.\n\n----- BEGIN NOSTR SIGNED BODY -----\n{}\n\ @@ -6174,7 +6169,7 @@ nitela\n\ let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; let composed = compose_agreement( &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", - "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, + "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, None, ).unwrap(); let armor = composed.armor; @@ -6216,7 +6211,7 @@ nitela\n\ let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; let composed = compose_agreement( &alice.private_key, &alice_pub, None, "Alice", - "Public Statement", "We agree to the public terms.", &to, &[], false, true, + "Public Statement", "We agree to the public terms.", &to, &[], false, true, None, ).unwrap(); let armor = composed.armor; @@ -6235,17 +6230,50 @@ nitela\n\ let alice = crypto::generate_keypair().unwrap(); let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); let err = compose_agreement( - &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false); + &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false, None); assert!(err.is_err()); } + #[test] + fn test_glossia_encode_bytes_honors_encoding_scheme() { + // The Advanced "encoding" setting must change the dialect used, while + // round-tripping to identical bytes. + let data: Vec = (0u8..48).collect(); + let (latin, latin_bytes) = glossia_encode_bytes_with(&data, Some("latin")).unwrap(); + let (english, english_bytes) = glossia_encode_bytes_with(&data, Some("english - bip39")).unwrap(); + assert_eq!(latin_bytes, data); + assert_eq!(english_bytes, data); + assert_ne!(latin, english, "different schemes must produce different encodings"); + } + + #[test] + fn test_compose_agreement_applies_encoding_setting() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + + // Compose the same plaintext agreement under two schemes; the encoded + // bodies must differ, yet both verify and decrypt. + let latin = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("latin"), + ).unwrap(); + let english = compose_agreement( + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("english - bip39"), + ).unwrap(); + assert_ne!(latin.armor, english.armor, "encoding setting must change the armor body"); + assert_eq!(verify_email_signature_inline(&latin.armor), Some(true)); + assert_eq!(verify_email_signature_inline(&english.armor), Some(true)); + } + #[test] fn test_glossia_body_survives_quote_prefix_and_wrap() { // The reason agreements use glossia, not base64: a glossia-encoded body // must decode to identical bytes after an email client quote-prefixes // ("> ") and re-wraps it — base64 would be corrupted by the "> ". let ciphertext: Vec = (0u8..64).collect(); - let (encoded, canonical) = glossia_encode_bytes(&ciphertext).unwrap(); + let (encoded, canonical) = glossia_encode_bytes_with(&ciphertext, None).unwrap(); assert_eq!(canonical, ciphertext, "glossia round-trips the bytes"); let words: Vec<&str> = encoded.split_whitespace().collect(); diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index 8ecd835..aee147e 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -2096,6 +2096,7 @@ async fn compose_agreement( encrypted: bool, originator_consents: bool, profile_name: Option, + glossia_encoding: Option, state: tauri::State<'_, AppState>, ) -> Result { email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); @@ -2112,6 +2113,7 @@ async fn compose_agreement( &cc, encrypted, originator_consents, + glossia_encoding.as_deref(), ) } @@ -2133,6 +2135,7 @@ async fn send_agreement( references: Option, include_pubkey_header: Option, include_sig_header: Option, + glossia_encoding: Option, state: tauri::State<'_, AppState>, ) -> Result<(), String> { email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); @@ -2152,6 +2155,7 @@ async fn send_agreement( references.as_deref(), include_pubkey, include_sig, + glossia_encoding.as_deref(), ) .await .map_err(|e| e.to_string())?; From ec00d09cc081427bad5dbf573769e69d0c04ace4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 08:04:21 +0000 Subject: [PATCH 15/44] feat(agreement): independent subject encoding setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated subject_encoding scheme, separate from the body encoding, for the agreement subject. Defaults to the body's encoding when unset, so a single setting still drives both. * compose_agreement / send_agreement_email take subject_encoding: Option<&str>; the encrypted subject is glossia-encoded under subject_encoding.or(encoding) * compose_agreement / send_agreement IPC commands gain a subject_encoding arg * spec §5: Body and Subject are now listed as independent encoding settings (subject defaults to body); §11.2 updated Tests: body in latin + subject in english both decrypt for the recipient; subject_encoding=None falls back to the body scheme. Full lib suite: 256. --- docs/nostr-mail-spec.md | 7 ++-- tauri-app/backend/src/email.rs | 64 ++++++++++++++++++++++++++++------ tauri-app/backend/src/lib.rs | 4 +++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/nostr-mail-spec.md b/docs/nostr-mail-spec.md index 0244986..e1a3b6b 100644 --- a/docs/nostr-mail-spec.md +++ b/docs/nostr-mail-spec.md @@ -306,9 +306,10 @@ This preserves the flat-concatenation, chain-of-custody property of Section 3.5: ## 5. Content Encoding (Glossia) -Body content, signatures, and pubkeys may be encoded using the Glossia steganographic encoding system. Each field has an independent encoding setting: +Body content, subjects, signatures, and pubkeys may be encoded using the Glossia steganographic encoding system. Each field has an independent encoding setting: -- **Body/Subject**: Glossia prose encoding (e.g., Latin, BIP39, or hex) +- **Body**: Glossia prose encoding (e.g., Latin, BIP39, or hex) +- **Subject**: Independent encoding setting (defaults to the body's when unset) - **Signature**: Independent encoding setting - **Pubkey**: Independent encoding setting @@ -652,7 +653,7 @@ The role is a **workflow** attribute, not an access attribute — every role can An agreement is initiated as a signed multi-recipient message: - **Body**: the agreement cover text / terms, in a signed `ENCRYPTED BODY` (the envelope is signalled by the RECIPIENTS block, not a keyword) — or, for a **plaintext (public) agreement**, a signed `SIGNED BODY` with the terms in the clear (Section 11.8). -- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body and **glossia-encoded** (so it reads as prose and survives header folding), so it is readable by exactly the recipients and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity), matching the body/subject split of Section 5. +- **Subject**: for an encrypted agreement the `Subject:` header is AES-256-GCM-encrypted under the **same CEK** as the body and **glossia-encoded** under the subject's own encoding setting (Section 5; defaults to the body's), so it reads as prose, survives header folding, is readable by exactly the recipients, and never leaks in cleartext; a reader recovers it by unwrapping the CEK from their RECIPIENTS stanza. For a plaintext (public) agreement the subject is sent in the clear. The subject is metadata and is **not** covered by the signature or `H` (GCM provides its own integrity). - **Attachment(s)**: the contract document(s), encrypted under the same CEK (existing hybrid-attachment path). - **RECIPIENTS**: a `signer` stanza for each required signatory, a `viewer` stanza for each viewer, and the `self` stanza. - **CONSENT** (optional): if the originator is themselves a required signatory, they include their own CONSENT block (Section 11.3) declaring consent. The originator's `self` stanza handles only decryption access and is never counted as a consent (Section 10.3) — an originator who signs MUST do so via a CONSENT block. diff --git a/tauri-app/backend/src/email.rs b/tauri-app/backend/src/email.rs index 3d42ff7..5cd1fa7 100644 --- a/tauri-app/backend/src/email.rs +++ b/tauri-app/backend/src/email.rs @@ -649,8 +649,10 @@ async fn send_message_with_timeout(mailer: SmtpTransport, email: Message) -> Res /// In encrypted mode the **subject is AES-256-GCM-encrypted under the same CEK /// as the body** and glossia-encoded, so it is readable by exactly the recipients /// and never leaks in cleartext; in plaintext mode the subject is returned -/// unchanged. `encoding` is the user's Advanced glossia scheme (`None` ⇒ default). -/// SMTP/MIME assembly is the caller's job, so this is unit-testable. +/// unchanged. `encoding` is the user's Advanced glossia scheme for the body; +/// `subject_encoding` is the (independent) scheme for the subject, falling back +/// to `encoding` when `None`. SMTP/MIME assembly is the caller's job, so this is +/// unit-testable. pub fn compose_agreement( private_key: &str, sender_pub: &str, @@ -663,6 +665,7 @@ pub fn compose_agreement( encrypted: bool, originator_consents: bool, encoding: Option<&str>, + subject_encoding: Option<&str>, ) -> Result { use crate::agreement::{AgreementRecipientInput, ROLE_SIGNER, ROLE_VIEWER}; @@ -691,11 +694,12 @@ pub fn compose_agreement( let armor = crate::agreement::encode_hybrid_agreement_with_cek( &cek, private_key, sender_pub, sender_email, profile_name, body.as_bytes(), &recips, originator_consents, encoding, ).map_err(|e| e.to_string())?; - // Glossia-encode the subject ciphertext too (same scheme as the body), so - // the Subject header reads as prose and survives header folding. + // Glossia-encode the subject ciphertext too, under its own scheme + // (defaulting to the body's), so the Subject header reads as prose and + // survives header folding. let subject_ct = crate::crypto::aes_gcm_encrypt_raw(&cek, subject.as_bytes()) .map_err(|e| format!("subject encrypt failed: {}", e))?; - let (enc_subject, _) = glossia_encode_bytes_with(&subject_ct, encoding) + let (enc_subject, _) = glossia_encode_bytes_with(&subject_ct, subject_encoding.or(encoding)) .ok_or_else(|| "subject glossia encode failed".to_string())?; Ok(crate::types::ComposedAgreement { armor, subject: enc_subject }) } else { @@ -768,6 +772,7 @@ pub async fn send_agreement_email( include_pubkey_header: bool, include_sig_header: bool, encoding: Option<&str>, + subject_encoding: Option<&str>, ) -> Result { let private_key = config.private_key.as_deref() .ok_or_else(|| anyhow::anyhow!("no private key configured; cannot sign the agreement"))?; @@ -775,7 +780,7 @@ pub async fn send_agreement_email( .map_err(|e| anyhow::anyhow!("could not derive sender pubkey: {}", e))?; let composed = compose_agreement( - private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, encoding, + private_key, &sender_pub, Some(&config.email_address), profile_name, subject, body, to, cc, encrypted, originator_consents, encoding, subject_encoding, ).map_err(|e| anyhow::anyhow!("{}", e))?; let armor = composed.armor; @@ -6169,7 +6174,7 @@ nitela\n\ let cc = vec![AgreementParty { email: "carol@example.org".into(), pubkey: carol.public_key.clone() }]; let composed = compose_agreement( &alice.private_key, &alice_pub, Some("alice@me.example"), "Alice", - "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, None, + "Quarterly NDA", "Mutual NDA terms.", &to, &cc, true, true, None, None, ).unwrap(); let armor = composed.armor; @@ -6211,7 +6216,7 @@ nitela\n\ let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; let composed = compose_agreement( &alice.private_key, &alice_pub, None, "Alice", - "Public Statement", "We agree to the public terms.", &to, &[], false, true, None, + "Public Statement", "We agree to the public terms.", &to, &[], false, true, None, None, ).unwrap(); let armor = composed.armor; @@ -6230,7 +6235,7 @@ nitela\n\ let alice = crypto::generate_keypair().unwrap(); let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); let err = compose_agreement( - &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false, None); + &alice.private_key, &alice_pub, None, "Alice", "Subj", "terms", &[], &[], true, false, None, None); assert!(err.is_err()); } @@ -6257,16 +6262,53 @@ nitela\n\ // Compose the same plaintext agreement under two schemes; the encoded // bodies must differ, yet both verify and decrypt. let latin = compose_agreement( - &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("latin"), + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("latin"), None, ).unwrap(); let english = compose_agreement( - &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("english - bip39"), + &alice.private_key, &alice_pub, None, "Alice", "Subj", "Public terms.", &to, &[], false, true, Some("english - bip39"), None, ).unwrap(); assert_ne!(latin.armor, english.armor, "encoding setting must change the armor body"); assert_eq!(verify_email_signature_inline(&latin.armor), Some(true)); assert_eq!(verify_email_signature_inline(&english.armor), Some(true)); } + #[test] + fn test_compose_agreement_independent_subject_encoding() { + use crate::types::AgreementParty; + let alice = crypto::generate_keypair().unwrap(); + let bob = crypto::generate_keypair().unwrap(); + let alice_pub = crypto::get_public_key_from_private(&alice.private_key).unwrap(); + let to = vec![AgreementParty { email: "bob@example.com".into(), pubkey: bob.public_key.clone() }]; + + // Body in latin, subject in english — the subject uses its own scheme, + // and the recipient still recovers both. + let composed = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Quarterly NDA", "Mutual NDA terms.", &to, &[], true, true, + Some("latin"), Some("english - bip39"), + ).unwrap(); + + // Subject is encrypted (not the cleartext) and decrypts back for the recipient. + assert_ne!(composed.subject, "Quarterly NDA"); + let r = decrypt_email_body_pipeline( + &bob.private_key, &composed.armor, &composed.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert!(r.success); + assert_eq!(r.body, "Mutual NDA terms."); + assert_eq!(r.subject, "Quarterly NDA", "subject decrypts regardless of its own scheme"); + + // subject_encoding = None falls back to the body encoding. + let fallback = compose_agreement( + &alice.private_key, &alice_pub, Some("alice@x.example"), "Alice", + "Quarterly NDA", "Mutual NDA terms.", &to, &[], true, true, + Some("latin"), None, + ).unwrap(); + let rf = decrypt_email_body_pipeline( + &bob.private_key, &fallback.armor, &fallback.subject, Some(&alice.public_key), None, None, true, false, + ).unwrap(); + assert_eq!(rf.subject, "Quarterly NDA"); + } + #[test] fn test_glossia_body_survives_quote_prefix_and_wrap() { // The reason agreements use glossia, not base64: a glossia-encoded body diff --git a/tauri-app/backend/src/lib.rs b/tauri-app/backend/src/lib.rs index aee147e..402b98b 100644 --- a/tauri-app/backend/src/lib.rs +++ b/tauri-app/backend/src/lib.rs @@ -2097,6 +2097,7 @@ async fn compose_agreement( originator_consents: bool, profile_name: Option, glossia_encoding: Option, + subject_encoding: Option, state: tauri::State<'_, AppState>, ) -> Result { email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); @@ -2114,6 +2115,7 @@ async fn compose_agreement( encrypted, originator_consents, glossia_encoding.as_deref(), + subject_encoding.as_deref(), ) } @@ -2136,6 +2138,7 @@ async fn send_agreement( include_pubkey_header: Option, include_sig_header: Option, glossia_encoding: Option, + subject_encoding: Option, state: tauri::State<'_, AppState>, ) -> Result<(), String> { email_config.private_key = Some(resolve_private_key(email_config.private_key, &state)?); @@ -2156,6 +2159,7 @@ async fn send_agreement( include_pubkey, include_sig, glossia_encoding.as_deref(), + subject_encoding.as_deref(), ) .await .map_err(|e| e.to_string())?; From 8a8c51b1733cac38354d192935f0165873098113 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 08:44:57 +0000 Subject: [PATCH 16/44] feat(ui): agreement compose + completion status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the agreement workflow, calling the new backend IPC commands. Compose: * "Make this an agreement" toggle in the compose form reveals a Cc (viewers) field, an Encrypted/Public visibility select, and an "I consent" checkbox * sendEmail() routes to sendAgreementCompose() when enabled: To: → signers, Cc: → viewers (§6.3), resolves each email to its contact's Nostr pubkey (errors listing any unknown recipients), then calls send_agreement with the body glossia encoding from settings Status: * renderAgreementStatus() shows an "M of N signatories signed" banner at the top of the email detail (green complete / amber pending), the short document hash, and a per-signatory signed/pending list — computed via agreement_status from the raw armor (works without decrypting; §11.5) * detail-view encryption gate broadened to match the generic envelope tag (BEGIN NOSTR ENCRYPTED BODY) so multi-recipient agreements decrypt, not just pairwise NIP-XX IPC wrappers: composeAgreement, sendAgreement, agreementStatus, verifyEmailBinding (tauri-service.js). New styles/agreements.css. Backend untouched (256 tests still green). JS syntax-checked; needs in-app testing. Follow-ups: list-view lock/subject indicators for the generic envelope tag, and a contact-picker for multi-recipient To/Cc. --- tauri-app/frontend/index.html | 31 +++- tauri-app/frontend/js/email-service.js | 173 ++++++++++++++++++++++- tauri-app/frontend/js/tauri-service.js | 30 ++++ tauri-app/frontend/styles/agreements.css | 77 ++++++++++ 4 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 tauri-app/frontend/styles/agreements.css diff --git a/tauri-app/frontend/index.html b/tauri-app/frontend/index.html index 46a1e59..2b7a710 100644 --- a/tauri-app/frontend/index.html +++ b/tauri-app/frontend/index.html @@ -25,6 +25,7 @@ +