use base64::Engine;
use sha2::{Digest, Sha256};
use crate::error::{HaiError, Result};
use crate::types::KeyRegistryResponse;
use crate::types::{ChainEntry, EmailVerificationResultV2, FieldResult, FieldStatus};
use jacs::simple::{CreateAgentParams, SimpleAgent};
pub use jacs::email::{
sign_email, AttachmentEntry, BodyPartEntry, ContentVerificationResult, EmailSignatureHeaders,
EmailSignaturePayload, JacsEmailMetadata, JacsEmailSignature, JacsEmailSignatureDocument,
ParsedAttachment, ParsedBodyPart, ParsedEmailParts, SignedHeaderEntry,
};
use jacs::email::{get_jacs_attachment, verify_email_content, verify_email_document};
pub struct AttachmentInput {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
pub fn compute_content_hash(subject: &str, body: &str, attachments: &[AttachmentInput]) -> String {
let mut att_hashes: Vec<String> = attachments
.iter()
.map(|att| {
let content_type_lower = att.content_type.to_lowercase();
let mut h = Sha256::new();
h.update(att.filename.as_bytes());
h.update(b":");
h.update(content_type_lower.as_bytes());
h.update(b":");
h.update(&att.data);
format!("sha256:{:x}", h.finalize())
})
.collect();
att_hashes.sort();
let mut h = Sha256::new();
h.update(subject.as_bytes());
h.update(b"\n");
h.update(body.as_bytes());
for ah in &att_hashes {
h.update(b"\n");
h.update(ah.as_bytes());
}
format!("sha256:{:x}", h.finalize())
}
fn convert_field_result(value: jacs::email::FieldResult) -> FieldResult {
let json = serde_json::to_value(value).expect("FieldResult should serialize");
serde_json::from_value(json).expect("FieldResult should match SDK schema")
}
fn convert_chain_entry(value: jacs::email::ChainEntry) -> ChainEntry {
let json = serde_json::to_value(value).expect("ChainEntry should serialize");
serde_json::from_value(json).expect("ChainEntry should match SDK schema")
}
pub async fn verify_email(raw_email: &[u8], hai_url: &str) -> EmailVerificationResultV2 {
let hai_url = hai_url.trim_end_matches('/');
let jacs_bytes = match get_jacs_attachment(raw_email) {
Ok(b) => b,
Err(e) => {
return EmailVerificationResultV2::err(
"",
"",
&format!("No JACS signature found: {e}"),
);
}
};
let jacs_value: serde_json::Value = match serde_json::from_slice(&jacs_bytes) {
Ok(v) => v,
Err(e) => {
return EmailVerificationResultV2::err(
"",
"",
&format!("Invalid JACS signature document: {e}"),
);
}
};
let jacs_id = jacs_value
.get("jacsSignature")
.and_then(|sig| sig.get("agentID"))
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
let content = match jacs_value.get("content") {
Some(c) => c,
None => {
return EmailVerificationResultV2::err(
&jacs_id,
"",
"JACS document missing 'content' field",
);
}
};
let pre_payload: EmailSignaturePayload = match serde_json::from_value(content.clone()) {
Ok(p) => p,
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
"",
&format!("Invalid email payload in JACS document: {e}"),
);
}
};
let from_email = pre_payload.headers.from.value.clone();
let sig_algorithm = jacs_value
.get("jacsSignature")
.and_then(|sig| sig.get("signingAlgorithm"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
let registry = match fetch_public_key_from_registry(hai_url, &from_email).await {
Ok(r) => r,
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
"",
&format!("Registry lookup failed: {e}"),
);
}
};
let reputation_tier = ®istry.reputation_tier;
if jacs_id != registry.jacs_id {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!(
"Identity mismatch: document issuer '{}' does not match registry jacs_id '{}'",
jacs_id, registry.jacs_id
),
);
}
if from_email != registry.email {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!(
"Identity mismatch: From '{}' does not match registry email '{}'",
from_email, registry.email
),
);
}
if !algorithms_match(&sig_algorithm, ®istry.algorithm) {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!(
"Algorithm mismatch: signature uses '{}' but registry has '{}'",
sig_algorithm, registry.algorithm
),
);
}
let agent_status = registry.agent_status.clone();
let benchmarks_completed = registry.benchmarks_completed.clone().unwrap_or_default();
if let Some(ref status) = agent_status {
if status != "active" {
return EmailVerificationResultV2 {
agent_status: agent_status.clone(),
benchmarks_completed: benchmarks_completed.clone(),
..EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!(
"Agent status is '{}' -- only active agents can send verified email",
status
),
)
};
}
}
let raw_pub_key = match extract_public_key_bytes(®istry.public_key) {
Ok(k) => k,
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!("Failed to parse public key: {e}"),
);
}
};
let agent = match create_verification_agent() {
Ok(a) => a,
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!("Failed to create verification agent: {e}"),
);
}
};
let (trusted_doc, parts) = match verify_email_document(raw_email, &agent, &raw_pub_key) {
Ok(result) => result,
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!("JACS signature verification failed: {e}"),
);
}
};
let dns_verified = if reputation_tier == "pro" || reputation_tier == "enterprise"
{
let domain = extract_domain(&from_email);
match verify_dns_public_key(&domain, ®istry.public_key).await {
Ok(verified) => {
if !verified {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
"DNS public key hash does not match registry key",
);
}
Some(true)
}
Err(e) => {
return EmailVerificationResultV2::err(
&jacs_id,
reputation_tier,
&format!("DNS verification failed: {e}"),
);
}
}
} else {
None };
let content_result = verify_email_content(&trusted_doc, &parts);
let field_results = content_result
.field_results
.into_iter()
.map(convert_field_result)
.collect::<Vec<_>>();
let mut chain = content_result
.chain
.into_iter()
.map(convert_chain_entry)
.collect::<Vec<_>>();
verify_parent_chain_entries(&mut chain, &parts, hai_url, &agent).await;
let fields_valid = !field_results.iter().any(|r| r.status == FieldStatus::Fail);
let chain_valid = chain.iter().all(|entry| entry.valid);
let valid = fields_valid && chain_valid;
EmailVerificationResultV2 {
valid,
jacs_id,
algorithm: sig_algorithm,
reputation_tier: reputation_tier.clone(),
dns_verified,
field_results,
chain,
error: None,
agent_status,
benchmarks_completed,
}
}
async fn verify_parent_chain_entries(
chain: &mut [ChainEntry],
parts: &ParsedEmailParts,
hai_url: &str,
agent: &SimpleAgent,
) {
for entry in chain.iter_mut().skip(1) {
if entry.valid {
continue; }
let parent_att = parts.jacs_attachments.iter().find(|att| {
serde_json::from_slice::<serde_json::Value>(&att.content)
.ok()
.and_then(|v| {
v.get("jacsSignature")
.and_then(|sig| sig.get("agentID"))
.and_then(|id| id.as_str())
.map(|id| id == entry.jacs_id)
})
.unwrap_or(false)
});
let Some(parent_att) = parent_att else {
continue; };
let parent_value: serde_json::Value = match serde_json::from_slice(&parent_att.content) {
Ok(v) => v,
Err(_) => continue,
};
let parent_issuer = parent_value
.get("jacsSignature")
.and_then(|sig| sig.get("agentID"))
.and_then(|id| id.as_str())
.unwrap_or("");
let parent_content = match parent_value.get("content") {
Some(c) => c,
None => continue,
};
let parent_payload: EmailSignaturePayload =
match serde_json::from_value(parent_content.clone()) {
Ok(p) => p,
Err(_) => continue,
};
let parent_algorithm = parent_value
.get("jacsSignature")
.and_then(|sig| sig.get("signingAlgorithm"))
.and_then(|a| a.as_str())
.unwrap_or("");
let parent_email = &parent_payload.headers.from.value;
let registry = match fetch_public_key_from_registry(hai_url, parent_email).await {
Ok(r) => r,
Err(_) => continue, };
if parent_issuer != registry.jacs_id {
continue; }
if !algorithms_match(parent_algorithm, ®istry.algorithm) {
continue; }
let raw_pub_key = match extract_public_key_bytes(®istry.public_key) {
Ok(k) => k,
Err(_) => continue,
};
let parent_json = match std::str::from_utf8(&parent_att.content) {
Ok(s) => s,
Err(_) => continue,
};
if agent
.verify_with_key(parent_json, raw_pub_key)
.is_ok_and(|r| r.valid)
{
entry.valid = true;
}
}
}
fn create_verification_agent() -> Result<SimpleAgent> {
let tmp = tempfile::tempdir().map_err(|e| {
HaiError::Provider(format!(
"Failed to create temp directory for verification agent: {e}"
))
})?;
let tmp_path = tmp.path().to_string_lossy().to_string();
let params = CreateAgentParams::builder()
.name("hai-verifier")
.password("hai-verify-ephemeral")
.algorithm("ring-Ed25519")
.data_directory(&format!("{}/jacs_data", tmp_path))
.key_directory(&format!("{}/jacs_keys", tmp_path))
.config_path(&format!("{}/jacs.config.json", tmp_path))
.build();
let (agent, _info) = SimpleAgent::create_with_params(params)
.map_err(|e| HaiError::Provider(format!("Failed to create verification agent: {e}")))?;
std::mem::forget(tmp);
Ok(agent)
}
pub async fn fetch_public_key_from_registry(
hai_url: &str,
email: &str,
) -> Result<KeyRegistryResponse> {
let encoded_email =
percent_encoding::utf8_percent_encode(email, percent_encoding::NON_ALPHANUMERIC);
let url = format!("{}/api/agents/keys/{}", hai_url, encoded_email);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.send()
.await
.map_err(|e| HaiError::Provider(format!("Failed to fetch public key: {e}")))?;
if !resp.status().is_success() {
return Err(HaiError::Provider(format!(
"Registry returned HTTP {}",
resp.status().as_u16()
)));
}
let registry: KeyRegistryResponse = resp
.json()
.await
.map_err(|e| HaiError::Provider(format!("Failed to parse registry response: {e}")))?;
if registry.public_key.is_empty() {
return Err(HaiError::Provider("No public key found in registry".into()));
}
Ok(registry)
}
pub async fn verify_dns_public_key(domain: &str, public_key_pem: &str) -> Result<bool> {
let expected_hash = {
let mut hasher = Sha256::new();
hasher.update(public_key_pem.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
};
let txt_name = format!("_v1.agent.jacs.{domain}");
let txt_records = fetch_dns_txt_records(&txt_name).await?;
for record in &txt_records {
for part in record.split(';') {
let part = part.trim();
if let Some(hash_value) = part.strip_prefix("jacs_public_key_hash=") {
let hash_value = hash_value.trim();
if hash_value == expected_hash {
return Ok(true);
}
return Ok(false);
}
}
}
Ok(false)
}
async fn fetch_dns_txt_records(name: &str) -> Result<Vec<String>> {
let url = format!(
"https://dns.google/resolve?name={}&type=TXT",
percent_encoding::utf8_percent_encode(name, percent_encoding::NON_ALPHANUMERIC)
);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("Accept", "application/dns-json")
.send()
.await
.map_err(|e| HaiError::Provider(format!("DNS lookup failed: {e}")))?;
if !resp.status().is_success() {
return Err(HaiError::Provider(format!(
"DNS lookup returned HTTP {}",
resp.status().as_u16()
)));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| HaiError::Provider(format!("Failed to parse DNS response: {e}")))?;
let mut records = Vec::new();
if let Some(answers) = body.get("Answer").and_then(|a| a.as_array()) {
for answer in answers {
if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
let clean = data.trim_matches('"');
records.push(clean.to_string());
}
}
}
Ok(records)
}
fn extract_domain(email: &str) -> String {
let clean = email
.rfind('<')
.and_then(|start| {
email[start + 1..]
.find('>')
.map(|end| &email[start + 1..start + 1 + end])
})
.unwrap_or(email);
clean
.rfind('@')
.map(|pos| &clean[pos + 1..])
.unwrap_or(clean)
.to_string()
}
fn extract_public_key_bytes(pem: &str) -> Result<Vec<u8>> {
let pem_lines: Vec<&str> = pem.lines().filter(|l| !l.starts_with("-----")).collect();
let der_bytes = base64::engine::general_purpose::STANDARD
.decode(pem_lines.join(""))
.map_err(|e| HaiError::Provider(format!("Invalid PEM encoding: {e}")))?;
if der_bytes.len() < 12 {
return Err(HaiError::Provider("Public key DER too short".into()));
}
let ed25519_oid: &[u8] = &[0x06, 0x03, 0x2b, 0x65, 0x70];
let is_ed25519 = der_bytes
.windows(ed25519_oid.len())
.any(|w| w == ed25519_oid);
if is_ed25519 {
if der_bytes.len() < 32 {
return Err(HaiError::Provider(
"Ed25519 DER too short for 32-byte key".into(),
));
}
Ok(der_bytes[der_bytes.len() - 32..].to_vec())
} else {
Ok(der_bytes)
}
}
fn algorithms_match(a: &str, b: &str) -> bool {
jacs::email::normalize_algorithm(a) == jacs::email::normalize_algorithm(b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_domain_simple() {
assert_eq!(extract_domain("agent@example.com"), "example.com");
}
#[test]
fn extract_domain_with_angle_brackets() {
assert_eq!(extract_domain("Agent <agent@example.com>"), "example.com");
}
#[test]
fn extract_domain_no_at_sign() {
assert_eq!(extract_domain("nodomain"), "nodomain");
}
#[test]
fn algorithms_match_ed25519_variants() {
assert!(algorithms_match("ed25519", "ed25519"));
assert!(algorithms_match("ed25519", "ring-ed25519"));
assert!(algorithms_match("ring-ed25519", "ed25519"));
}
#[test]
fn algorithms_match_rsa_variants() {
assert!(algorithms_match("rsa-pss", "rsa-pss"));
assert!(algorithms_match("rsa-pss", "rsa-pss-sha256"));
}
#[test]
fn algorithms_mismatch() {
assert!(!algorithms_match("ed25519", "rsa-pss"));
}
#[test]
fn err_result_sets_fields() {
let r = EmailVerificationResultV2::err("agent:v1", "free_chaotic", "test error");
assert!(!r.valid);
assert_eq!(r.jacs_id, "agent:v1");
assert_eq!(r.reputation_tier, "free_chaotic");
assert_eq!(r.error.as_deref(), Some("test error"));
assert!(r.field_results.is_empty());
assert!(r.chain.is_empty());
assert!(r.dns_verified.is_none());
}
use super::{CreateAgentParams, SimpleAgent};
fn create_test_agent(name: &str) -> (SimpleAgent, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("create temp dir");
let tmp_path = tmp.path().to_string_lossy().to_string();
let params = CreateAgentParams::builder()
.name(name)
.password("TestHaiSdk!2026")
.algorithm("ring-Ed25519")
.data_directory(&format!("{}/jacs_data", tmp_path))
.key_directory(&format!("{}/jacs_keys", tmp_path))
.config_path(&format!("{}/jacs.config.json", tmp_path))
.build();
let (agent, _info) = SimpleAgent::create_with_params(params).expect("create test agent");
unsafe {
std::env::set_var("JACS_PRIVATE_KEY_PASSWORD", "TestHaiSdk!2026");
std::env::set_var("JACS_KEY_DIRECTORY", format!("{}/jacs_keys", tmp_path));
std::env::set_var("JACS_AGENT_PRIVATE_KEY_FILENAME", "jacs.private.pem.enc");
}
(agent, tmp)
}
#[test]
fn sign_email_and_extract_doc() {
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n";
let (agent, _tmp) = create_test_agent("test-agent");
let signed = sign_email(email, &agent).unwrap();
let doc_bytes = get_jacs_attachment(&signed).unwrap();
let jacs_doc: serde_json::Value = serde_json::from_slice(&doc_bytes).unwrap();
assert_eq!(jacs_doc["jacsType"].as_str(), Some("message"));
assert!(jacs_doc.get("jacsId").is_some(), "should have jacsId");
assert!(
jacs_doc.get("jacsSignature").is_some(),
"should have jacsSignature"
);
assert!(
jacs_doc.get("content").is_some(),
"should have content field"
);
let payload: EmailSignaturePayload =
serde_json::from_value(jacs_doc["content"].clone()).unwrap();
assert!(!payload.headers.from.value.is_empty());
let agent_id = jacs_doc["jacsSignature"]["agentID"].as_str().unwrap_or("");
assert!(!agent_id.is_empty());
}
#[tokio::test]
async fn verify_email_missing_jacs_attachment() {
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n";
let result = verify_email(email, "http://127.0.0.1:1").await;
assert!(!result.valid);
assert!(result
.error
.as_deref()
.unwrap()
.contains("No JACS signature found"));
}
#[tokio::test]
async fn verify_email_registry_unreachable() {
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n";
let (agent, _tmp) = create_test_agent("test-agent");
let signed = sign_email(email, &agent).unwrap();
let result = verify_email(&signed, "http://127.0.0.1:1").await;
assert!(!result.valid);
assert!(result
.error
.as_deref()
.unwrap()
.contains("Registry lookup failed"));
}
#[tokio::test]
async fn verify_email_with_mock_registry_identity_mismatch() {
let (agent, _tmp) = create_test_agent("test-agent");
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n";
let signed = sign_email(email, &agent).unwrap();
let server = httpmock::MockServer::start();
server.mock(|when, then| {
when.method("GET")
.path_includes("/api/agents/keys/");
then.status(200)
.json_body(serde_json::json!({
"email": "sender@example.com",
"jacs_id": "wrong-agent:v1", "public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjA=\n-----END PUBLIC KEY-----",
"algorithm": "ed25519",
"reputation_tier": "free_chaotic",
"registered_at": "2026-01-01T00:00:00Z"
}));
});
let result = verify_email(&signed, &server.base_url()).await;
assert!(!result.valid);
assert!(result
.error
.as_deref()
.unwrap()
.contains("Identity mismatch"));
assert!(result.error.as_deref().unwrap().contains("issuer"));
}
#[tokio::test]
async fn verify_email_with_mock_registry_email_mismatch() {
let (agent, _tmp) = create_test_agent("test-agent");
let email = b"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\nDate: Fri, 28 Feb 2026 12:00:00 +0000\r\nMessage-ID: <test@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello World\r\n";
let signed = sign_email(email, &agent).unwrap();
let doc_bytes = get_jacs_attachment(&signed).unwrap();
let jacs_doc: serde_json::Value = serde_json::from_slice(&doc_bytes).unwrap();
let real_agent_id = jacs_doc["jacsSignature"]["agentID"]
.as_str()
.unwrap_or("unknown")
.to_string();
let server = httpmock::MockServer::start();
server.mock(|when, then| {
when.method("GET")
.path_includes("/api/agents/keys/");
then.status(200)
.json_body(serde_json::json!({
"email": "different@example.com", "jacs_id": real_agent_id,
"public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjAxMjA=\n-----END PUBLIC KEY-----",
"algorithm": "ed25519",
"reputation_tier": "free_chaotic",
"registered_at": "2026-01-01T00:00:00Z"
}));
});
let result = verify_email(&signed, &server.base_url()).await;
assert!(!result.valid);
assert!(result
.error
.as_deref()
.unwrap()
.contains("Identity mismatch"));
assert!(result.error.as_deref().unwrap().contains("From"));
}
#[test]
fn registry_url_encodes_special_email_chars() {
let encoded: String = percent_encoding::utf8_percent_encode(
"agent+tag@example.com",
percent_encoding::NON_ALPHANUMERIC,
)
.to_string();
assert!(
!encoded.contains('+'),
"'+' should be percent-encoded in URL path, got: {encoded}"
);
assert!(
encoded.contains("%2B") || encoded.contains("%2b"),
"'+' should become %2B, got: {encoded}"
);
assert!(
encoded.contains("%40"),
"'@' should be percent-encoded, got: {encoded}"
);
}
}