1use serde_json::Value;
6
7use super::attestation::{verify_attestation_claim, AttestationVerified, ATTESTATION_SCHEME};
8use super::constraints::check_constraints;
9use super::jws::{sha256_b64u, Jwk};
10use super::mandate::{L2View, VCT_CHECKOUT_FINAL, VCT_PAYMENT_FINAL};
11use super::sd_jwt::{presentation_hash, SdJwt};
12use super::ViError;
13
14#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
15pub struct Check {
16 pub name: String,
17 pub pass: bool,
18 pub detail: String,
19}
20
21#[derive(Debug, Clone, Default, serde::Serialize)]
22pub struct Report {
23 pub checks: Vec<Check>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub attestation: Option<AttestationSummary>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub checkout_hash: Option<String>,
29}
30
31#[derive(Debug, Clone, serde::Serialize)]
32pub struct AttestationSummary {
33 pub artifact_id: String,
34 pub key_id: String,
35 pub public_key: String,
36 pub session: Option<String>,
37 pub chain_head: String,
38 pub checkpoint: String,
39 pub approval_use: Option<String>,
40 pub mandate_digest: String,
41 pub transaction_id: String,
42 pub chain_length: u64,
43 pub timestamp: String,
44 pub actor: String,
45}
46
47impl Report {
48 pub fn ok(&self) -> bool {
49 self.checks.iter().all(|c| c.pass)
50 }
51 pub fn passed(&self) -> usize {
52 self.checks.iter().filter(|c| c.pass).count()
53 }
54 pub fn failed(&self) -> usize {
55 self.checks.len() - self.passed()
56 }
57 fn push(&mut self, name: &str, pass: bool, detail: impl Into<String>) -> bool {
58 self.checks.push(Check {
59 name: name.into(),
60 pass,
61 detail: detail.into(),
62 });
63 pass
64 }
65}
66
67fn header_ok(h: &Value, expected_typ: &str) -> Result<(), String> {
68 if h.get("alg").and_then(Value::as_str) != Some("ES256") {
69 return Err(format!(
70 "alg must be ES256, got {}",
71 h.get("alg").cloned().unwrap_or(Value::Null)
72 ));
73 }
74 if h.get("typ").and_then(Value::as_str) != Some(expected_typ) {
75 return Err(format!(
76 "typ must be '{expected_typ}', got {}",
77 h.get("typ").cloned().unwrap_or(Value::Null)
78 ));
79 }
80 Ok(())
81}
82
83pub fn verify_l2_against_l1(l1: &SdJwt, l2: &SdJwt, issuer_jwk: Option<&Jwk>, now: u64) -> Report {
86 let mut r = Report::default();
87 match issuer_jwk {
88 Some(jwk) => {
89 r.push(
90 "l1_signature",
91 l1.verify_signature(jwk).is_ok(),
92 "L1 issuer signature (ES256)",
93 );
94 }
95 None => {
96 r.push("l1_signature", true, "not checked: no issuer key given");
97 }
98 }
99 r.push(
100 "l1_header",
101 header_ok(l1.header(), "sd+jwt").is_ok(),
102 "L1 header alg/typ",
103 );
104 let l1_exp = l1.payload().get("exp").and_then(Value::as_u64);
105 r.push(
106 "l1_not_expired",
107 l1_exp.map(|e| now <= e + 300).unwrap_or(true),
108 format!(
109 "exp {}",
110 l1_exp
111 .map(|e| e.to_string())
112 .unwrap_or_else(|| "absent".into())
113 ),
114 );
115 let user_jwk = l1
116 .payload()
117 .get("cnf")
118 .and_then(|c| c.get("jwk"))
119 .and_then(|j| Jwk::from_value(j).ok());
120 match user_jwk {
121 Some(jwk) => {
122 r.push(
123 "l2_signature",
124 l2.verify_signature(&jwk).is_ok(),
125 "L2 signed by the user key L1 binds (cnf.jwk)",
126 );
127 }
128 None => {
129 r.push("l2_signature", false, "L1 has no cnf.jwk to verify L2 with");
130 }
131 }
132 let expected = sha256_b64u(l1.serialize().as_bytes());
133 let actual = l2
134 .payload()
135 .get("sd_hash")
136 .and_then(Value::as_str)
137 .unwrap_or("");
138 r.push(
139 "l2_sd_hash",
140 actual == expected,
141 "L2 sd_hash binds the presented L1",
142 );
143 r
144}
145
146pub fn verify_l3(
150 l2: &L2View,
151 l3a: Option<&SdJwt>,
152 l3b: Option<&SdJwt>,
153 l2_payment_presentation: Option<&str>,
154 l2_checkout_presentation: Option<&str>,
155 now: u64,
156) -> Report {
157 let mut r = Report::default();
158 r.push(
159 "l2_mode",
160 l2.autonomous,
161 if l2.autonomous {
162 "autonomous (open) mandates"
163 } else {
164 "not autonomous: L3 has no meaning"
165 },
166 );
167 r.push(
168 "l2_header",
169 header_ok(l2.sd_jwt.header(), "kb-sd-jwt+kb").is_ok(),
170 "L2 header alg/typ",
171 );
172 if !l2.autonomous {
173 return r;
174 }
175 let full = l2.sd_jwt.serialize();
176 let agent_jwk = &l2.agent_jwk;
177
178 let mut l3a_claims: Option<Value> = None;
179 let mut l3b_claims: Option<Value> = None;
180 let mut attestation: Option<AttestationVerified> = None;
181
182 for (l3, label, pres, pair_disc, required_vct) in [
183 (
184 l3a,
185 "l3a",
186 l2_payment_presentation,
187 l2.payment.as_ref().map(|m| m.disclosure.clone()),
188 VCT_PAYMENT_FINAL,
189 ),
190 (
191 l3b,
192 "l3b",
193 l2_checkout_presentation,
194 l2.checkout.as_ref().map(|m| m.disclosure.clone()),
195 VCT_CHECKOUT_FINAL,
196 ),
197 ] {
198 let Some(l3) = l3 else { continue };
199 let p = l3.payload();
200 r.push(
201 &format!("{label}_no_cnf"),
202 p.get("cnf").is_none(),
203 "terminal delegation: no cnf",
204 );
205 r.push(
206 &format!("{label}_signature"),
207 l3.verify_signature(agent_jwk).is_ok(),
208 "signed by the agent key L2 delegates to (cnf.jwk)",
209 );
210 r.push(
211 &format!("{label}_header"),
212 header_ok(l3.header(), "kb-sd-jwt").is_ok(),
213 "header alg/typ",
214 );
215 let kid = l3.header().get("kid").and_then(Value::as_str).unwrap_or("");
216 let kid_ok = !kid.is_empty() && l2.agent_kid.as_deref().map(|k| k == kid).unwrap_or(true);
217 r.push(
218 &format!("{label}_kid"),
219 kid_ok,
220 format!("header kid '{kid}' matches L2 cnf.jwk.kid"),
221 );
222 let pres_str = pres.map(str::to_string).unwrap_or_else(|| full.clone());
223 let sd_ok =
224 p.get("sd_hash").and_then(Value::as_str) == Some(presentation_hash(&pres_str).as_str());
225 r.push(
226 &format!("{label}_sd_hash"),
227 sd_ok,
228 "sd_hash binds the L2 presentation this recipient saw",
229 );
230 if let Some(pd) = &pair_disc {
231 let included = pres_str.split('~').any(|s| s == pd);
232 r.push(
233 &format!("{label}_pair_binding"),
234 included,
235 "presentation includes this mandate's disclosure",
236 );
237 }
238 let sd_alg_ok = p.get("_sd_alg").map(|v| v == "sha-256").unwrap_or(true);
239 r.push(&format!("{label}_sd_alg"), sd_alg_ok, "_sd_alg sha-256");
240 let iat = p.get("iat").and_then(Value::as_u64);
241 let exp = p.get("exp").and_then(Value::as_u64);
242 r.push(
243 &format!("{label}_iat"),
244 iat.map(|i| i <= now + 300).unwrap_or(true),
245 "iat not in the future",
246 );
247 r.push(
248 &format!("{label}_exp"),
249 exp.map(|e| now <= e + 300).unwrap_or(true),
250 format!(
251 "not expired (exp {})",
252 exp.map(|e| e.to_string())
253 .unwrap_or_else(|| "absent".into())
254 ),
255 );
256 if let (Some(i), Some(e)) = (iat, exp) {
257 r.push(
258 &format!("{label}_lifetime"),
259 e >= i && e - i <= 3600,
260 "exp at most 1 hour after iat",
261 );
262 }
263 let claims = l3.resolve();
264 let delegates = claims
265 .get("delegate_payload")
266 .and_then(Value::as_array)
267 .cloned()
268 .unwrap_or_default();
269 let mandate = delegates
270 .iter()
271 .find(|d| d.get("vct").and_then(Value::as_str) == Some(required_vct))
272 .cloned();
273 r.push(
274 &format!("{label}_mandate_present"),
275 mandate.is_some(),
276 format!("carries a {required_vct} disclosure"),
277 );
278 if let Some(m) = &mandate {
279 if required_vct == VCT_PAYMENT_FINAL {
280 let tid = m
281 .get("transaction_id")
282 .and_then(Value::as_str)
283 .filter(|s| !s.trim().is_empty());
284 r.push(
285 "l3a_transaction_id",
286 tid.is_some(),
287 "transaction_id present",
288 );
289 let payee_ok = m
290 .get("payee")
291 .and_then(Value::as_object)
292 .map(|p| {
293 p.get("name")
294 .and_then(Value::as_str)
295 .filter(|s| !s.trim().is_empty())
296 .is_some()
297 && p.get("website")
298 .and_then(Value::as_str)
299 .filter(|s| !s.trim().is_empty())
300 .is_some()
301 })
302 .unwrap_or(false);
303 r.push("l3a_payee", payee_ok, "payee has name and website");
304 let amount_ok = m
305 .get("payment_amount")
306 .and_then(Value::as_object)
307 .map(|a| {
308 a.get("currency")
309 .and_then(Value::as_str)
310 .filter(|s| !s.trim().is_empty())
311 .is_some()
312 && a.get("amount")
313 .map(|x| x.is_i64() || x.is_u64())
314 .unwrap_or(false)
315 })
316 .unwrap_or(false);
317 r.push(
318 "l3a_payment_amount",
319 amount_ok,
320 "payment_amount has currency and integer amount",
321 );
322 let pi = m.get("payment_instrument").cloned().unwrap_or(Value::Null);
323 let pi_ok = pi
324 .get("id")
325 .and_then(Value::as_str)
326 .filter(|s| !s.is_empty())
327 .is_some()
328 && pi
329 .get("type")
330 .and_then(Value::as_str)
331 .filter(|s| !s.is_empty())
332 .is_some();
333 r.push(
334 "l3a_payment_instrument",
335 pi_ok,
336 "payment_instrument has id and type",
337 );
338 if let Some(l2pi) = l2.payment_instrument() {
339 let same = pi.get("id") == l2pi.get("id") && pi.get("type") == l2pi.get("type");
340 r.push(
341 "l3a_instrument_matches_l2",
342 same,
343 "payment_instrument is the one L2 authorized",
344 );
345 }
346 let fulfillment = serde_json::json!({
347 "payment_amount": m.get("payment_amount").cloned().unwrap_or(Value::Null),
348 "payee": m.get("payee").cloned().unwrap_or(Value::Null),
349 "allowed_merchants": l2.allowed_payees(),
350 });
351 let cr = check_constraints(&l2.payment_constraints(), &fulfillment, true);
352 r.push(
353 "l3a_constraints",
354 cr.satisfied,
355 if cr.satisfied {
356 format!("checked: {}", cr.checked.join(", "))
357 } else {
358 cr.violations.join("; ")
359 },
360 );
361 } else {
362 let cj = m
363 .get("checkout_jwt")
364 .and_then(Value::as_str)
365 .filter(|s| !s.is_empty());
366 let ch = m
367 .get("checkout_hash")
368 .and_then(Value::as_str)
369 .filter(|s| !s.is_empty());
370 r.push(
371 "l3b_checkout_fields",
372 cj.is_some() && ch.is_some(),
373 "checkout_jwt and checkout_hash present",
374 );
375 if let (Some(cj), Some(ch)) = (cj, ch) {
376 r.push(
377 "l3b_checkout_hash",
378 sha256_b64u(cj.as_bytes()) == ch,
379 "checkout_hash = B64U(SHA-256(checkout_jwt))",
380 );
381 r.checkout_hash = Some(ch.to_string());
382 }
383 if let Some(items) = m.get("line_items").and_then(Value::as_array) {
384 let merchant = l3a_payee_from(&l3a_claims).unwrap_or(Value::Null);
385 let fulfillment = serde_json::json!({
386 "merchant": merchant,
387 "line_items": items,
388 "allowed_merchants": l2.allowed_merchants(),
389 });
390 let constraints: Vec<Value> = l2
391 .checkout_constraints()
392 .into_iter()
393 .filter(|c| {
394 merchant.is_object()
395 || c.get("type").and_then(Value::as_str)
396 != Some("mandate.checkout.allowed_merchants")
397 })
398 .collect();
399 let cr = check_constraints(&constraints, &fulfillment, true);
400 r.push(
401 "l3b_constraints",
402 cr.satisfied,
403 if cr.satisfied {
404 format!("checked: {}", cr.checked.join(", "))
405 } else {
406 cr.violations.join("; ")
407 },
408 );
409 }
410 }
411 }
412 if let Some(att) = p.get("agent_attestation") {
413 let ty = att.get("type").and_then(Value::as_str).unwrap_or("");
414 if ty == ATTESTATION_SCHEME {
415 match verify_attestation_claim(att) {
416 Ok(v) => {
417 r.push(
418 &format!("{label}_attestation"),
419 true,
420 format!(
421 "Treeship attestation {} signed by {}",
422 v.artifact_id, v.key_id
423 ),
424 );
425 if attestation
426 .as_ref()
427 .map(|a| a.artifact_id != v.artifact_id)
428 .unwrap_or(false)
429 {
430 r.push(
431 "attestation_same_on_both",
432 false,
433 "L3a and L3b carry different attestations",
434 );
435 }
436 attestation = Some(v);
437 }
438 Err(e) => {
439 r.push(&format!("{label}_attestation"), false, e.to_string());
440 }
441 }
442 } else {
443 r.push(
444 &format!("{label}_attestation"),
445 true,
446 format!("unknown attestation scheme '{ty}' ignored, as the spec requires"),
447 );
448 }
449 }
450 if required_vct == VCT_PAYMENT_FINAL {
451 l3a_claims = Some(claims);
452 } else {
453 l3b_claims = Some(claims);
454 }
455 }
456
457 if let (Some(a), Some(b)) = (&l3a_claims, &l3b_claims) {
458 let tid = find_delegate(a, VCT_PAYMENT_FINAL).and_then(|m| {
459 m.get("transaction_id")
460 .and_then(Value::as_str)
461 .map(str::to_string)
462 });
463 let ch = find_delegate(b, VCT_CHECKOUT_FINAL).and_then(|m| {
464 m.get("checkout_hash")
465 .and_then(Value::as_str)
466 .map(str::to_string)
467 });
468 r.push(
469 "l3_cross_reference",
470 tid.is_some() && tid == ch,
471 "L3a transaction_id == L3b checkout_hash",
472 );
473 let na = a.get("nonce");
474 let nb = b.get("nonce");
475 r.push(
476 "l3_shared_nonce",
477 na.is_some() && na == nb,
478 "both halves carry the same nonce",
479 );
480 }
481
482 if let Some(v) = attestation {
483 let s = &v.statement;
484 let md_ok = s.mandate_digest == sha256_b64u(l2.base_jwt().as_bytes());
485 r.push(
486 "attestation_mandate_digest",
487 md_ok,
488 "attestation names this L2 (SHA-256 of its base JWT)",
489 );
490 if let Some(ch) = &r.checkout_hash {
491 r.push(
492 "attestation_transaction_id",
493 &s.transaction_id == ch,
494 "attestation names this transaction",
495 );
496 }
497 r.attestation = Some(AttestationSummary {
498 artifact_id: v.artifact_id,
499 key_id: v.key_id,
500 public_key: v.public_key,
501 session: s.session.clone(),
502 chain_head: s.chain_head.clone(),
503 checkpoint: s.checkpoint.clone(),
504 approval_use: s.approval_use.clone(),
505 mandate_digest: s.mandate_digest.clone(),
506 transaction_id: s.transaction_id.clone(),
507 chain_length: s.chain_length,
508 timestamp: s.timestamp.clone(),
509 actor: s.actor.clone(),
510 });
511 }
512 r
513}
514
515fn find_delegate(claims: &Value, vct: &str) -> Option<Value> {
516 claims
517 .get("delegate_payload")
518 .and_then(Value::as_array)
519 .and_then(|a| {
520 a.iter()
521 .find(|d| d.get("vct").and_then(Value::as_str) == Some(vct))
522 .cloned()
523 })
524}
525
526fn l3a_payee_from(l3a_claims: &Option<Value>) -> Option<Value> {
527 l3a_claims
528 .as_ref()
529 .and_then(|c| find_delegate(c, VCT_PAYMENT_FINAL))
530 .and_then(|m| m.get("payee").cloned())
531}
532
533impl From<ViError> for Check {
534 fn from(e: ViError) -> Self {
535 Check {
536 name: "error".into(),
537 pass: false,
538 detail: e.to_string(),
539 }
540 }
541}