jacs_core/verify.rs
1//! `VerificationOutcome` + the canonical signature payload helpers used by
2//! both `CoreAgent::sign_message` and `CoreAgent::verify`.
3//!
4//! The signature payload layout matches `jacs::agent::build_signature_content_v2`
5//! exactly (PRD §4.4 / §4.5) so canonical bytes are identical across native
6//! `jacs` and `jacs-core`. The fields written into / read out of the
7//! `jacsSignature` object match `jacs::agent::signing_procedure` so the wire
8//! shape is interchangeable.
9//!
10//! See PRD §4.2.
11
12use crate::CoreError;
13use crate::canonical::canonicalize_json_try;
14use crate::sign::{Ed25519DalekSigner, Pq2025Signer, SigningAlgorithm};
15use serde_json::{Map, Value, json};
16
17// =========================================================================
18// Wire constants — identical to native `jacs::agent` so the canonical
19// bytes round-trip across the protocol boundary.
20// =========================================================================
21
22/// Field name carrying the signed payload metadata version. Matches
23/// `jacs::agent::SIGNATURE_CONTENT_VERSION_FIELDNAME`.
24pub const SIGNATURE_CONTENT_VERSION_FIELDNAME: &str = "signatureContentVersion";
25/// Wire value for v2 signature payloads. Matches
26/// `jacs::agent::SIGNATURE_CONTENT_VERSION_V2`.
27pub const SIGNATURE_CONTENT_VERSION_V2: &str = "jacs-signature-v2";
28/// Domain separator embedded in the canonical signature payload. Matches
29/// `jacs::agent::SIGNATURE_CONTENT_DOMAIN_V2`.
30pub const SIGNATURE_CONTENT_DOMAIN_V2: &str = "jacs.signature.v2";
31
32/// Fields excluded from signed-field selection. Mirrors
33/// `jacs::agent::JACS_IGNORE_FIELDS` for the subset jacs-core needs (it does
34/// not need agreement / agent-registration / task-agreement fields because
35/// those live in higher-layer schemas — `jacs-core::agreements` will add
36/// its own equivalents in Task 014).
37pub const JACS_IGNORE_FIELDS: &[&str] = &[
38 "jacsSha256",
39 "jacsSignature",
40 "jacsAgentSignature",
41 "jacsAgreement",
42 "jacsRegistration",
43 "jacsTaskStartAgreement",
44 "jacsTaskEndAgreement",
45];
46
47// =========================================================================
48// VerificationOutcome
49// =========================================================================
50
51/// Structured verification result returned by `CoreAgent::verify` and
52/// `CoreAgent::verify_with_key`.
53///
54/// `valid` is `true` iff the cryptographic signature reconstructs and the
55/// algorithm matches the expected algorithm. The other fields are
56/// extracted from the signed document so callers do not have to re-parse
57/// the JSON to find them.
58///
59/// `errors` is a list of human-readable error strings when `valid` is
60/// `false`. It is always empty when `valid` is `true`.
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct VerificationOutcome {
63 /// Whether the cryptographic signature verified.
64 pub valid: bool,
65 /// `jacsSignature.agentID`, empty if the field is absent.
66 pub signer_id: String,
67 /// `jacsSignature.date`, empty if the field is absent.
68 pub timestamp: String,
69 /// The full signed document, returned as-is so callers do not have to
70 /// re-parse the JSON.
71 pub data: Value,
72 /// Human-readable error descriptions when `valid` is `false`.
73 pub errors: Vec<String>,
74}
75
76// =========================================================================
77// Canonical signature payload builder (v2)
78// =========================================================================
79
80/// Build the canonical bytes that the signer signs over (and the verifier
81/// reconstructs).
82///
83/// The shape is JSON-canonicalized via `serde_json_canonicalizer` — same
84/// canonicalizer used by native `jacs`. The four required keys are
85/// `domain`, `placementKey`, `fields`, and `signatureMetadata`.
86///
87/// `signature_metadata` carries everything that ends up under
88/// `jacsSignature` *except* the `signature` field itself (which is
89/// stripped here because it is undefined at sign time).
90pub fn build_signature_content_v2(
91 document: &Value,
92 fields: &[String],
93 placement_key: &str,
94 signature_metadata: &Value,
95) -> Result<String, CoreError> {
96 let mut metadata = signature_metadata.clone();
97 let metadata_obj = metadata.as_object_mut().ok_or_else(|| {
98 CoreError::MalformedDocument(format!(
99 "signature metadata at '{}' must be a JSON object",
100 placement_key
101 ))
102 })?;
103 metadata_obj.remove("signature");
104
105 let mut field_entries = Vec::with_capacity(fields.len());
106 for key in fields {
107 if key == placement_key || JACS_IGNORE_FIELDS.contains(&key.as_str()) {
108 return Err(CoreError::MalformedDocument(format!(
109 "signed field '{}' is reserved",
110 key
111 )));
112 }
113 let value = document.get(key).ok_or_else(|| {
114 CoreError::MalformedDocument(format!("signed field '{}' missing from document", key))
115 })?;
116 field_entries.push(json!({ "name": key, "value": value }));
117 }
118
119 let payload = json!({
120 "domain": SIGNATURE_CONTENT_DOMAIN_V2,
121 "placementKey": placement_key,
122 "fields": field_entries,
123 "signatureMetadata": metadata,
124 });
125
126 canonicalize_json_try(&payload)
127}
128
129/// Build the list of fields to sign. With `None` we take every top-level
130/// object key of `document` minus the placement key + the reserved
131/// `JACS_IGNORE_FIELDS`, sorted lexicographically (matches native default
132/// behavior in `jacs::agent::build_signature_content`).
133pub fn default_signed_fields(document: &Value, placement_key: &str) -> Vec<String> {
134 let Some(obj) = document.as_object() else {
135 return Vec::new();
136 };
137 let mut fields: Vec<String> = obj
138 .keys()
139 .filter(|k| k.as_str() != placement_key && !JACS_IGNORE_FIELDS.contains(&k.as_str()))
140 .cloned()
141 .collect();
142 fields.sort();
143 fields.dedup();
144 fields
145}
146
147// =========================================================================
148// Verifier dispatch
149// =========================================================================
150
151/// Verify a raw signature for `message` using `public_key` under
152/// `algorithm`. Dispatches to the right `DetachedSigner` impl.
153pub fn verify_detached(
154 algorithm: SigningAlgorithm,
155 public_key: &[u8],
156 message: &[u8],
157 signature: &[u8],
158) -> Result<(), CoreError> {
159 match algorithm {
160 SigningAlgorithm::Ed25519 => Ed25519DalekSigner::verify(public_key, message, signature),
161 SigningAlgorithm::Pq2025 => Pq2025Signer::verify(public_key, message, signature),
162 }
163}
164
165// =========================================================================
166// Static verify_with_key — used by both `CoreAgent::verify_with_key` and
167// internally by `CoreAgent::verify` after the algorithm match check.
168// =========================================================================
169
170/// Verify a signed JACS document against an explicit public key + algorithm.
171///
172/// Extracts `jacsSignature.signature` (base64) + the signed-fields list +
173/// the metadata, reconstructs the canonical payload bytes, and runs
174/// cryptographic verification. The publicKeyHash baked into the
175/// metadata is **not** checked against `public_key` here — that is an
176/// identity check that lives one layer up (the caller is asserting the
177/// key they pass is the right one for this document).
178pub fn verify_document(
179 signed: &Value,
180 public_key: &[u8],
181 algorithm: SigningAlgorithm,
182 placement_key: &str,
183) -> Result<VerificationOutcome, CoreError> {
184 let sig_obj = signed.get(placement_key).ok_or_else(|| {
185 CoreError::MalformedDocument(format!(
186 "signed document missing '{}' object",
187 placement_key
188 ))
189 })?;
190
191 // signing algorithm check — extracted from the document, must match
192 // the caller's expectation. This is the strong-typing guard against
193 // verifying a pq2025 doc with an Ed25519 key. The doc may carry the
194 // native-side wire form (`"ring-Ed25519"`) instead of jacs-core's
195 // (`"ed25519"`); both resolve to the same algorithm via
196 // `SigningAlgorithm::from_wire_str` for cross-compat (PRD §4.5).
197 let doc_algorithm_str = sig_obj
198 .get("signingAlgorithm")
199 .and_then(|v| v.as_str())
200 .ok_or_else(|| {
201 CoreError::MalformedDocument(format!(
202 "'{}.signingAlgorithm' missing or not a string",
203 placement_key
204 ))
205 })?;
206 let doc_algorithm = SigningAlgorithm::from_wire_str(doc_algorithm_str).ok_or_else(|| {
207 CoreError::UnsupportedAlgorithm(format!(
208 "signed document algorithm '{}' is not recognized",
209 doc_algorithm_str
210 ))
211 })?;
212 if doc_algorithm != algorithm {
213 return Err(CoreError::AlgorithmMismatch {
214 expected: algorithm.to_string(),
215 actual: doc_algorithm_str.to_string(),
216 });
217 }
218
219 let signature_b64 = sig_obj
220 .get("signature")
221 .and_then(|v| v.as_str())
222 .ok_or_else(|| {
223 CoreError::MalformedDocument(format!(
224 "'{}.signature' missing or not a string",
225 placement_key
226 ))
227 })?;
228 let signature_bytes =
229 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_b64).map_err(
230 |e| CoreError::MalformedDocument(format!("invalid base64 signature: {}", e)),
231 )?;
232
233 let fields = sig_obj
234 .get("fields")
235 .and_then(|v| v.as_array())
236 .ok_or_else(|| {
237 CoreError::MalformedDocument(format!(
238 "'{}.fields' missing or not an array",
239 placement_key
240 ))
241 })?
242 .iter()
243 .filter_map(|v| v.as_str().map(str::to_string))
244 .collect::<Vec<_>>();
245
246 // SECURITY (SV-5): a v2 signature authenticates only the fields named in
247 // `<placement>.fields`. `jacsSha256` is not itself signed, so an attacker can
248 // append an unsigned top-level field, recompute the hash, and slip
249 // unauthenticated data past both the signature and hash checks. The document
250 // signature (`jacsSignature`) must attest the *whole* document, so reject any
251 // top-level key under a document signature that is not a signed field, a
252 // reserved JACS field, or the placement itself. Agreement placements sign a
253 // trimmed subset and are exempt (this guard only fires for "jacsSignature").
254 if placement_key == "jacsSignature"
255 && let Some(obj) = signed.as_object()
256 {
257 for key in obj.keys() {
258 if key == placement_key
259 || JACS_IGNORE_FIELDS.contains(&key.as_str())
260 || fields.iter().any(|f| f == key)
261 {
262 continue;
263 }
264 return Err(CoreError::MalformedDocument(format!(
265 "Unsigned top-level field '{}' is present but not covered by '{}.fields'; the v2 signature does not authenticate it.",
266 key, placement_key
267 )));
268 }
269 }
270
271 // Reconstruct canonical bytes using the embedded metadata as-is so the
272 // bytes are identical to what the signer produced (PRD §4.5).
273 let canonical = build_signature_content_v2(signed, &fields, placement_key, sig_obj)?;
274
275 let mut outcome = VerificationOutcome {
276 valid: false,
277 signer_id: sig_obj
278 .get("agentID")
279 .and_then(|v| v.as_str())
280 .unwrap_or("")
281 .to_string(),
282 timestamp: sig_obj
283 .get("date")
284 .and_then(|v| v.as_str())
285 .unwrap_or("")
286 .to_string(),
287 data: signed.clone(),
288 errors: Vec::new(),
289 };
290
291 match verify_detached(
292 algorithm,
293 public_key,
294 canonical.as_bytes(),
295 &signature_bytes,
296 ) {
297 Ok(()) => {
298 outcome.valid = true;
299 }
300 Err(e) => {
301 outcome.errors.push(format!("{}", e));
302 }
303 }
304 Ok(outcome)
305}
306
307// =========================================================================
308// Helpers shared with the agreements module (Task 014)
309// =========================================================================
310
311/// Stable SHA-256 hex digest of raw bytes. Mirrors
312/// `jacs::crypt::hash::hash_bytes` and is the function jacs-core uses for
313/// `publicKeyHash` (sha256 of the raw public-key bytes — no PEM-aware
314/// trimming, no UTF-8 lossy decode). Native callers that need to match
315/// jacs-core's hash convention can compute the same value through
316/// `sha2::Sha256` over the raw bytes.
317pub fn sha256_hex(bytes: &[u8]) -> String {
318 use sha2::{Digest, Sha256};
319 let mut hasher = Sha256::new();
320 hasher.update(bytes);
321 format!("{:x}", hasher.finalize())
322}
323
324/// Build the `signatureMetadata` object embedded in a `jacsSignature`. The
325/// shape exactly mirrors what `jacs::agent::signing_procedure` produces
326/// so verifiers using `build_signature_content_v2` reconstruct the same
327/// canonical bytes from either side.
328///
329/// This helper does not include the `signature` field — `build_signature_content_v2`
330/// strips it anyway, and `CoreAgent::sign_message` fills it in after
331/// running the signer.
332#[allow(clippy::too_many_arguments)]
333pub fn build_signature_metadata(
334 agent_id: &str,
335 agent_version: &str,
336 date: &str,
337 iat: i64,
338 jti: &str,
339 algorithm: SigningAlgorithm,
340 public_key_hash: &str,
341 fields: &[String],
342) -> Value {
343 let mut obj = Map::new();
344 obj.insert("agentID".into(), json!(agent_id));
345 obj.insert("agentVersion".into(), json!(agent_version));
346 obj.insert("date".into(), json!(date));
347 obj.insert("iat".into(), json!(iat));
348 obj.insert("jti".into(), json!(jti));
349 obj.insert("signature".into(), json!(""));
350 obj.insert("signingAlgorithm".into(), json!(algorithm.as_str()));
351 obj.insert("publicKeyHash".into(), json!(public_key_hash));
352 obj.insert("fields".into(), json!(fields));
353 obj.insert(
354 SIGNATURE_CONTENT_VERSION_FIELDNAME.into(),
355 json!(SIGNATURE_CONTENT_VERSION_V2),
356 );
357 Value::Object(obj)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::agent::CoreAgent;
364 use serde_json::json;
365
366 #[test]
367 fn verify_document_rejects_unsigned_top_level_field_under_document_signature() {
368 let mut agent = CoreAgent::ephemeral(SigningAlgorithm::Ed25519).expect("ephemeral");
369 let public_key = agent.public_key().to_vec();
370 let mut signed = agent
371 .sign_message(&json!({ "safe": "x" }))
372 .expect("sign message");
373
374 signed["evil"] = json!("x");
375
376 let err = verify_document(
377 &signed,
378 &public_key,
379 SigningAlgorithm::Ed25519,
380 "jacsSignature",
381 )
382 .expect_err("unsigned top-level field must be rejected");
383 match err {
384 CoreError::MalformedDocument(message) => {
385 assert!(message.contains("Unsigned top-level field 'evil'"));
386 }
387 other => panic!("expected MalformedDocument, got {:?}", other),
388 }
389 }
390}