Skip to main content

treeship_core/vi/
verify.rs

1//! Verifying Layer 3 credentials against their Layer 2, and Layer 2 against
2//! Layer 1, with the same checks the reference `verify_chain` runs, plus the
3//! Treeship attestation claim when present.
4
5use 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    /// Set when an attestation claim was present and verified.
25    #[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
83/// Verify the L2 issuer signature with the user key L1 binds, and L2's
84/// `sd_hash` against the L1 serialization. `issuer_jwk` verifies L1 itself.
85pub 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
146/// Verify L3a and/or L3b against `l2`. The presentations are the exact L2
147/// strings each recipient was handed; when absent the full L2 serialization
148/// is used, as the reference does. `now` is Unix seconds.
149pub 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}