From 1da1c8ceba74983e8333d1150da2a02aa7796591 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Sun, 22 Mar 2026 15:27:30 -0700 Subject: [PATCH 1/7] jacs-mcp/src/jacs_tools.rs:4173-4201: the test test_tools_list_matches_compiled_features asserted that core tool names were "always present" without feature gates, but the nightly CI runs it with --no-default-features --features mcp which excludes core-tools. The fix wraps each tool family's assertions with the matching #[cfg(feature = "...")] guard. Both --features mcp a --- jacs-mcp/src/jacs_tools.rs | 82 +++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/jacs-mcp/src/jacs_tools.rs b/jacs-mcp/src/jacs_tools.rs index 007d7d85..e560aef1 100644 --- a/jacs-mcp/src/jacs_tools.rs +++ b/jacs-mcp/src/jacs_tools.rs @@ -4170,35 +4170,61 @@ mod tests { "tools() count should match total_tool_count()" ); - // Core tools are always present (core-tools is in default features) - assert!(names.contains(&"jacs_sign_state")); - assert!(names.contains(&"jacs_verify_state")); - assert!(names.contains(&"jacs_load_state")); - assert!(names.contains(&"jacs_update_state")); - assert!(names.contains(&"jacs_list_state")); - assert!(names.contains(&"jacs_adopt_state")); - assert!(names.contains(&"jacs_create_agent")); - assert!(names.contains(&"jacs_reencrypt_key")); - assert!(names.contains(&"jacs_audit")); - assert!(names.contains(&"jacs_audit_log")); - assert!(names.contains(&"jacs_audit_query")); - assert!(names.contains(&"jacs_audit_export")); + // Core tool families — present when their feature flag is enabled + // (all included via core-tools in default features) + #[cfg(feature = "state-tools")] + { + assert!(names.contains(&"jacs_sign_state")); + assert!(names.contains(&"jacs_verify_state")); + assert!(names.contains(&"jacs_load_state")); + assert!(names.contains(&"jacs_update_state")); + assert!(names.contains(&"jacs_list_state")); + assert!(names.contains(&"jacs_adopt_state")); + } + + #[cfg(feature = "document-tools")] + { + assert!(names.contains(&"jacs_create_agent")); + assert!(names.contains(&"jacs_sign_document")); + assert!(names.contains(&"jacs_verify_document")); + } + + #[cfg(feature = "key-tools")] + { + assert!(names.contains(&"jacs_reencrypt_key")); + assert!(names.contains(&"jacs_export_agent_card")); + assert!(names.contains(&"jacs_generate_well_known")); + assert!(names.contains(&"jacs_export_agent")); + } + + #[cfg(feature = "audit-tools")] + { + assert!(names.contains(&"jacs_audit")); + assert!(names.contains(&"jacs_audit_log")); + assert!(names.contains(&"jacs_audit_query")); + assert!(names.contains(&"jacs_audit_export")); + } + + #[cfg(feature = "search-tools")] assert!(names.contains(&"jacs_search")); - assert!(names.contains(&"jacs_sign_document")); - assert!(names.contains(&"jacs_verify_document")); - assert!(names.contains(&"jacs_export_agent_card")); - assert!(names.contains(&"jacs_generate_well_known")); - assert!(names.contains(&"jacs_export_agent")); - assert!(names.contains(&"jacs_trust_agent")); - assert!(names.contains(&"jacs_untrust_agent")); - assert!(names.contains(&"jacs_list_trusted_agents")); - assert!(names.contains(&"jacs_is_trusted")); - assert!(names.contains(&"jacs_get_trusted_agent")); - assert!(names.contains(&"jacs_memory_save")); - assert!(names.contains(&"jacs_memory_recall")); - assert!(names.contains(&"jacs_memory_list")); - assert!(names.contains(&"jacs_memory_forget")); - assert!(names.contains(&"jacs_memory_update")); + + #[cfg(feature = "trust-tools")] + { + assert!(names.contains(&"jacs_trust_agent")); + assert!(names.contains(&"jacs_untrust_agent")); + assert!(names.contains(&"jacs_list_trusted_agents")); + assert!(names.contains(&"jacs_is_trusted")); + assert!(names.contains(&"jacs_get_trusted_agent")); + } + + #[cfg(feature = "memory-tools")] + { + assert!(names.contains(&"jacs_memory_save")); + assert!(names.contains(&"jacs_memory_recall")); + assert!(names.contains(&"jacs_memory_list")); + assert!(names.contains(&"jacs_memory_forget")); + assert!(names.contains(&"jacs_memory_update")); + } // Advanced tools conditionally present based on feature flags #[cfg(feature = "messaging-tools")] From 8a5b27383bcdb924592308b7277e5402d7a03b20 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Mon, 23 Mar 2026 21:06:54 -0700 Subject: [PATCH 2/7] email sigs --- jacs/src/email/attachment.rs | 30 ++++++++++++++++++++---------- jacs/src/email/canonicalize.rs | 5 ++++- jacs/src/email/error.rs | 8 ++++---- jacs/src/email/mod.rs | 6 ++++-- jacs/src/email/sign.rs | 28 +++++++++++++++------------- jacs/src/email/verify.rs | 12 ++++++------ 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/jacs/src/email/attachment.rs b/jacs/src/email/attachment.rs index a9f6ab47..926d249d 100644 --- a/jacs/src/email/attachment.rs +++ b/jacs/src/email/attachment.rs @@ -1,6 +1,6 @@ //! JACS attachment operations for raw RFC 5322 email bytes. //! -//! Implements add/get/remove operations for the `jacs-signature.json` +//! Implements add/get/remove operations for the `hai.ai.signature.jacs.json` //! MIME attachment. Works entirely at the raw byte level -- no //! re-serialization libraries are used. @@ -9,7 +9,7 @@ use mail_parser::{MessageParser, MimeHeaders as _}; use super::error::EmailError; /// Name of the active JACS signature attachment. -const JACS_SIGNATURE_FILENAME: &str = "jacs-signature.json"; +pub const JACS_SIGNATURE_FILENAME: &str = "hai.ai.signature.jacs.json"; /// Find the last occurrence of `needle` in `haystack` (byte-level rfind). /// Returns the byte offset of the start of the match, or None. @@ -22,7 +22,7 @@ pub(crate) fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option { .find(|&i| &haystack[i..i + needle.len()] == needle) } -/// Add a `jacs-signature.json` attachment to a raw RFC 5322 email. +/// Add a `hai.ai.signature.jacs.json` attachment to a raw RFC 5322 email. /// /// - If the email is already `multipart/mixed`: insert a new MIME part before the closing boundary. /// - If the email is `multipart/alternative` or single-part: wrap in a new `multipart/mixed` envelope. @@ -60,7 +60,7 @@ pub fn add_jacs_attachment(raw_email: &[u8], doc: &[u8]) -> Result, Emai } } -/// Extract the `jacs-signature.json` attachment from a raw RFC 5322 email. +/// Extract the `hai.ai.signature.jacs.json` attachment from a raw RFC 5322 email. /// /// Returns the raw bytes of the attachment content (MIME-decoded). pub fn get_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { @@ -83,7 +83,7 @@ pub fn get_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { Err(EmailError::MissingJacsSignature) } -/// Remove the `jacs-signature.json` MIME part from a raw email. +/// Remove the `hai.ai.signature.jacs.json` MIME part from a raw email. /// /// Returns the email without the JACS attachment. The result is valid RFC 5322. pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { @@ -408,9 +408,19 @@ fn strip_line_ending(line: &[u8]) -> &[u8] { fn build_jacs_mime_part_bytes(boundary: &str, doc: &[u8]) -> Vec { let mut part = Vec::new(); part.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); - part.extend_from_slice(b"Content-Type: application/json; name=\"jacs-signature.json\"\r\n"); part.extend_from_slice( - b"Content-Disposition: attachment; filename=\"jacs-signature.json\"\r\n", + format!( + "Content-Type: application/json; name=\"{}\"\r\n", + JACS_SIGNATURE_FILENAME + ) + .as_bytes(), + ); + part.extend_from_slice( + format!( + "Content-Disposition: attachment; filename=\"{}\"\r\n", + JACS_SIGNATURE_FILENAME + ) + .as_bytes(), ); part.extend_from_slice(b"Content-Transfer-Encoding: 7bit\r\n"); part.extend_from_slice(b"\r\n"); @@ -477,7 +487,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("jacs-signature.json")); + assert!(result_str.contains("hai.ai.signature.jacs.json")); assert!(result_str.contains(r#"{"test":"doc"}"#)); // Should still be parseable assert!(MessageParser::default().parse(&result).is_some()); @@ -489,7 +499,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("jacs-signature.json")); + assert!(result_str.contains("hai.ai.signature.jacs.json")); // Original content should be preserved assert!(result_str.contains("Plain text") || result_str.contains("

HTML

")); } @@ -500,7 +510,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("jacs-signature.json")); + assert!(result_str.contains("hai.ai.signature.jacs.json")); assert!(result_str.contains("multipart/mixed")); // Original body should be preserved assert!(result_str.contains("Hello World")); diff --git a/jacs/src/email/canonicalize.rs b/jacs/src/email/canonicalize.rs index 1982de33..121de5ac 100644 --- a/jacs/src/email/canonicalize.rs +++ b/jacs/src/email/canonicalize.rs @@ -95,7 +95,10 @@ pub fn extract_email_parts(raw_email: &[u8]) -> Result Result, /// Prepare an email for signing, handling the forwarding case. /// -/// If the email already has a `jacs-signature.json` attachment: +/// If the email already has a `hai.ai.signature.jacs.json` attachment: /// 1. Extract it and compute its SHA-256 hash (becomes parent_signature_hash) -/// 2. Remove the active `jacs-signature.json` -/// 3. Re-attach it as `jacs-signature-{N}.json` where N is the next index +/// 2. Remove the active `hai.ai.signature.jacs.json` +/// 3. Re-attach it as `hai.ai.signature.{N}.jacs.json` where N is the next index /// /// Returns (prepared_email_bytes, parent_signature_hash_option). fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), EmailError> { - // Try to extract the existing jacs-signature.json + // Try to extract the existing JACS signature attachment let jacs_bytes = match get_jacs_attachment(raw_email) { Ok(bytes) => bytes, Err(EmailError::MissingJacsSignature) => { @@ -149,7 +149,7 @@ fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), Err(e) => return Err(e), }; - // Compute parent_signature_hash = sha256(exact bytes of existing jacs-signature.json) + // Compute parent_signature_hash = sha256(exact bytes of existing JACS signature) let parent_hash = { let mut hasher = Sha256::new(); hasher.update(&jacs_bytes); @@ -159,17 +159,19 @@ fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), // Count existing renamed JACS signatures to determine next index let parts = extract_email_parts(raw_email)?; - // Count only the renamed ones (jacs-signature-N.json pattern), - // not the active jacs-signature.json + // Count existing renamed JACS signatures (both old and new naming schemes) let renamed_count = parts .jacs_attachments .iter() - .filter(|a| a.filename.starts_with("jacs-signature-") && a.filename.ends_with(".json")) + .filter(|a| { + (a.filename.starts_with("hai.ai.signature.") && a.filename.ends_with(".jacs.json")) + || (a.filename.starts_with("jacs-signature-") && a.filename.ends_with(".json")) + }) .count(); - let new_name = format!("jacs-signature-{}.json", renamed_count); + let new_name = format!("hai.ai.signature.{}.jacs.json", renamed_count); - // Remove the active jacs-signature.json + // Remove the active JACS signature attachment let email_without_active = remove_jacs_attachment(raw_email)?; // Re-attach it with the new name @@ -461,7 +463,7 @@ mod tests { let email = simple_text_email(); let signed = sign_email(&email, &agent).unwrap(); let signed_str = String::from_utf8_lossy(&signed); - assert!(signed_str.contains("jacs-signature.json")); + assert!(signed_str.contains("hai.ai.signature.jacs.json")); assert!( mail_parser::MessageParser::default() .parse(&signed) diff --git a/jacs/src/email/verify.rs b/jacs/src/email/verify.rs index 576d1f14..f80f8561 100644 --- a/jacs/src/email/verify.rs +++ b/jacs/src/email/verify.rs @@ -45,7 +45,7 @@ pub fn normalize_algorithm(algorithm: &str) -> String { /// payload and parsed email parts. /// /// Steps: -/// 1. Extracts the `jacs-signature.json` attachment +/// 1. Extracts the `hai.ai.signature.jacs.json` attachment /// 2. Removes the JACS attachment (the signature covers the email WITHOUT itself) /// 3. Verifies the JACS document signature using the provided public key /// 4. Extracts the email signature payload from the `content` field @@ -157,14 +157,14 @@ pub fn verify_email_document( /// This is the primary API for email verification. It combines /// cryptographic signature validation and content hash comparison: /// -/// 1. Extracts and verifies the `jacs-signature.json` JACS document +/// 1. Extracts and verifies the `hai.ai.signature.jacs.json` JACS document /// 2. Compares every hash in the trusted JACS document against the actual /// email content (headers, body parts, attachments) /// 3. Returns field-level results showing which fields pass, fail, or were /// modified /// /// # Arguments -/// * `raw_eml` - Raw RFC 5322 email bytes (with `jacs-signature.json` attached) +/// * `raw_eml` - Raw RFC 5322 email bytes (with `hai.ai.signature.jacs.json` attached) /// * `verifier` - Any type implementing [`JacsSigner`] (e.g. `SimpleAgent`) /// * `public_key` - The signer's public key bytes (from registry, trust store, etc.) /// @@ -289,7 +289,7 @@ pub fn verify_email_content( ); // Verify attachments - // For forwarded emails, the renamed jacs-signature-N.json files appear as + // For forwarded emails, the renamed signature files appear as // regular attachments in the current email (parts.jacs_attachments) and // should be included when comparing against the signed attachment list. let mut all_current_attachments = parts.attachments.clone(); @@ -1035,10 +1035,10 @@ mod tests { let renamed = parts .jacs_attachments .iter() - .find(|a| a.filename == "jacs-signature-0.json"); + .find(|a| a.filename == "hai.ai.signature.0.jacs.json"); assert!( renamed.is_some(), - "Expected jacs-signature-0.json attachment, found: {:?}", + "Expected hai.ai.signature.0.jacs.json attachment, found: {:?}", parts .jacs_attachments .iter() From a902741a1f426a5974be9cae6648a793550537df Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Mon, 23 Mar 2026 21:43:59 -0700 Subject: [PATCH 3/7] jacs signature change --- jacs/src/email/attachment.rs | 187 ++++++++++++++++++++++++++++------- jacs/src/email/mod.rs | 6 +- jacs/src/email/sign.rs | 81 ++++++++++++--- 3 files changed, 219 insertions(+), 55 deletions(-) diff --git a/jacs/src/email/attachment.rs b/jacs/src/email/attachment.rs index 926d249d..e0fb7e57 100644 --- a/jacs/src/email/attachment.rs +++ b/jacs/src/email/attachment.rs @@ -8,8 +8,16 @@ use mail_parser::{MessageParser, MimeHeaders as _}; use super::error::EmailError; -/// Name of the active JACS signature attachment. -pub const JACS_SIGNATURE_FILENAME: &str = "hai.ai.signature.jacs.json"; +/// Default name for the JACS signature attachment. +/// +/// JACS is a generic library. Callers (e.g., HAI server, haisdk) may pass +/// their own preferred filename via the `_named` variants of attachment +/// functions. +pub const DEFAULT_JACS_SIGNATURE_FILENAME: &str = "jacs-signature.json"; + +/// Legacy constant alias -- points to the generic JACS default. +/// Use `DEFAULT_JACS_SIGNATURE_FILENAME` for new code. +pub const JACS_SIGNATURE_FILENAME: &str = DEFAULT_JACS_SIGNATURE_FILENAME; /// Find the last occurrence of `needle` in `haystack` (byte-level rfind). /// Returns the byte offset of the start of the match, or None. @@ -22,13 +30,34 @@ pub(crate) fn rfind_bytes(haystack: &[u8], needle: &[u8]) -> Option { .find(|&i| &haystack[i..i + needle.len()] == needle) } -/// Add a `hai.ai.signature.jacs.json` attachment to a raw RFC 5322 email. +/// Add a JACS signature attachment with a custom filename to a raw RFC 5322 email. /// /// - If the email is already `multipart/mixed`: insert a new MIME part before the closing boundary. /// - If the email is `multipart/alternative` or single-part: wrap in a new `multipart/mixed` envelope. /// /// Returns the new raw email bytes with the attachment included. +pub fn add_jacs_attachment_named( + raw_email: &[u8], + doc: &[u8], + filename: &str, +) -> Result, EmailError> { + add_jacs_attachment_inner(raw_email, doc, filename) +} + +/// Add a JACS signature attachment with the default filename to a raw RFC 5322 email. +/// +/// Uses `DEFAULT_JACS_SIGNATURE_FILENAME` (`jacs-signature.json`). +/// For a custom name, use [`add_jacs_attachment_named`]. pub fn add_jacs_attachment(raw_email: &[u8], doc: &[u8]) -> Result, EmailError> { + add_jacs_attachment_inner(raw_email, doc, DEFAULT_JACS_SIGNATURE_FILENAME) +} + +/// Inner implementation: add a JACS attachment with the given filename. +fn add_jacs_attachment_inner( + raw_email: &[u8], + doc: &[u8], + filename: &str, +) -> Result, EmailError> { let message = MessageParser::default().parse(raw_email).ok_or_else(|| { EmailError::InvalidEmailFormat("Cannot parse email for attachment injection".into()) })?; @@ -47,34 +76,34 @@ pub fn add_jacs_attachment(raw_email: &[u8], doc: &[u8]) -> Result, Emai EmailError::InvalidEmailFormat("multipart/mixed without boundary".into()) })? .to_string(); - insert_part_before_closing_boundary(raw_email, &boundary, doc) + insert_part_before_closing_boundary_named(raw_email, &boundary, doc, filename) } Some(ct) if ct.starts_with("multipart/") => { // Wrap in multipart/mixed - wrap_in_multipart_mixed(raw_email, doc) + wrap_in_multipart_mixed_named(raw_email, doc, filename) } _ => { // Single-part email: wrap in multipart/mixed - wrap_in_multipart_mixed(raw_email, doc) + wrap_in_multipart_mixed_named(raw_email, doc, filename) } } } -/// Extract the `hai.ai.signature.jacs.json` attachment from a raw RFC 5322 email. +/// Extract a JACS signature attachment with a custom filename from a raw RFC 5322 email. /// /// Returns the raw bytes of the attachment content (MIME-decoded). -pub fn get_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { +pub fn get_jacs_attachment_named(raw_email: &[u8], filename: &str) -> Result, EmailError> { let message = MessageParser::default().parse(raw_email).ok_or_else(|| { EmailError::InvalidEmailFormat("Cannot parse email for attachment extraction".into()) })?; for part in message.parts.iter() { - let filename = part + let part_filename = part .attachment_name() .or_else(|| part.content_type().and_then(|ct| ct.attribute("name"))); - if let Some(name) = filename { - if name == JACS_SIGNATURE_FILENAME { + if let Some(name) = part_filename { + if name == filename { return Ok(part.contents().to_vec()); } } @@ -83,10 +112,21 @@ pub fn get_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { Err(EmailError::MissingJacsSignature) } -/// Remove the `hai.ai.signature.jacs.json` MIME part from a raw email. +/// Extract the default JACS signature attachment from a raw RFC 5322 email. /// -/// Returns the email without the JACS attachment. The result is valid RFC 5322. -pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { +/// Looks for `DEFAULT_JACS_SIGNATURE_FILENAME` (`jacs-signature.json`). +/// For a custom name, use [`get_jacs_attachment_named`]. +pub fn get_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { + get_jacs_attachment_named(raw_email, DEFAULT_JACS_SIGNATURE_FILENAME) +} + +/// Remove a JACS attachment with a custom filename from a raw email. +/// +/// Returns the email without the named JACS attachment. The result is valid RFC 5322. +pub fn remove_jacs_attachment_named( + raw_email: &[u8], + filename: &str, +) -> Result, EmailError> { let message = MessageParser::default().parse(raw_email).ok_or_else(|| { EmailError::InvalidEmailFormat("Cannot parse email for attachment removal".into()) })?; @@ -94,11 +134,11 @@ pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { // Find the JACS part to remove let mut jacs_part_idx = None; for (idx, part) in message.parts.iter().enumerate() { - let filename = part + let part_filename = part .attachment_name() .or_else(|| part.content_type().and_then(|ct| ct.attribute("name"))); - if let Some(name) = filename { - if name == JACS_SIGNATURE_FILENAME { + if let Some(name) = part_filename { + if name == filename { jacs_part_idx = Some(idx); break; } @@ -143,11 +183,20 @@ pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { Ok(result) } -/// Insert a JACS part before the closing boundary of a multipart/mixed email. -fn insert_part_before_closing_boundary( +/// Remove the default JACS signature attachment from a raw email. +/// +/// Looks for `DEFAULT_JACS_SIGNATURE_FILENAME` (`jacs-signature.json`). +/// For a custom name, use [`remove_jacs_attachment_named`]. +pub fn remove_jacs_attachment(raw_email: &[u8]) -> Result, EmailError> { + remove_jacs_attachment_named(raw_email, DEFAULT_JACS_SIGNATURE_FILENAME) +} + +/// Insert a JACS part before the closing boundary of a multipart/mixed email (named variant). +fn insert_part_before_closing_boundary_named( raw_email: &[u8], boundary: &str, doc: &[u8], + filename: &str, ) -> Result, EmailError> { let closing = format!("--{}--", boundary); @@ -156,7 +205,7 @@ fn insert_part_before_closing_boundary( EmailError::InvalidEmailFormat("Cannot find closing boundary in multipart/mixed".into()) })?; - let jacs_part = build_jacs_mime_part(boundary, doc); + let jacs_part = build_jacs_mime_part_named(boundary, doc, filename); let mut result = Vec::new(); result.extend_from_slice(&raw_email[..closing_pos]); @@ -173,8 +222,12 @@ fn insert_part_before_closing_boundary( Ok(result) } -/// Wrap a non-multipart/mixed email in a new multipart/mixed envelope. -fn wrap_in_multipart_mixed(raw_email: &[u8], doc: &[u8]) -> Result, EmailError> { +/// Wrap a non-multipart/mixed email in a new multipart/mixed envelope (named variant). +fn wrap_in_multipart_mixed_named( + raw_email: &[u8], + doc: &[u8], + filename: &str, +) -> Result, EmailError> { let boundary = generate_boundary(); // Find the header/body boundary @@ -229,7 +282,7 @@ fn wrap_in_multipart_mixed(raw_email: &[u8], doc: &[u8]) -> Result, Emai } // JACS signature as second part - let jacs_part = build_jacs_mime_part_bytes(&boundary, doc); + let jacs_part = build_jacs_mime_part_bytes_named(&boundary, doc, filename); result.extend_from_slice(&jacs_part); // Closing boundary @@ -404,21 +457,17 @@ fn strip_line_ending(line: &[u8]) -> &[u8] { &line[..end] } -/// Build the MIME part for the JACS signature attachment as raw bytes. -fn build_jacs_mime_part_bytes(boundary: &str, doc: &[u8]) -> Vec { +/// Build the MIME part for a JACS signature attachment with a custom filename as raw bytes. +fn build_jacs_mime_part_bytes_named(boundary: &str, doc: &[u8], filename: &str) -> Vec { let mut part = Vec::new(); part.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); part.extend_from_slice( - format!( - "Content-Type: application/json; name=\"{}\"\r\n", - JACS_SIGNATURE_FILENAME - ) - .as_bytes(), + format!("Content-Type: application/json; name=\"{}\"\r\n", filename).as_bytes(), ); part.extend_from_slice( format!( "Content-Disposition: attachment; filename=\"{}\"\r\n", - JACS_SIGNATURE_FILENAME + filename ) .as_bytes(), ); @@ -429,9 +478,14 @@ fn build_jacs_mime_part_bytes(boundary: &str, doc: &[u8]) -> Vec { part } -/// Build the MIME part for the JACS signature attachment. -fn build_jacs_mime_part(boundary: &str, doc: &[u8]) -> String { - let bytes = build_jacs_mime_part_bytes(boundary, doc); +/// Build the MIME part for the JACS signature attachment (default name) as raw bytes. +fn build_jacs_mime_part_bytes(boundary: &str, doc: &[u8]) -> Vec { + build_jacs_mime_part_bytes_named(boundary, doc, DEFAULT_JACS_SIGNATURE_FILENAME) +} + +/// Build the MIME part for the JACS signature attachment (named variant, UTF-8 string). +fn build_jacs_mime_part_named(boundary: &str, doc: &[u8], filename: &str) -> String { + let bytes = build_jacs_mime_part_bytes_named(boundary, doc, filename); // JACS documents are JSON (valid UTF-8), so this conversion is safe. String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) } @@ -487,7 +541,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("hai.ai.signature.jacs.json")); + assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME)); assert!(result_str.contains(r#"{"test":"doc"}"#)); // Should still be parseable assert!(MessageParser::default().parse(&result).is_some()); @@ -499,7 +553,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("hai.ai.signature.jacs.json")); + assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME)); // Original content should be preserved assert!(result_str.contains("Plain text") || result_str.contains("

HTML

")); } @@ -510,7 +564,7 @@ mod tests { let doc = br#"{"test":"doc"}"#; let result = add_jacs_attachment(&email, doc).unwrap(); let result_str = String::from_utf8_lossy(&result); - assert!(result_str.contains("hai.ai.signature.jacs.json")); + assert!(result_str.contains(DEFAULT_JACS_SIGNATURE_FILENAME)); assert!(result_str.contains("multipart/mixed")); // Original body should be preserved assert!(result_str.contains("Hello World")); @@ -599,4 +653,63 @@ mod tests { let parsed = MessageParser::default().parse(&unsigned); assert!(parsed.is_some()); } + + // ===================================================================== + // _named variant tests + // ===================================================================== + + #[test] + fn add_jacs_attachment_named_custom_name() { + let email = simple_text_email(); + let doc = br#"{"test":"custom"}"#; + let result = add_jacs_attachment_named(&email, doc, "hai.ai.signature.jacs.json").unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("hai.ai.signature.jacs.json")); + assert!(result_str.contains(r#"{"test":"custom"}"#)); + } + + #[test] + fn get_jacs_attachment_named_finds_custom() { + let email = simple_text_email(); + let doc = br#"{"version":"2.0"}"#; + let signed = add_jacs_attachment_named(&email, doc, "custom.jacs.json").unwrap(); + let extracted = get_jacs_attachment_named(&signed, "custom.jacs.json").unwrap(); + assert_eq!(extracted, doc); + } + + #[test] + fn get_jacs_attachment_named_misses_wrong_name() { + let email = simple_text_email(); + let doc = br#"{"v":"1"}"#; + let signed = add_jacs_attachment_named(&email, doc, "custom.jacs.json").unwrap(); + // Looking for default name should fail + let result = get_jacs_attachment(&signed); + assert!(result.is_err()); + } + + #[test] + fn default_attachment_name_unchanged() { + // Default functions still use jacs-signature.json + let email = simple_text_email(); + let doc = br#"{"test":"default"}"#; + let result = add_jacs_attachment(&email, doc).unwrap(); + let result_str = String::from_utf8_lossy(&result); + assert!( + result_str.contains("jacs-signature.json"), + "Default should use jacs-signature.json, got: {}", + result_str + ); + } + + #[test] + fn remove_jacs_attachment_named_removes_custom() { + let email = simple_text_email(); + let doc = br#"{"v":"1"}"#; + let signed = add_jacs_attachment_named(&email, doc, "hai.ai.signature.jacs.json").unwrap(); + assert!(get_jacs_attachment_named(&signed, "hai.ai.signature.jacs.json").is_ok()); + + let unsigned = remove_jacs_attachment_named(&signed, "hai.ai.signature.jacs.json").unwrap(); + assert!(get_jacs_attachment_named(&unsigned, "hai.ai.signature.jacs.json").is_err()); + assert!(MessageParser::default().parse(&unsigned).is_some()); + } } diff --git a/jacs/src/email/mod.rs b/jacs/src/email/mod.rs index b92794b1..180ee7ff 100644 --- a/jacs/src/email/mod.rs +++ b/jacs/src/email/mod.rs @@ -54,14 +54,16 @@ pub use types::{ }; // Signing: the primary sender-side function. -pub use sign::{canonicalize_json_rfc8785, sign_email}; +pub use sign::{canonicalize_json_rfc8785, sign_email, sign_email_named}; // Verification: one-call API + two-step API + content-only API. pub use verify::{normalize_algorithm, verify_email, verify_email_content, verify_email_document}; // Attachment operations (needed by HAI API to peek at doc before full verify). pub use attachment::{ - JACS_SIGNATURE_FILENAME, add_jacs_attachment, get_jacs_attachment, remove_jacs_attachment, + DEFAULT_JACS_SIGNATURE_FILENAME, JACS_SIGNATURE_FILENAME, add_jacs_attachment, + add_jacs_attachment_named, get_jacs_attachment, get_jacs_attachment_named, + remove_jacs_attachment, remove_jacs_attachment_named, }; // Canonicalization utilities (needed by fixture conformance tests). diff --git a/jacs/src/email/sign.rs b/jacs/src/email/sign.rs index 58b6be51..97e70a22 100644 --- a/jacs/src/email/sign.rs +++ b/jacs/src/email/sign.rs @@ -1,8 +1,11 @@ //! Email signing implementation for the JACS email system. //! //! Provides `sign_email()` which takes raw RFC 5322 email bytes and any -//! [`JacsSigner`] implementor, and returns the email with a -//! `hai.ai.signature.jacs.json` MIME attachment containing a real JACS document. +//! [`JacsSigner`] implementor, and returns the email with a JACS signature +//! MIME attachment containing a real JACS document. +//! +//! The attachment filename is configurable via `sign_email_named()`. The +//! default `sign_email()` uses `DEFAULT_JACS_SIGNATURE_FILENAME`. //! //! All cryptographic operations are delegated to the [`JacsSigner`] via //! [`JacsSigner::sign_message()`]. The email module only handles hash @@ -11,8 +14,9 @@ use sha2::{Digest, Sha256}; use super::attachment::{ - add_jacs_attachment, ensure_multipart_mixed, get_jacs_attachment, remove_jacs_attachment, - rfind_bytes, + DEFAULT_JACS_SIGNATURE_FILENAME, add_jacs_attachment, add_jacs_attachment_named, + ensure_multipart_mixed, get_jacs_attachment, get_jacs_attachment_named, remove_jacs_attachment, + remove_jacs_attachment_named, rfind_bytes, }; use super::canonicalize::{ canonicalize_body, canonicalize_header, compute_attachment_hash, compute_body_hash, @@ -25,7 +29,20 @@ use super::types::{ use super::JacsSigner; -/// Sign a raw RFC 5322 email and attach a `hai.ai.signature.jacs.json` document. +/// Sign a raw RFC 5322 email with a custom attachment filename. +/// +/// Same as [`sign_email`] but the caller specifies the JACS attachment +/// filename. Use this when you need a branded attachment name (e.g., +/// `"hai.ai.signature.jacs.json"`) instead of the JACS default. +pub fn sign_email_named( + raw_email: &[u8], + signer: &impl JacsSigner, + filename: &str, +) -> Result, EmailError> { + sign_email_inner(raw_email, signer, filename) +} + +/// Sign a raw RFC 5322 email and attach a JACS signature document. /// /// This is the primary sender-side function. It: /// 1. Parses and canonicalizes the email @@ -33,18 +50,27 @@ use super::JacsSigner; /// 3. Creates a real JACS document containing the hash payload via the signer /// 4. Attaches the signed JACS document as a MIME part /// -/// All cryptographic operations are handled by the [`JacsSigner`] — no manual -/// signing, hashing, or key management in this module. +/// Uses `DEFAULT_JACS_SIGNATURE_FILENAME` (`jacs-signature.json`). +/// For a custom name, use [`sign_email_named`]. /// /// Accepts any type implementing [`JacsSigner`], including `SimpleAgent`. /// /// Returns the modified email bytes with the JACS attachment. pub fn sign_email(raw_email: &[u8], signer: &impl JacsSigner) -> Result, EmailError> { + sign_email_inner(raw_email, signer, DEFAULT_JACS_SIGNATURE_FILENAME) +} + +/// Inner implementation of email signing with configurable filename. +fn sign_email_inner( + raw_email: &[u8], + signer: &impl JacsSigner, + filename: &str, +) -> Result, EmailError> { // Step 0: Size check check_email_size(raw_email)?; // Step 0b: Check for existing JACS signature (forwarding case) - let (email_for_signing, parent_signature_hash) = prepare_for_forwarding(raw_email)?; + let (email_for_signing, parent_signature_hash) = prepare_for_forwarding(raw_email, filename)?; // Step 0c: Ensure the email is multipart/mixed BEFORE parsing. // This guarantees that the MIME headers hashed during signing match what @@ -126,21 +152,27 @@ pub fn sign_email(raw_email: &[u8], signer: &impl JacsSigner) -> Result, // Step 6: Create a real JACS document containing the email hash payload let jacs_doc_json = build_jacs_email_document(&payload, signer)?; - // Step 7: Attach via add_jacs_attachment (to the wrapped email) - add_jacs_attachment(&wrapped_email, jacs_doc_json.as_bytes()) + // Step 7: Attach via add_jacs_attachment_named (to the wrapped email) + add_jacs_attachment_named(&wrapped_email, jacs_doc_json.as_bytes(), filename) } /// Prepare an email for signing, handling the forwarding case. /// /// If the email already has a `hai.ai.signature.jacs.json` attachment: /// 1. Extract it and compute its SHA-256 hash (becomes parent_signature_hash) -/// 2. Remove the active `hai.ai.signature.jacs.json` -/// 3. Re-attach it as `hai.ai.signature.{N}.jacs.json` where N is the next index +/// 2. Remove the active attachment +/// 3. Re-attach it with a numbered name for the forwarding chain +/// +/// `active_filename` is the filename to look for as the active signature +/// (e.g., `"jacs-signature.json"` or `"hai.ai.signature.jacs.json"`). /// /// Returns (prepared_email_bytes, parent_signature_hash_option). -fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), EmailError> { +fn prepare_for_forwarding( + raw_email: &[u8], + active_filename: &str, +) -> Result<(Vec, Option), EmailError> { // Try to extract the existing JACS signature attachment - let jacs_bytes = match get_jacs_attachment(raw_email) { + let jacs_bytes = match get_jacs_attachment_named(raw_email, active_filename) { Ok(bytes) => bytes, Err(EmailError::MissingJacsSignature) => { // No existing signature -- not a forward @@ -169,10 +201,11 @@ fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), }) .count(); - let new_name = format!("hai.ai.signature.{}.jacs.json", renamed_count); + // Derive the renamed filename from the active name's base pattern + let new_name = derive_forwarding_name(active_filename, renamed_count); // Remove the active JACS signature attachment - let email_without_active = remove_jacs_attachment(raw_email)?; + let email_without_active = remove_jacs_attachment_named(raw_email, active_filename)?; // Re-attach it with the new name let renamed_email = add_named_jacs_attachment(&email_without_active, &jacs_bytes, &new_name)?; @@ -180,6 +213,22 @@ fn prepare_for_forwarding(raw_email: &[u8]) -> Result<(Vec, Option), Ok((renamed_email, Some(parent_hash))) } +/// Derive a forwarding chain filename from the active filename. +/// +/// For `"hai.ai.signature.jacs.json"` → `"hai.ai.signature.0.jacs.json"`, etc. +/// For `"jacs-signature.json"` → `"jacs-signature-0.json"`, etc. +fn derive_forwarding_name(active_filename: &str, index: usize) -> String { + if active_filename.ends_with(".jacs.json") { + let base = active_filename.trim_end_matches(".jacs.json"); + format!("{}.{}.jacs.json", base, index) + } else if active_filename.ends_with(".json") { + let base = active_filename.trim_end_matches(".json"); + format!("{}-{}.json", base, index) + } else { + format!("{}.{}", active_filename, index) + } +} + /// Add a named JACS attachment to a raw RFC 5322 email. /// Unlike `add_jacs_attachment`, this lets you specify a custom filename. fn add_named_jacs_attachment( From 7fadad0bfb7f9393cdd257db248256e2be36e910 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Mon, 23 Mar 2026 22:00:00 -0700 Subject: [PATCH 4/7] email sigs --- jacs/src/email/attachment.rs | 6 +- jacs/src/email/canonicalize.rs | 6 +- jacs/src/email/mod.rs | 6 +- jacs/src/email/sign.rs | 17 ++-- jacs/src/email/verify.rs | 180 +++++++++++++++++++++++++-------- 5 files changed, 159 insertions(+), 56 deletions(-) diff --git a/jacs/src/email/attachment.rs b/jacs/src/email/attachment.rs index e0fb7e57..1fee05d0 100644 --- a/jacs/src/email/attachment.rs +++ b/jacs/src/email/attachment.rs @@ -1,8 +1,8 @@ //! JACS attachment operations for raw RFC 5322 email bytes. //! -//! Implements add/get/remove operations for the `hai.ai.signature.jacs.json` -//! MIME attachment. Works entirely at the raw byte level -- no -//! re-serialization libraries are used. +//! Implements add/get/remove operations for JACS signature MIME attachments. +//! The attachment filename is configurable -- callers pass their preferred +//! name via `_named` variants. Works entirely at the raw byte level. use mail_parser::{MessageParser, MimeHeaders as _}; diff --git a/jacs/src/email/canonicalize.rs b/jacs/src/email/canonicalize.rs index 121de5ac..0937e626 100644 --- a/jacs/src/email/canonicalize.rs +++ b/jacs/src/email/canonicalize.rs @@ -95,8 +95,10 @@ pub fn extract_email_parts(raw_email: &[u8]) -> Result String { if active_filename.ends_with(".jacs.json") { @@ -512,7 +511,7 @@ mod tests { let email = simple_text_email(); let signed = sign_email(&email, &agent).unwrap(); let signed_str = String::from_utf8_lossy(&signed); - assert!(signed_str.contains("hai.ai.signature.jacs.json")); + assert!(signed_str.contains("jacs-signature.json")); assert!( mail_parser::MessageParser::default() .parse(&signed) diff --git a/jacs/src/email/verify.rs b/jacs/src/email/verify.rs index f80f8561..ba652206 100644 --- a/jacs/src/email/verify.rs +++ b/jacs/src/email/verify.rs @@ -6,7 +6,9 @@ use sha2::{Digest, Sha256}; -use super::attachment::{get_jacs_attachment, remove_jacs_attachment}; +use super::attachment::{ + DEFAULT_JACS_SIGNATURE_FILENAME, get_jacs_attachment_named, remove_jacs_attachment_named, +}; use super::canonicalize::{ canonicalize_body, canonicalize_header, compute_attachment_hash, compute_body_hash, compute_header_entry, compute_mime_headers_hash, extract_email_parts, @@ -38,32 +40,22 @@ pub fn normalize_algorithm(algorithm: &str) -> String { s } -/// Extract and verify the JACS email signature document from a raw email. -/// -/// Uses [`JacsSigner::verify_with_key()`] to validate the JACS document -/// signature against the supplied `public_key`, then extracts the email -/// payload and parsed email parts. +/// Extract and verify the JACS email signature document from a raw email, +/// looking for a custom attachment filename. /// -/// Steps: -/// 1. Extracts the `hai.ai.signature.jacs.json` attachment -/// 2. Removes the JACS attachment (the signature covers the email WITHOUT itself) -/// 3. Verifies the JACS document signature using the provided public key -/// 4. Extracts the email signature payload from the `content` field -/// 5. Returns a `JacsEmailSignatureDocument` and parsed email parts -/// -/// # Arguments -/// * `raw_email` - The raw RFC 5322 email bytes (with JACS attachment) -/// * `verifier` - Any type implementing [`JacsSigner`] (e.g. `SimpleAgent`) -/// * `public_key` - The signer's public key bytes (from registry, trust store, etc.) -pub fn verify_email_document( +/// Same as [`verify_email_document`] but accepts a custom JACS attachment +/// filename. Use this when the email uses a branded attachment name +/// instead of the JACS default. +pub fn verify_email_document_named( raw_email: &[u8], verifier: &impl super::JacsSigner, public_key: &[u8], + filename: &str, ) -> Result<(JacsEmailSignatureDocument, ParsedEmailParts), EmailError> { check_email_size(raw_email)?; - let jacs_bytes = get_jacs_attachment(raw_email)?; - let email_without_jacs = remove_jacs_attachment(raw_email)?; + let jacs_bytes = get_jacs_attachment_named(raw_email, filename)?; + let email_without_jacs = remove_jacs_attachment_named(raw_email, filename)?; let jacs_str = std::str::from_utf8(&jacs_bytes).map_err(|e| { EmailError::InvalidJacsDocument(format!("attachment is not valid UTF-8: {e}")) @@ -152,32 +144,51 @@ pub fn verify_email_document( Ok((doc, parts)) } -/// Verify a JACS-signed .eml (RFC 5322) email in a single call. -/// -/// This is the primary API for email verification. It combines -/// cryptographic signature validation and content hash comparison: +/// Extract and verify the JACS email signature document from a raw email. /// -/// 1. Extracts and verifies the `hai.ai.signature.jacs.json` JACS document -/// 2. Compares every hash in the trusted JACS document against the actual -/// email content (headers, body parts, attachments) -/// 3. Returns field-level results showing which fields pass, fail, or were -/// modified +/// Uses the default attachment filename ([`DEFAULT_JACS_SIGNATURE_FILENAME`]). +/// For a custom filename, use [`verify_email_document_named`]. +pub fn verify_email_document( + raw_email: &[u8], + verifier: &impl super::JacsSigner, + public_key: &[u8], +) -> Result<(JacsEmailSignatureDocument, ParsedEmailParts), EmailError> { + verify_email_document_named( + raw_email, + verifier, + public_key, + DEFAULT_JACS_SIGNATURE_FILENAME, + ) +} + +/// Verify a JACS-signed email with a custom attachment filename. /// -/// # Arguments -/// * `raw_eml` - Raw RFC 5322 email bytes (with `hai.ai.signature.jacs.json` attached) -/// * `verifier` - Any type implementing [`JacsSigner`] (e.g. `SimpleAgent`) -/// * `public_key` - The signer's public key bytes (from registry, trust store, etc.) +/// Same as [`verify_email`] but accepts a custom JACS attachment filename. +pub fn verify_email_named( + raw_eml: &[u8], + verifier: &impl super::JacsSigner, + public_key: &[u8], + filename: &str, +) -> Result { + let (doc, parts) = verify_email_document_named(raw_eml, verifier, public_key, filename)?; + Ok(verify_email_content(&doc, &parts)) +} + +/// Verify a JACS-signed email in a single call. /// -/// # Returns -/// `ContentVerificationResult` with field-level results. Check `.valid` for -/// overall pass/fail. +/// Uses the default attachment filename ([`DEFAULT_JACS_SIGNATURE_FILENAME`]). +/// For a custom filename, use [`verify_email_named`]. pub fn verify_email( raw_eml: &[u8], verifier: &impl super::JacsSigner, public_key: &[u8], ) -> Result { - let (doc, parts) = verify_email_document(raw_eml, verifier, public_key)?; - Ok(verify_email_content(&doc, &parts)) + verify_email_named( + raw_eml, + verifier, + public_key, + DEFAULT_JACS_SIGNATURE_FILENAME, + ) } /// Compare trusted JACS document hashes against actual email content. @@ -685,7 +696,8 @@ fn verify_attachments( #[cfg(test)] mod tests { use super::*; - use crate::email::sign::sign_email; + use crate::email::canonicalize::extract_email_parts; + use crate::email::sign::{sign_email, sign_email_named}; use crate::email::types::*; use crate::simple::SimpleAgent; @@ -1035,10 +1047,10 @@ mod tests { let renamed = parts .jacs_attachments .iter() - .find(|a| a.filename == "hai.ai.signature.0.jacs.json"); + .find(|a| a.filename == "jacs-signature-0.json"); assert!( renamed.is_some(), - "Expected hai.ai.signature.0.jacs.json attachment, found: {:?}", + "Expected jacs-signature-0.json attachment, found: {:?}", parts .jacs_attachments .iter() @@ -1200,6 +1212,92 @@ mod tests { assert!(!result.chain[2].valid); } + // -- Custom-name forwarding tests -- + + #[test] + #[serial(jacs_env)] + fn sign_email_named_uses_custom_attachment_name() { + let _lock = EMAIL_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let (agent, _tmp, _env_guard) = create_test_agent("custom-name-sign"); + let email = simple_text_email(); + let signed = sign_email_named(&email, &agent, "myapp.jacs.json").unwrap(); + let parts = extract_email_parts(&signed).unwrap(); + assert!( + parts + .jacs_attachments + .iter() + .any(|a| a.filename == "myapp.jacs.json"), + "Expected custom attachment name 'myapp.jacs.json', found: {:?}", + parts + .jacs_attachments + .iter() + .map(|a| &a.filename) + .collect::>() + ); + } + + #[test] + #[serial(jacs_env)] + fn forward_with_custom_name_renames_correctly() { + let _lock = EMAIL_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let custom_name = "myapp.jacs.json"; + + let (agent_a, _tmp_a, _env_guard_a) = create_test_agent("custom-fwd-a"); + let original = b"From: a@example.com\r\nTo: b@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: \r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello\r\n"; + let signed_by_a = sign_email_named(original, &agent_a, custom_name).unwrap(); + + let (agent_b, _tmp_b, _env_guard_b) = create_test_agent("custom-fwd-b"); + let signed_by_b = sign_email_named(&signed_by_a, &agent_b, custom_name).unwrap(); + + let parts = extract_email_parts(&signed_by_b).unwrap(); + let filenames: Vec<&str> = parts + .jacs_attachments + .iter() + .map(|a| a.filename.as_str()) + .collect(); + + // Active signature should be the custom name + assert!( + filenames.contains(&custom_name), + "Expected active '{}', found: {:?}", + custom_name, + filenames + ); + // Renamed original should be `myapp.0.jacs.json` + assert!( + filenames.contains(&"myapp.0.jacs.json"), + "Expected forwarded 'myapp.0.jacs.json', found: {:?}", + filenames + ); + } + + #[test] + #[serial(jacs_env)] + fn verify_email_named_works_with_custom_name() { + let _lock = EMAIL_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let custom_name = "branded.jacs.json"; + + let (agent, _tmp, _env_guard) = create_test_agent("custom-verify"); + let pubkey = get_pubkey(&agent); + let email = simple_text_email(); + let signed = sign_email_named(&email, &agent, custom_name).unwrap(); + + // Default verify should fail (looks for jacs-signature.json) + assert!( + verify_email_document(&signed, &agent, &pubkey).is_err(), + "Default verify should not find custom-named attachment" + ); + + // Named verify should succeed + let result = verify_email_document_named(&signed, &agent, &pubkey, custom_name); + assert!( + result.is_ok(), + "Named verify should find '{}': {:?}", + custom_name, + result.err() + ); + } + // -- Security regression tests -- #[test] #[serial(jacs_env)] From 294fd068dfc5c8e473d7f3864af34f6a89c3df4f Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Mon, 23 Mar 2026 22:11:00 -0700 Subject: [PATCH 5/7] bump versions --- CHANGELOG.md | 4 ++++ binding-core/Cargo.toml | 4 ++-- jacs-cli/Cargo.toml | 6 +++--- jacs-duckdb/Cargo.toml | 4 ++-- jacs-mcp/Cargo.toml | 6 +++--- jacs-mcp/contract/jacs-mcp-contract.json | 2 +- jacs-postgresql/Cargo.toml | 4 ++-- jacs-redb/Cargo.toml | 4 ++-- jacs-surrealdb/Cargo.toml | 4 ++-- jacs/Cargo.toml | 2 +- jacsgo/lib/Cargo.toml | 2 +- jacsnpm/Cargo.toml | 2 +- jacsnpm/package.json | 2 +- jacspy/Cargo.toml | 2 +- jacspy/pyproject.toml | 2 +- 15 files changed, 27 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8efa7ed..19d4bc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.11 + +(unreleased) + ## 0.9.10 (unreleased) diff --git a/binding-core/Cargo.toml b/binding-core/Cargo.toml index 18259a03..23a23801 100644 --- a/binding-core/Cargo.toml +++ b/binding-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-binding-core" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" resolver = "3" @@ -19,7 +19,7 @@ attestation = ["jacs/attestation"] pq-tests = [] [dependencies] -jacs = { version = "0.9.10", path = "../jacs" } +jacs = { version = "0.9.11", path = "../jacs" } serde_json = "1.0" base64 = "0.22.1" serde = { version = "1.0", features = ["derive"] } diff --git a/jacs-cli/Cargo.toml b/jacs-cli/Cargo.toml index a557c5c9..6fa04086 100644 --- a/jacs-cli/Cargo.toml +++ b/jacs-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-cli" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" description = "JACS CLI: command-line interface for JSON AI Communication Standard" @@ -23,8 +23,8 @@ attestation = ["jacs/attestation"] keychain = ["jacs/keychain"] [dependencies] -jacs = { version = "0.9.10", path = "../jacs" } -jacs-mcp = { version = "0.9.10", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } +jacs = { version = "0.9.11", path = "../jacs" } +jacs-mcp = { version = "0.9.11", path = "../jacs-mcp", features = ["mcp", "full-tools"], optional = true } clap = { version = "4.5.4", features = ["derive", "cargo"] } rpassword = "7.3.1" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/jacs-duckdb/Cargo.toml b/jacs-duckdb/Cargo.toml index a3f90745..0f2dc53a 100644 --- a/jacs-duckdb/Cargo.toml +++ b/jacs-duckdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-duckdb" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true description = "DuckDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "duckdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.10", path = "../jacs", default-features = false } +jacs = { version = "0.9.11", path = "../jacs", default-features = false } duckdb = { version = "1.4", features = ["bundled", "json"] } serde_json = "1.0" diff --git a/jacs-mcp/Cargo.toml b/jacs-mcp/Cargo.toml index 4f4f92bf..f10bb989 100644 --- a/jacs-mcp/Cargo.toml +++ b/jacs-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-mcp" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" description = "MCP server for JACS: data provenance and cryptographic signing of agent state" @@ -45,8 +45,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } rmcp = { version = "0.12", features = ["client", "server", "transport-io", "transport-child-process", "macros"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time"], optional = true } -jacs = { version = "0.9.10", path = "../jacs", default-features = true } -jacs-binding-core = { version = "0.9.10", path = "../binding-core", features = ["a2a"] } +jacs = { version = "0.9.11", path = "../jacs", default-features = true } +jacs-binding-core = { version = "0.9.11", path = "../binding-core", features = ["a2a"] } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" diff --git a/jacs-mcp/contract/jacs-mcp-contract.json b/jacs-mcp/contract/jacs-mcp-contract.json index 6400757a..b3fb3935 100644 --- a/jacs-mcp/contract/jacs-mcp-contract.json +++ b/jacs-mcp/contract/jacs-mcp-contract.json @@ -3,7 +3,7 @@ "server": { "name": "jacs-mcp", "title": "JACS MCP Server", - "version": "0.9.10", + "version": "0.9.11", "website_url": "https://humanassisted.github.io/JACS/", "instructions": "This MCP server provides data provenance and cryptographic signing for agent state files and agent-to-agent messaging. Agent state tools: jacs_sign_state (sign files), jacs_verify_state (verify integrity), jacs_load_state (load with verification), jacs_update_state (update and re-sign), jacs_list_state (list signed docs), jacs_adopt_state (adopt external files). Memory tools: jacs_memory_save (save a memory), jacs_memory_recall (search memories by query), jacs_memory_list (list all memories), jacs_memory_forget (soft-delete a memory), jacs_memory_update (update an existing memory). Messaging tools: jacs_message_send (create and sign a message), jacs_message_update (update and re-sign a message), jacs_message_agree (co-sign/agree to a message), jacs_message_receive (verify and extract a received message). Agent management: jacs_create_agent (create new agent with keys), jacs_reencrypt_key (rotate private key password). A2A artifacts: jacs_wrap_a2a_artifact (sign artifact with provenance), jacs_verify_a2a_artifact (verify wrapped artifact), jacs_assess_a2a_agent (assess remote agent trust level). A2A discovery: jacs_export_agent_card (export Agent Card), jacs_generate_well_known (generate .well-known documents), jacs_export_agent (export full agent JSON). Trust store: jacs_trust_agent (add agent to trust store), jacs_untrust_agent (remove from trust store, requires JACS_MCP_ALLOW_UNTRUST=true), jacs_list_trusted_agents (list all trusted agent IDs), jacs_is_trusted (check if agent is trusted), jacs_get_trusted_agent (get trusted agent JSON). Attestation: jacs_attest_create (create signed attestation with claims), jacs_attest_verify (verify attestation, optionally with evidence checks), jacs_attest_lift (lift signed document into attestation), jacs_attest_export_dsse (export attestation as DSSE envelope). Security: jacs_audit (read-only security audit and health checks). Audit trail: jacs_audit_log (record events as signed audit entries), jacs_audit_query (search audit trail by action, target, time range), jacs_audit_export (export audit trail as signed bundle). Search: jacs_search (unified search across all signed documents)." }, diff --git a/jacs-postgresql/Cargo.toml b/jacs-postgresql/Cargo.toml index 0a522e2a..651552eb 100644 --- a/jacs-postgresql/Cargo.toml +++ b/jacs-postgresql/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-postgresql" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true description = "PostgreSQL storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "postgresql", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.10", path = "../jacs", default-features = false } +jacs = { version = "0.9.11", path = "../jacs", default-features = false } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.0", features = ["rt-multi-thread"] } serde_json = "1.0" diff --git a/jacs-redb/Cargo.toml b/jacs-redb/Cargo.toml index d75f99e4..6e050bb2 100644 --- a/jacs-redb/Cargo.toml +++ b/jacs-redb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-redb" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true readme.workspace = true @@ -13,7 +13,7 @@ categories.workspace = true description = "Redb (pure-Rust embedded KV) storage backend for JACS documents" [dependencies] -jacs = { version = "0.9.10", path = "../jacs", default-features = false } +jacs = { version = "0.9.11", path = "../jacs", default-features = false } redb = "3.1" chrono = "0.4.40" serde_json = "1.0" diff --git a/jacs-surrealdb/Cargo.toml b/jacs-surrealdb/Cargo.toml index 107978c7..ee47540e 100644 --- a/jacs-surrealdb/Cargo.toml +++ b/jacs-surrealdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs-surrealdb" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true description = "SurrealDB storage backend for JACS documents" @@ -13,7 +13,7 @@ keywords = ["cryptography", "json", "surrealdb", "storage"] categories = ["database", "data-structures"] [dependencies] -jacs = { version = "0.9.10", path = "../jacs", default-features = false } +jacs = { version = "0.9.11", path = "../jacs", default-features = false } surrealdb = { version = "3.0.2", default-features = false, features = ["kv-mem"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 7c65be6a..1df36149 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacs" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsgo/lib/Cargo.toml b/jacsgo/lib/Cargo.toml index 946b06f4..7dc3c387 100644 --- a/jacsgo/lib/Cargo.toml +++ b/jacsgo/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsgo" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsnpm/Cargo.toml b/jacsnpm/Cargo.toml index f481126b..14eccf41 100644 --- a/jacsnpm/Cargo.toml +++ b/jacsnpm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacsnpm" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacsnpm/package.json b/jacsnpm/package.json index 3b6d9699..e07ca105 100644 --- a/jacsnpm/package.json +++ b/jacsnpm/package.json @@ -1,6 +1,6 @@ { "name": "@hai.ai/jacs", - "version": "0.9.10", + "version": "0.9.11", "description": "JACS (JSON Agent Communication Standard) - Data provenance and cryptographic signing for AI agents", "main": "index.js", "types": "index.d.ts", diff --git a/jacspy/Cargo.toml b/jacspy/Cargo.toml index 6d3eb8b8..eadeebb0 100644 --- a/jacspy/Cargo.toml +++ b/jacspy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacspy" -version = "0.9.10" +version = "0.9.11" edition = "2024" rust-version = "1.93" resolver = "3" diff --git a/jacspy/pyproject.toml b/jacspy/pyproject.toml index 7a282090..5b9a1cba 100644 --- a/jacspy/pyproject.toml +++ b/jacspy/pyproject.toml @@ -3,7 +3,7 @@ requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [project] name = "jacs" -version = "0.9.10" +version = "0.9.11" description = "JACS - JSON AI Communication Standard: Cryptographic signing and verification for AI agents." readme = "README.md" requires-python = ">=3.10" From 19d67a6fc33fc8c0a9d2d58b4b0476a7ffa93455 Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Tue, 24 Mar 2026 00:06:48 -0700 Subject: [PATCH 6/7] test cargos --- jacs-postgresql/Cargo.toml | 4 ++-- jacs/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/jacs-postgresql/Cargo.toml b/jacs-postgresql/Cargo.toml index 651552eb..19dd6ac0 100644 --- a/jacs-postgresql/Cargo.toml +++ b/jacs-postgresql/Cargo.toml @@ -21,7 +21,7 @@ tracing = "0.1" [dev-dependencies] serial_test = "3.2.0" -testcontainers = "0.26" -testcontainers-modules = { version = "0.14", features = ["postgres"] } +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["postgres"] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time"] } serde_json = "1.0" diff --git a/jacs/Cargo.toml b/jacs/Cargo.toml index 1df36149..e874f8a1 100644 --- a/jacs/Cargo.toml +++ b/jacs/Cargo.toml @@ -128,8 +128,8 @@ tempfile = "3.19.1" serial_test = "3.2.0" futures = "0.3" tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time"] } -testcontainers = "0.26" -testcontainers-modules = { version = "0.14", features = ["minio"] } +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["minio"] } reqwest = { version = "0.13.2", default-features = false, features = ["rustls"] } [lib] From 9590b5a619ae370db54c0582ba3bcbdabba93a1f Mon Sep 17 00:00:00 2001 From: Jonathan Hendler Date: Tue, 24 Mar 2026 00:43:32 -0700 Subject: [PATCH 7/7] fix flaky trust timestamp tests by adding serial annotation test_timestamp_unix_epoch_valid_by_default and test_valid_old_timestamp were racing with test_old_timestamp_rejected_when_expiry_enabled which sets JACS_MAX_SIGNATURE_AGE_SECONDS env var, causing spurious failures. --- jacs/src/trust.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jacs/src/trust.rs b/jacs/src/trust.rs index 8e91b542..2a303594 100644 --- a/jacs/src/trust.rs +++ b/jacs/src/trust.rs @@ -887,6 +887,7 @@ mod tests { } #[test] + #[serial(home_env)] fn test_valid_old_timestamp() { // A timestamp from a year ago should be valid by default (no expiration) // JACS documents are designed to be idempotent and eternal @@ -1229,6 +1230,7 @@ mod tests { } #[test] + #[serial(home_env)] fn test_timestamp_unix_epoch_valid_by_default() { // Unix epoch (1970-01-01) should be valid by default (no expiration) // JACS documents are eternal