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:
| Action | Function | What you supply | What you get back |
|---|---|---|---|
| Sign | jacs::email::sign_email() | raw .eml bytes + a JacsSigner | .eml bytes with jacs-signature.json |
| Verify | jacs::email::verify_email() | signed .eml bytes + sender's public key + verifier | ContentVerificationResult (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
- Parses and canonicalizes the email headers and body
- Computes SHA-256 hashes for each header, body part, and attachment
- Builds the JACS email signature payload
- Places the hash payload in
contentviasign_message() - Uses the normal JACS document canonicalization, hashing and signing pipeline
- 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:
- Renames the existing signature to
jacs-signature-0.json(or-1,-2, ...) - Computes a
parent_signature_hashlinking to the previous signature - 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
One-call API (recommended)
#![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:
- Extracts
jacs-signature.jsonfrom the email - Removes it (the signature covers the email without itself)
- Verifies the JACS document signature against the sender's public key
- Compares covered canonicalized headers, body parts, attachments and MIME hashes
- 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:
| Status | Meaning |
|---|---|
Pass | Covered hash matches the signed value; no independent mailbox attribution |
Modified | From, To or Cc names the same mailboxes, differing only in quoting, whitespace, RFC 2047 encoding, order or address case |
Fail | Content does not match the signed hash |
Unverifiable | Field 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_payloadfor 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_pngandextract_jacs_header_from_logo_pngfor the signed inline PNG logo transport, withHAI_LOGO_PNGas the picture.HAI_LOGO_ALTandHAI_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_envelopefor reply-safe hidden envelope selection.inline_email_presentation_matchesfor the noncryptographic whole-document presentation check, also suitable for ingress safety.remove_inline_signature_artifacts,strip_inline_signature_artifacts_from_html, andhtml_bodies_equivalentfor 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_contentfor 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 attachment | Meaning |
|---|---|
content.headers | Selected canonicalized header values and hashes |
content.body_plain, content.body_html | Present body parts' content and MIME-header hashes |
content.attachments | Attachment filenames, content hashes and MIME-header hashes |
content.parent_signature_hash | When forwarding, hash of the previous signature attachment bytes |
jacsId, jacsVersion, jacsSha256 | Normal JACS document identifiers and document hash |
jacsSignature | Normal 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 }