Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Native library reference. Extended CLI examples use jacs-compat. This source reference can include unreleased changes; see the package guide for installation and MCP profiles for available tools.

Email Signing and Verification

JACS signs selected canonicalized fields from an RFC 5322 .eml input and returns email bytes with a jacs-signature.json MIME attachment. That attachment is an ordinary signed JACS document containing the email hash payload. The recipient verifies it with an explicitly supplied public key, then compares the received email's covered fields and attachments against the signed hashes. This does not assert byte-identical preservation of the entire .eml, mailbox ownership, truth, human approval or permission to contact a recipient.

JACS also exposes migration helpers for HAI's HTML-inline signed email transport. In that mode, the signature material travels in the HTML body and inline logo instead of as user-visible signature attachments. Core signing and verification still stay in JACS; SDKs and servers should call these helpers rather than reimplementing email hashing, MIME parsing, or media extraction.

The primary attachment-mode APIs are:

ActionFunctionWhat you supplyWhat you get back
Signjacs::email::sign_email()raw .eml bytes + a JacsSigner.eml bytes with jacs-signature.json
Verifyjacs::email::verify_email()signed .eml bytes + sender's public key + verifierContentVerificationResult (pass/fail per field)

Runnable local example

From a source checkout, run:

JACS_KEYCHAIN_BACKEND=disabled JACS_ALLOW_NETWORK=false cargo run --locked --manifest-path archive/native/Cargo.toml -p jacs --no-default-features --example email_signing

The example creates two disposable SimpleAgent::ephemeral instances and a synthetic multipart email with one attachment. It passes the sender's public key directly to the recipient, signs and verifies the email, prints the actual JACS attachment, checks body tampering and a wrong key, and inspects a forwarding chain. It sends no mail, reads no user keys and writes no files. jacs::email is available without an optional email feature. This is a local JACS-to-JACS example, not an independent implementation or mailbox-identity test.

//! Inspect real attachment-mode email signing with disposable in-memory keys.
//! No config files, mail delivery, remote key lookup or user keys are used.

use jacs::email::{FieldStatus, get_jacs_attachment, sign_email, verify_email};
use jacs::simple::SimpleAgent;
use serde_json::Value;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (sender, _) = SimpleAgent::ephemeral(Some("ed25519"))?;
    let (recipient, _) = SimpleAgent::ephemeral(Some("ed25519"))?;
    // In this local example the key is passed directly. A real recipient must
    // establish the key's attribution separately from signature verification.
    let public_key = sender.get_public_key()?;
    let raw_email = concat!(
        "From: sender@example.test\r\n",
        "To: recipient@example.test\r\n",
        "Subject: Synthetic email provenance\r\n",
        "Date: Mon, 14 Sep 2026 12:00:00 +0000\r\n",
        "Message-ID: <synthetic-provenance@example.test>\r\n",
        "MIME-Version: 1.0\r\n",
        "Content-Type: multipart/mixed; boundary=synthetic-boundary\r\n\r\n",
        "--synthetic-boundary\r\n",
        "Content-Type: text/plain; charset=utf-8\r\n\r\n",
        "Synthetic body for envelope inspection.\r\n",
        "--synthetic-boundary\r\n",
        "Content-Type: application/octet-stream; name=receipt.txt\r\n",
        "Content-Disposition: attachment; filename=receipt.txt\r\n\r\n",
        "Synthetic receipt\r\n",
        "--synthetic-boundary--\r\n",
    );

    let signed_email = sign_email(raw_email.as_bytes(), &sender)?;
    let result = verify_email(&signed_email, &recipient, &public_key)?;
    assert!(result.valid, "synthetic email must verify: {result:?}");
    let attachment = get_jacs_attachment(&signed_email)?;
    let document: Value = serde_json::from_slice(&attachment)?;
    assert!(document["content"]["headers"].is_object());
    assert_eq!(
        document["content"]["attachments"][0]["filename"],
        "receipt.txt"
    );
    assert!(document["jacsSignature"]["signature"].is_string());
    assert!(document.get("payload").is_none());
    println!("Actual signed attachment (synthetic data only):");
    println!("{}", std::str::from_utf8(&attachment)?);
    println!(
        "Field verification: {}",
        serde_json::to_string_pretty(&result)?
    );

    let tampered = std::str::from_utf8(&signed_email)?.replacen(
        "Synthetic body for envelope inspection.",
        "Changed body for envelope inspection.",
        1,
    );
    assert_ne!(tampered.as_bytes(), signed_email.as_slice());
    let tampered_result = verify_email(tampered.as_bytes(), &recipient, &public_key)?;
    assert!(!tampered_result.valid);
    assert!(
        tampered_result
            .field_results
            .iter()
            .any(|field| { field.field == "body_plain" && field.status == FieldStatus::Fail })
    );
    assert!(verify_email(&signed_email, &recipient, &recipient.get_public_key()?).is_err());

    // Re-signing links the previous attachment bytes. The one-key email API
    // does not verify ancestors, even when this example reuses the same sender.
    let forwarded = sign_email(&signed_email, &sender)?;
    let forwarding_result = verify_email(&forwarded, &recipient, &public_key)?;
    assert!(!forwarding_result.valid);
    assert_eq!(forwarding_result.chain.len(), 2);
    assert!(forwarding_result.chain[0].valid);
    assert!(!forwarding_result.chain[1].valid);
    assert!(
        forwarding_result
            .field_results
            .iter()
            .all(|field| field.status != FieldStatus::Fail)
    );
    println!(
        "Forwarding inspection: {}",
        serde_json::to_string_pretty(&forwarding_result)?
    );
    println!("Signature checks do not establish mailbox identity, truth or human approval.");
    Ok(())
}

Signing an email

#![allow(unused)]
fn main() {
use jacs::email::sign_email;

// 1. Load raw email bytes (RFC 5322 format)
let raw_eml = std::fs::read("outgoing.eml")?;

// 2. Sign — SimpleAgent implements JacsSigner
let signed_eml = sign_email(&raw_eml, &my_agent)?;

// 3. Send signed_eml — it is a valid .eml with the JACS attachment
std::fs::write("outgoing_signed.eml", &signed_eml)?;
}

The JacsSigner trait

sign_email accepts any type that implements JacsSigner. SimpleAgent implements it out of the box.

#![allow(unused)]
fn main() {
pub trait JacsSigner {
    /// Create a signed JACS document from the email hash payload.
    fn sign_message(&self, data: &serde_json::Value) -> Result<SignedDocument, JacsError>;

    /// Verify a signed JACS document using the sender's public key.
    fn verify_with_key(
        &self,
        signed_document: &str,
        public_key: Vec<u8>,
    ) -> Result<VerificationResult, JacsError>;
}
}

The signing algorithm is read from your JACS agent at runtime and recorded in the jacs-signature.json document. Do not hardcode it in email code.

What sign_email does internally

  1. Parses and canonicalizes the email headers and body
  2. Computes SHA-256 hashes for each header, body part, and attachment
  3. Builds the JACS email signature payload
  4. Places the hash payload in content via sign_message()
  5. Uses the normal JACS document canonicalization, hashing and signing pipeline
  6. Attaches the result as jacs-signature.json

You do not need to know any of this to use it — it is a single function call.

Forwarding (re-signing)

If the email already has a jacs-signature.json (it was previously signed by another agent), sign_email automatically:

  1. Renames the existing signature to jacs-signature-0.json (or -1, -2, ...)
  2. Computes a parent_signature_hash linking to the previous signature
  3. Signs the email with a new jacs-signature.json

The new signature covers the hash link and the retained attachment bytes. It does not verify the earlier signers' cryptographic signatures. The current one-key verifier records ancestors with chain[].valid: false because their keys were not supplied; this also keeps the overall result false even if the current signature and all covered fields pass. Treat those ancestors as unchecked, not as proven tampering. Verify their signatures with independently selected keys before an application relies on the chain; do not turn these flags true based only on a matching hash or claimed signer label.

Verifying an email

#![allow(unused)]
fn main() {
use jacs::email::verify_email;
use jacs::simple::SimpleAgent;

let signed_eml = std::fs::read("incoming_signed.eml")?;
let sender_public_key: Vec<u8> = /* fetch from local trust, DNS, or another trusted source */;

// Any agent can verify — the sender's public key is passed explicitly
let (agent, _) = SimpleAgent::ephemeral(None)?;
let result = verify_email(&signed_eml, &agent, &sender_public_key)?;

if result.valid {
    println!("Covered email fields match a JACS signature checked with the supplied key");
} else {
    // Inspect field failures and any unchecked forwarding ancestors
    for field in &result.field_results {
        println!("{}: {:?}", field.field, field.status);
    }
    println!("Chain inspection: {:?}", result.chain);
}
}

verify_email does everything in one call:

  1. Extracts jacs-signature.json from the email
  2. Removes it (the signature covers the email without itself)
  3. Verifies the JACS document signature against the sender's public key
  4. Compares covered canonicalized headers, body parts, attachments and MIME hashes
  5. Returns per-field and forwarding-chain results; Message-ID is stored but not verified

Two-step API (when you need the JACS document)

If you need a parsed email DTO and JACS metadata (issuer, timestamps) before doing the content comparison:

#![allow(unused)]
fn main() {
use jacs::email::{verify_email_document, verify_email_content};
use jacs::simple::SimpleAgent;

let (agent, _) = SimpleAgent::ephemeral(None)?;

// Step 1: Verify the current JACS signature, then project its email DTO
let (doc, parts) = verify_email_document(&signed_eml, &agent, &sender_public_key)?;

// Inspect the document
println!("Signed by: {}", doc.metadata.issuer);
println!("Created at: {}", doc.metadata.created_at);

// Step 2: Compare content hashes
let result = verify_email_content(&doc, &parts);
assert!(result.valid);
}

All cryptographic operations are handled by the JACS agent via SimpleAgent::verify_with_key(). The agent's own key is not used -- the sender's public key is passed explicitly.

Field-level results

The ContentVerificationResult contains a field_results vector with one entry per field:

StatusMeaning
PassCovered hash matches the signed value; no independent mailbox attribution
ModifiedFrom, To or Cc names the same mailboxes, differing only in quoting, whitespace, RFC 2047 encoding, order or address case
FailContent does not match the signed hash
UnverifiableField absent or not verifiable (e.g. Message-ID may change in transit)

Headers covered include from, to, subject, date and, when present, cc, in_reply_to and references. message_id is stored in the signed payload but always reported Unverifiable, since transit can change it. Inspect every field status; overall valid does not mean every field was checked.

Most clients show a display name instead of the address, so a changed, added or removed display name (or comment) in From, To or Cc fails. A From, To, Cc, Subject, Date, Message-ID, In-Reply-To or References header that appears more than once fails as that field (current_value such as 2 subject headers), whether or not it was signed: clients choose which copy to show. Signing already refuses such mail.

Body hashing uses decoded/canonicalized text, including charset, line-ending and trailing-whitespace normalization. Attachment hashes include the normalized filename, lowercased content type and extracted content bytes; body and attachment MIME structural headers have separate hashes. Equivalent normalized content can verify despite changes to its wire representation. Uncovered headers and transport metadata are outside this claim.

MIME parts outside the signature

A signature covers the first text/plain part not marked as an attachment, the first such text/html part (attachment mode only), and every attachment: a part with a filename, or a part of another type marked inline or attachment. Mail clients can also show other parts: a second text or HTML alternative, an image without a filename, a text part marked as an attachment without a filename, an embedded message/rfc822, or a part without a Content-Type. extract_email_parts lists these in ParsedEmailParts::unsigned_parts. Content verification reports each as Fail, named unsigned_parts[0], unsigned_parts[1] and so on, with current_value describing its type, disposition, Content-ID and size. A body that is present but not signed, such as an HTML part added to text-only mail, fails as body_plain or body_html with current_value present but not signed.

sign_email and build_html_inline_email_signature_payload refuse such mail with EmailError::UnsupportedFeature, because the signature could never verify. HTML-inline verification excludes the generated HTML body and exactly one HAI logo part. A second logo part or HTML body fails; the V3 picture and calendar invitations remain signed attachments.

Mailing lists and gateways that append a footer or disclaimer as a separate MIME part therefore make verification fail, as a footer added inside a signed body already did.

HTML-inline signed email migration helpers

The HTML-inline transport is being added for HAI email while attachment mode remains compatible. Use verify_signed_email when a caller may receive either transport:

#![allow(unused)]
fn main() {
use jacs::email::{verify_signed_email, VerificationMode};

let result = verify_signed_email(
    &raw_eml,
    &verifier_agent,
    &sender_public_key,
    VerificationMode::Strict,
)?;
}

The verifier detects SignedEmailTransport::AttachmentJacs or SignedEmailTransport::HtmlInline and returns SignedEmailVerificationResult with Verified, PartiallyVerified, or Failed.

Canonical presentation has three explicit versions. Historical V1 escapes plaintext without links and omits inline_template_version from the signed payload. V2 uses render_inline_email_message_body with InlineEmailTemplateVersion::V2: a complete, unpadded HTTPS URL line becomes an anchor whose visible URL and destination are the exact original text, including query and fragment. Credentials, controls, whitespace and markup delimiters remain ordinary escaped text. The helper is generic and does not recognize application routes or invitation tokens. render_inline_email_message_main adds the canonical container: V2 uses dir="auto", deriving displayed direction from the first strong character of signed text so Arabic and Hebrew render naturally; V1 retains its historical container. Direction attributes are part of the reconstructed presentation and cannot be changed independently.

V3 is V2 after one picture: render_inline_email_message_main puts <img src="cid:mediationbench-judge@hai.ai" alt="MediationBench judge" width="96" height="96"><br> before the container (HAI_BANNER_* constants). The picture is an ordinary inline MIME part, so the signed payload hashes its bytes with the other attachments; its <img> is part of the reconstructed presentation, so removing, moving or resizing it fails presentation verification. Content-IDs are not hashed, as for the logo: a part without a signature that takes the picture's Content-ID fails as an unsigned part, but relabelling another signed image part is not detected. V3 binds inline_template_version: "v3"; verifiers that know only V1 and V2 reject it.

V2 binds inline_template_version: "v2" inside the signed email payload. In every version, the strict verifier rebuilds the whole HTML document from the signed plaintext and requires exactly one supported template marker matching the signed version. The plaintext must end with HAI's footer line: a blank line, then This email is sent from an AI agent. Verify at <URL>. The expected document is the marker, the message from render_inline_email_message_main, HAI's logo, an empty application/jacs+json envelope script, and the footer <footer data-hai-verify-footer="v1">, whose text and single link both name that URL. The logo may take any shape a HAI producer has emitted, each with alt text HAI verification logo: the picture linked to the same URL, that link with <br><br> before the picture, or the unlinked picture.

The logo part must show HAI's picture, HAI_LOGO_PNG, carrying the compact header of the signed document: the size and decoded pixels of embed_jacs_header_in_logo_png(HAI_LOGO_PNG, header). Dropped metadata and re-encoding still verify; another picture, even one carrying the genuine header, or an animated PNG fails with LogoSignatureMismatch in every mode.

Only the contents of the first envelope script are ignored; the verifier checks that envelope separately. It must be a JSON object with no members other than compactHeader (matched against the logo), jacsEnvelope (the signed JACS document) and agentEnvelope (the hosted agent's document on HAI-countersigned mail), and must not contain <!--, which changes where HTML parsers end a script. Otherwise verification fails with MissingInlineJacsEnvelope; a signed value containing <!--, such as a subject, fails safe unless the producer escapes < as \u003c. Nothing else is stripped: elements carrying the footer, envelope or logo markers are compared like any other markup, and any comment (including Outlook conditional comments), a second envelope script or an HTML tokenizer error fails. Tag and attribute-name case, attribute order and quoting, and character references are normalized. Altered hrefs, labels, fragments, extra or moved markup, a missing plaintext footer line and unsigned version changes fail presentation verification with HtmlEquivalenceFailed: Failed in VerificationMode::Strict, PartiallyVerified in VerificationMode::Degraded. Edits to the signed version fail cryptographic verification.

Link rewriting in transit, such as Microsoft Defender Safe Links, therefore changes the verified presentation. Any email whose footer or logo link was rewritten now fails presentation verification: V1 mail, and V2 or V3 mail whose message has no URL lines, as well as V2 and V3 mail with rewritten message links, which already failed.

Readers implementing only V1 reject V2 presentation. Upgrade producers and verifying delivery services together before emitting V2; SDKs may continue to produce V1. V1 historical serialized payloads and verification remain supported. The optional Rust payload field is an additive serialized contract; downstream Rust struct literals must supply inline_template_version: None when using V1 or attachment mode. These source changes do not constitute a published release.

HTML-inline helpers include:

  • build_html_inline_email_signature_payload for the inline signed pre-image. It signs the existing email header scope, the text body, and user attachments. Generated HTML and signature artifacts are excluded.
  • embed_jacs_header_in_logo_png and extract_jacs_header_from_logo_png for the signed inline PNG logo transport, with HAI_LOGO_PNG as the picture.
  • HAI_LOGO_ALT and HAI_VERIFY_FOOTER_TEXT_PREFIX, the logo alt text and the footer text before the verify URL, for producers and their tests.
  • extract_topmost_inline_jacs_envelope for reply-safe hidden envelope selection.
  • inline_email_presentation_matches for the noncryptographic whole-document presentation check, also suitable for ingress safety.
  • remove_inline_signature_artifacts, strip_inline_signature_artifacts_from_html, and html_bodies_equivalent for parser-based artifact removal and HTML comparison, for example to display or index a message. Strict presentation verification does not use them.
  • verify_html_inline_email_content for content-hash verification after removing inline transport artifacts while keeping user attachments signed.

Attachment mode remains available through sign_email, verify_email, and the verify_email_* compatibility APIs.

The JACS signature document

The primary signed attachment uses the normal JACS envelope. The runnable example above prints its actual generated JSON; do not construct a signature by filling in a sample object.

Path in the signed attachmentMeaning
content.headersSelected canonicalized header values and hashes
content.body_plain, content.body_htmlPresent body parts' content and MIME-header hashes
content.attachmentsAttachment filenames, content hashes and MIME-header hashes
content.parent_signature_hashWhen forwarding, hash of the previous signature attachment bytes
jacsId, jacsVersion, jacsSha256Normal JACS document identifiers and document hash
jacsSignatureNormal signature metadata, including agentID, date, signingAlgorithm and signature

verify_email_document() first verifies this envelope, then returns a JacsEmailSignatureDocument DTO with payload, metadata and signature fields for compatibility. That DTO is not the serialized attachment format: payload comes from content; metadata.issuer comes from jacsSignature.agentID; metadata.hash comes from jacsSha256. Its legacy signature.key_id, signature.algorithm and signature.signature fields are empty in this projection. Inspect the original attachment with get_jacs_attachment() for the actual signature metadata or re-verification.

The historical version: "1.0" / payload / metadata / signature layout is not what current sign_email() emits. Parsing such a layout in an ancestor is compatibility inspection, not cryptographic verification. Neither the DTO nor the attachment establishes that a key belongs to the address in From; that attribution and any human/contact authority require separate evidence.

Public API summary

All items are re-exported from jacs::email:

#![allow(unused)]
fn main() {
// Signing
jacs::email::sign_email(raw_email: &[u8], signer: &impl JacsSigner) -> Result<Vec<u8>, EmailError>
jacs::email::build_html_inline_email_signature_payload(raw_email: &[u8])
jacs::email::JacsSigner                   // trait implemented by SimpleAgent

// Verification
jacs::email::verify_email(raw, &agent, pubkey)       // one-call: crypto + content check
jacs::email::verify_signed_email(raw, &agent, pubkey, mode)
jacs::email::verify_email_document(raw, &agent, pk)  // step 1: crypto only
jacs::email::verify_email_content(&doc, &parts)      // step 2: content hash comparison
jacs::email::verify_html_inline_email_content(&doc, &parts)
jacs::email::normalize_algorithm(...)                 // algorithm name normalization

// Types
jacs::email::ContentVerificationResult    // overall result with field_results
jacs::email::SignedEmailVerificationResult
jacs::email::SignedEmailTransport         // AttachmentJacs | HtmlInline
jacs::email::VerificationMode             // Strict | Degraded
jacs::email::FieldResult                  // per-field status
jacs::email::FieldStatus                  // Pass | Modified | Fail | Unverifiable
jacs::email::JacsEmailSignatureDocument   // parsed compatibility DTO, not raw attachment JSON
jacs::email::ParsedEmailParts             // parsed bodies, attachments and unsigned_parts
jacs::email::UnsignedPart                 // a MIME part no signature covers
jacs::email::EmailError                   // error type

// Attachment helpers (for advanced use)
jacs::email::get_jacs_attachment(...)     // extract jacs-signature.json bytes
jacs::email::remove_jacs_attachment(...)  // strip jacs-signature.json from email
jacs::email::add_jacs_attachment(...)     // inject jacs-signature.json into email
}