Skip to main content

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}