use crate::CoreError;
use crate::canonical::canonicalize_json_try;
use crate::sign::{Ed25519DalekSigner, Pq2025Signer, SigningAlgorithm};
use serde_json::{Map, Value, json};
pub const SIGNATURE_CONTENT_VERSION_FIELDNAME: &str = "signatureContentVersion";
pub const SIGNATURE_CONTENT_VERSION_V2: &str = "jacs-signature-v2";
pub const SIGNATURE_CONTENT_DOMAIN_V2: &str = "jacs.signature.v2";
pub const JACS_IGNORE_FIELDS: &[&str] = &[
"jacsSha256",
"jacsSignature",
"jacsAgentSignature",
"jacsAgreement",
"jacsRegistration",
"jacsTaskStartAgreement",
"jacsTaskEndAgreement",
];
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VerificationOutcome {
pub valid: bool,
pub signer_id: String,
pub timestamp: String,
pub data: Value,
pub errors: Vec<String>,
}
pub fn build_signature_content_v2(
document: &Value,
fields: &[String],
placement_key: &str,
signature_metadata: &Value,
) -> Result<String, CoreError> {
let mut metadata = signature_metadata.clone();
let metadata_obj = metadata.as_object_mut().ok_or_else(|| {
CoreError::MalformedDocument(format!(
"signature metadata at '{}' must be a JSON object",
placement_key
))
})?;
metadata_obj.remove("signature");
let mut field_entries = Vec::with_capacity(fields.len());
for key in fields {
if key == placement_key || JACS_IGNORE_FIELDS.contains(&key.as_str()) {
return Err(CoreError::MalformedDocument(format!(
"signed field '{}' is reserved",
key
)));
}
let value = document.get(key).ok_or_else(|| {
CoreError::MalformedDocument(format!("signed field '{}' missing from document", key))
})?;
field_entries.push(json!({ "name": key, "value": value }));
}
let payload = json!({
"domain": SIGNATURE_CONTENT_DOMAIN_V2,
"placementKey": placement_key,
"fields": field_entries,
"signatureMetadata": metadata,
});
canonicalize_json_try(&payload)
}
pub fn default_signed_fields(document: &Value, placement_key: &str) -> Vec<String> {
let Some(obj) = document.as_object() else {
return Vec::new();
};
let mut fields: Vec<String> = obj
.keys()
.filter(|k| k.as_str() != placement_key && !JACS_IGNORE_FIELDS.contains(&k.as_str()))
.cloned()
.collect();
fields.sort();
fields.dedup();
fields
}
pub fn verify_detached(
algorithm: SigningAlgorithm,
public_key: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<(), CoreError> {
match algorithm {
SigningAlgorithm::Ed25519 => Ed25519DalekSigner::verify(public_key, message, signature),
SigningAlgorithm::Pq2025 => Pq2025Signer::verify(public_key, message, signature),
}
}
pub fn verify_document(
signed: &Value,
public_key: &[u8],
algorithm: SigningAlgorithm,
placement_key: &str,
) -> Result<VerificationOutcome, CoreError> {
let sig_obj = signed.get(placement_key).ok_or_else(|| {
CoreError::MalformedDocument(format!(
"signed document missing '{}' object",
placement_key
))
})?;
let doc_algorithm_str = sig_obj
.get("signingAlgorithm")
.and_then(|v| v.as_str())
.ok_or_else(|| {
CoreError::MalformedDocument(format!(
"'{}.signingAlgorithm' missing or not a string",
placement_key
))
})?;
let doc_algorithm = SigningAlgorithm::from_wire_str(doc_algorithm_str).ok_or_else(|| {
CoreError::UnsupportedAlgorithm(format!(
"signed document algorithm '{}' is not recognized",
doc_algorithm_str
))
})?;
if doc_algorithm != algorithm {
return Err(CoreError::AlgorithmMismatch {
expected: algorithm.to_string(),
actual: doc_algorithm_str.to_string(),
});
}
let signature_b64 = sig_obj
.get("signature")
.and_then(|v| v.as_str())
.ok_or_else(|| {
CoreError::MalformedDocument(format!(
"'{}.signature' missing or not a string",
placement_key
))
})?;
let signature_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_b64).map_err(
|e| CoreError::MalformedDocument(format!("invalid base64 signature: {}", e)),
)?;
let fields = sig_obj
.get("fields")
.and_then(|v| v.as_array())
.ok_or_else(|| {
CoreError::MalformedDocument(format!(
"'{}.fields' missing or not an array",
placement_key
))
})?
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>();
if placement_key == "jacsSignature"
&& let Some(obj) = signed.as_object()
{
for key in obj.keys() {
if key == placement_key
|| JACS_IGNORE_FIELDS.contains(&key.as_str())
|| fields.iter().any(|f| f == key)
{
continue;
}
return Err(CoreError::MalformedDocument(format!(
"Unsigned top-level field '{}' is present but not covered by '{}.fields'; the v2 signature does not authenticate it.",
key, placement_key
)));
}
}
let canonical = build_signature_content_v2(signed, &fields, placement_key, sig_obj)?;
let mut outcome = VerificationOutcome {
valid: false,
signer_id: sig_obj
.get("agentID")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
timestamp: sig_obj
.get("date")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
data: signed.clone(),
errors: Vec::new(),
};
match verify_detached(
algorithm,
public_key,
canonical.as_bytes(),
&signature_bytes,
) {
Ok(()) => {
outcome.valid = true;
}
Err(e) => {
outcome.errors.push(format!("{}", e));
}
}
Ok(outcome)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub fn build_signature_metadata(
agent_id: &str,
agent_version: &str,
date: &str,
iat: i64,
jti: &str,
algorithm: SigningAlgorithm,
public_key_hash: &str,
fields: &[String],
) -> Value {
let mut obj = Map::new();
obj.insert("agentID".into(), json!(agent_id));
obj.insert("agentVersion".into(), json!(agent_version));
obj.insert("date".into(), json!(date));
obj.insert("iat".into(), json!(iat));
obj.insert("jti".into(), json!(jti));
obj.insert("signature".into(), json!(""));
obj.insert("signingAlgorithm".into(), json!(algorithm.as_str()));
obj.insert("publicKeyHash".into(), json!(public_key_hash));
obj.insert("fields".into(), json!(fields));
obj.insert(
SIGNATURE_CONTENT_VERSION_FIELDNAME.into(),
json!(SIGNATURE_CONTENT_VERSION_V2),
);
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::CoreAgent;
use serde_json::json;
#[test]
fn verify_document_rejects_unsigned_top_level_field_under_document_signature() {
let mut agent = CoreAgent::ephemeral(SigningAlgorithm::Ed25519).expect("ephemeral");
let public_key = agent.public_key().to_vec();
let mut signed = agent
.sign_message(&json!({ "safe": "x" }))
.expect("sign message");
signed["evil"] = json!("x");
let err = verify_document(
&signed,
&public_key,
SigningAlgorithm::Ed25519,
"jacsSignature",
)
.expect_err("unsigned top-level field must be rejected");
match err {
CoreError::MalformedDocument(message) => {
assert!(message.contains("Unsigned top-level field 'evil'"));
}
other => panic!("expected MalformedDocument, got {:?}", other),
}
}
}