use crate::agent::Agent;
use crate::agent::boilerplate::BoilerPlate;
use crate::crypt::KeyManager;
use crate::error::JacsError;
use crate::time_utils::now_rfc3339;
use base64::Engine;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub fn canonicalize_json(value: &serde_json::Value) -> String {
serde_json_canonicalizer::to_string(value).unwrap_or_else(|_| "null".to_string())
}
pub fn build_auth_header(agent: &mut Agent) -> Result<String, JacsError> {
let jacs_id = agent.get_lookup_id()?;
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("build_auth_header: system clock error: {}", e))?
.as_secs();
let message = format!("{jacs_id}:{ts}");
let signature = agent.sign_string(&message)?;
Ok(format!("JACS {jacs_id}:{ts}:{signature}"))
}
pub fn sign_response(agent: &mut Agent, payload: &Value) -> Result<Value, JacsError> {
let jacs_id = agent.get_lookup_id()?;
let now = now_rfc3339();
let canonical = canonicalize_json(payload);
let hash = {
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
};
let signature = agent.sign_string(&canonical)?;
let data: Value = serde_json::from_str(&canonical)
.map_err(|e| format!("sign_response: failed to re-parse canonical JSON: {e}"))?;
let envelope = serde_json::json!({
"version": "1.0.0",
"document_type": "job_response",
"data": data,
"metadata": {
"issuer": jacs_id,
"document_id": Uuid::new_v4().to_string(),
"created_at": now,
"hash": hash,
},
"jacsSignature": {
"agentID": jacs_id,
"date": now,
"signature": signature,
},
});
Ok(envelope)
}
pub fn encode_verify_payload(document: &str) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(document.as_bytes())
}
pub fn decode_verify_payload(encoded: &str) -> Result<String, JacsError> {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| format!("decode_verify_payload: invalid base64url: {e}"))?;
String::from_utf8(bytes)
.map_err(|e| format!("decode_verify_payload: invalid UTF-8: {e}").into())
}
pub fn extract_document_id(document: &str) -> Result<String, JacsError> {
let value: Value = serde_json::from_str(document)
.map_err(|e| format!("extract_document_id: invalid JSON: {e}"))?;
let doc_id = value
.get("jacsDocumentId")
.and_then(Value::as_str)
.or_else(|| value.get("document_id").and_then(Value::as_str))
.or_else(|| value.get("id").and_then(Value::as_str));
doc_id.map(String::from).ok_or_else(|| {
"extract_document_id: no ID field found (expected jacsDocumentId, document_id, or id)"
.into()
})
}
pub fn unwrap_signed_event(
agent: &Agent,
event: &Value,
server_public_keys: &HashMap<String, Vec<u8>>,
) -> Result<(Value, bool), JacsError> {
if let (Some(data), Some(jacs_sig)) = (event.get("data"), event.get("jacsSignature")) {
let agent_id = jacs_sig
.get("agentID")
.and_then(Value::as_str)
.unwrap_or("");
if let Some(public_key) = server_public_keys.get(agent_id) {
let signature = jacs_sig
.get("signature")
.and_then(Value::as_str)
.ok_or_else(|| {
format!(
"unwrap_signed_event: jacsSignature for agentID=\"{}\" is missing \"signature\" field",
agent_id
)
})?;
let signing_algorithm = jacs_sig
.get("signingAlgorithm")
.and_then(Value::as_str)
.map(String::from);
let canonical = canonicalize_json(data);
agent
.verify_string(&canonical, signature, public_key.clone(), signing_algorithm)
.map_err(|e| {
format!(
"JACS signature verification failed for agentID=\"{}\": {}",
agent_id, e
)
})?;
return Ok((data.clone(), true));
}
return Ok((data.clone(), false));
}
if let Some(payload) = event.get("payload") {
return Ok((payload.clone(), false));
}
Ok((event.clone(), false))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonicalize_sorts_keys() {
let input = json!({"b": 2, "a": 1});
let result = canonicalize_json(&input);
assert_eq!(result, r#"{"a":1,"b":2}"#);
}
#[test]
fn canonicalize_nested_objects() {
let input = json!({"z": {"b": 2, "a": 1}, "a": 0});
let result = canonicalize_json(&input);
assert_eq!(result, r#"{"a":0,"z":{"a":1,"b":2}}"#);
}
#[test]
fn canonicalize_null() {
let input = json!(null);
let result = canonicalize_json(&input);
assert_eq!(result, "null");
}
#[test]
fn canonicalize_empty_object() {
let input = json!({});
let result = canonicalize_json(&input);
assert_eq!(result, "{}");
}
#[test]
fn canonicalize_empty_array() {
let input = json!([]);
let result = canonicalize_json(&input);
assert_eq!(result, "[]");
}
fn make_test_agent() -> Agent {
let mut agent = Agent::ephemeral("ring-Ed25519").expect("Failed to create ephemeral agent");
let agent_string = crate::create_minimal_blank_agent("ai".to_string(), None, None, None)
.expect("Failed to create minimal agent JSON");
agent
.create_agent_and_load(&agent_string, true, Some("ring-Ed25519"))
.expect("Failed to create and load agent");
agent
}
#[test]
fn auth_header_starts_with_jacs_prefix() {
let mut agent = make_test_agent();
let header = build_auth_header(&mut agent).expect("build_auth_header failed");
assert!(
header.starts_with("JACS "),
"Header must start with 'JACS ', got: {header}"
);
}
#[test]
fn auth_header_has_three_colon_separated_parts() {
let mut agent = make_test_agent();
let header = build_auth_header(&mut agent).expect("build_auth_header failed");
let payload = header
.strip_prefix("JACS ")
.expect("Missing 'JACS ' prefix");
let parts: Vec<&str> = payload.splitn(4, ':').collect();
assert_eq!(
parts.len(),
4,
"Expected 4 colon-separated parts (id:version:timestamp:sig), got {}: {:?}",
parts.len(),
parts
);
}
#[test]
fn auth_header_timestamp_is_recent() {
let mut agent = make_test_agent();
let header = build_auth_header(&mut agent).expect("build_auth_header failed");
let payload = header
.strip_prefix("JACS ")
.expect("Missing 'JACS ' prefix");
let parts: Vec<&str> = payload.splitn(4, ':').collect();
let ts: u64 = parts[2].parse().expect("Timestamp should be a u64");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(
now.abs_diff(ts) < 5,
"Timestamp should be within 5 seconds of now"
);
}
#[test]
fn auth_header_signature_is_nonempty_base64() {
let mut agent = make_test_agent();
let header = build_auth_header(&mut agent).expect("build_auth_header failed");
let payload = header
.strip_prefix("JACS ")
.expect("Missing 'JACS ' prefix");
let parts: Vec<&str> = payload.splitn(4, ':').collect();
let sig = parts[3];
assert!(!sig.is_empty(), "Signature must not be empty");
use base64::{Engine as _, engine::general_purpose::STANDARD};
STANDARD
.decode(sig)
.expect("Signature should be valid base64");
}
#[test]
fn sign_response_has_required_top_level_keys() {
let mut agent = make_test_agent();
let payload = json!({"answer": 42});
let envelope = sign_response(&mut agent, &payload).expect("sign_response failed");
for key in &[
"version",
"document_type",
"data",
"metadata",
"jacsSignature",
] {
assert!(
envelope.get(key).is_some(),
"Envelope missing required key: {key}"
);
}
}
#[test]
fn sign_response_version_and_document_type() {
let mut agent = make_test_agent();
let payload = json!({"foo": "bar"});
let envelope = sign_response(&mut agent, &payload).expect("sign_response failed");
assert_eq!(envelope["version"], "1.0.0");
assert_eq!(envelope["document_type"], "job_response");
}
#[test]
fn sign_response_metadata_has_required_fields() {
let mut agent = make_test_agent();
let payload = json!({"x": 1});
let envelope = sign_response(&mut agent, &payload).expect("sign_response failed");
let metadata = &envelope["metadata"];
for key in &["issuer", "document_id", "created_at", "hash"] {
assert!(
metadata.get(key).is_some(),
"metadata missing required field: {key}"
);
}
let hash = metadata["hash"].as_str().expect("hash should be string");
assert_eq!(hash.len(), 64, "SHA-256 hex hash should be 64 chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash should be valid hex"
);
}
#[test]
fn sign_response_jacs_signature_has_required_fields() {
let mut agent = make_test_agent();
let payload = json!({"y": 2});
let envelope = sign_response(&mut agent, &payload).expect("sign_response failed");
let sig = &envelope["jacsSignature"];
for key in &["agentID", "date", "signature"] {
assert!(
sig.get(key).is_some(),
"jacsSignature missing required field: {key}"
);
}
let signature = sig["signature"]
.as_str()
.expect("signature should be string");
assert!(!signature.is_empty(), "signature must not be empty");
}
#[test]
fn encode_verify_payload_uses_url_safe_base64_no_padding() {
let encoded = encode_verify_payload(r#"{"k":">>>>"}"#);
assert!(!encoded.contains('+'), "URL-safe base64 must not contain +");
assert!(!encoded.contains('/'), "URL-safe base64 must not contain /");
assert!(
!encoded.contains('='),
"URL-safe base64 must not contain = (no padding)"
);
}
#[test]
fn encode_decode_round_trips() {
let original = r#"{"hello":"world","num":123}"#;
let encoded = encode_verify_payload(original);
let decoded = decode_verify_payload(&encoded).expect("should decode");
assert_eq!(decoded, original);
}
#[test]
fn extract_id_prefers_jacs_document_id() {
let doc = r#"{"jacsDocumentId":"preferred","document_id":"fallback","id":"last"}"#;
let id = extract_document_id(doc).expect("should succeed");
assert_eq!(id, "preferred");
}
#[test]
fn extract_id_falls_back_to_document_id() {
let doc = r#"{"document_id":"def-456"}"#;
let id = extract_document_id(doc).expect("should succeed");
assert_eq!(id, "def-456");
}
#[test]
fn extract_id_falls_back_to_id() {
let doc = r#"{"id":"ghi-789"}"#;
let id = extract_document_id(doc).expect("should succeed");
assert_eq!(id, "ghi-789");
}
#[test]
fn extract_id_errors_when_no_id_field() {
let doc = r#"{"name":"no-id-here"}"#;
let result = extract_document_id(doc);
assert!(result.is_err(), "Should error when no ID field is present");
}
#[test]
fn extract_id_errors_on_invalid_json() {
let result = extract_document_id("not json");
assert!(result.is_err(), "Should error on invalid JSON input");
}
#[test]
fn unwrap_canonical_with_unknown_agent_returns_data_unverified() {
let agent = make_test_agent();
let data = json!({"result": "hello"});
let event = json!({
"version": "1.0.0",
"document_type": "job_response",
"data": data,
"metadata": {
"issuer": "unknown-agent:v1",
"document_id": "doc-1",
"created_at": "2026-01-01T00:00:00Z",
"hash": "abc123",
},
"jacsSignature": {
"agentID": "unknown-agent:v1",
"date": "2026-01-01T00:00:00Z",
"signature": "fakesig",
},
});
let keys: HashMap<String, Vec<u8>> = HashMap::new();
let (result_data, verified) =
unwrap_signed_event(&agent, &event, &keys).expect("should not error");
assert_eq!(result_data, data);
assert!(!verified, "Unknown agent should return verified=false");
}
#[test]
fn unwrap_legacy_payload_returns_payload_unverified() {
let agent = make_test_agent();
let payload = json!({"status": "ok", "items": [1, 2, 3]});
let event = json!({
"payload": payload,
"signature": {
"key_id": "some-key",
"signature": "irrelevant",
},
"metadata": {
"timestamp": "2026-01-01T00:00:00Z",
},
});
let keys: HashMap<String, Vec<u8>> = HashMap::new();
let (result_data, verified) =
unwrap_signed_event(&agent, &event, &keys).expect("should not error");
assert_eq!(result_data, payload);
assert!(!verified, "Legacy format should return verified=false");
}
#[test]
fn unwrap_plain_event_returns_event_unverified() {
let agent = make_test_agent();
let event = json!({"type": "heartbeat", "ts": 12345});
let keys: HashMap<String, Vec<u8>> = HashMap::new();
let (result_data, verified) =
unwrap_signed_event(&agent, &event, &keys).expect("should not error");
assert_eq!(result_data, event);
assert!(!verified, "Plain event should return verified=false");
}
#[test]
fn unwrap_canonical_with_known_key_verifies_signature() {
let mut agent = make_test_agent();
let payload = json!({"answer": 42});
let envelope = sign_response(&mut agent, &payload).expect("sign_response failed");
let agent_id = envelope["jacsSignature"]["agentID"]
.as_str()
.expect("agentID should be a string")
.to_string();
let public_key = agent
.get_public_key()
.expect("should be able to get public key");
let mut keys: HashMap<String, Vec<u8>> = HashMap::new();
keys.insert(agent_id, public_key);
let (result_data, verified) =
unwrap_signed_event(&agent, &envelope, &keys).expect("should not error");
assert_eq!(result_data["answer"], 42);
assert!(
verified,
"Known key with valid signature should return verified=true"
);
}
#[test]
fn unwrap_canonical_with_known_key_and_bad_signature_errors() {
let agent = make_test_agent();
let data = json!({"result": "tampered"});
let agent_id = "known-agent:v1".to_string();
let public_key = agent
.get_public_key()
.expect("should be able to get public key");
let event = json!({
"version": "1.0.0",
"document_type": "job_response",
"data": data,
"metadata": {
"issuer": agent_id,
"document_id": "doc-bad",
"created_at": "2026-01-01T00:00:00Z",
"hash": "000",
},
"jacsSignature": {
"agentID": agent_id,
"date": "2026-01-01T00:00:00Z",
"signature": "dGhpcyBpcyBub3QgYSB2YWxpZCBzaWduYXR1cmU=",
},
});
let mut keys: HashMap<String, Vec<u8>> = HashMap::new();
keys.insert(agent_id, public_key);
let result = unwrap_signed_event(&agent, &event, &keys);
assert!(
result.is_err(),
"Known key with bad signature must return an error, not silent false"
);
}
}