Skip to main content

dpp_vc/credential/
verify.rs

1use chrono::{DateTime, Utc};
2
3use super::revocation::{RevocationOutcome, check_revocation};
4use super::trust::TrustedIssuerRegistry;
5use super::types::{Audience, CredentialRole, DppAccessCredential};
6use crate::status_list::StatusList;
7
8// ─── Verification result ────────────────────────────────────────────────────
9
10/// Result of verifying a DPP access credential.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum VerificationResult {
13    /// Credential is valid — the granted audience is returned.
14    Valid {
15        audience: Audience,
16        role: CredentialRole,
17        holder_did: String,
18    },
19    /// Credential has expired.
20    Expired { expired_at: DateTime<Utc> },
21    /// JWS signature is invalid or cannot be verified.
22    InvalidSignature(String),
23    /// Credential has been revoked.
24    Revoked,
25    /// Credential is structurally invalid (missing fields, wrong type).
26    MalformedCredential(String),
27    /// The credential's scope doesn't cover the requested resource.
28    OutOfScope { reason: String },
29    /// The credential's issuer DID is not in the operator's trust registry for
30    /// the audience it claims to grant.
31    UntrustedIssuer { issuer_did: String },
32}
33
34impl VerificationResult {
35    pub fn is_valid(&self) -> bool {
36        matches!(self, Self::Valid { .. })
37    }
38}
39
40// ─── Verify functions ────────────────────────────────────────────────────────
41
42/// Verify structural validity and expiration of a credential (no signature check).
43///
44/// Signature verification requires the issuer's public key and is done
45/// separately via the JWS verifier. This function handles the credential-
46/// level checks: type, expiration, and scope.
47pub fn verify_credential_claims(
48    credential: &DppAccessCredential,
49    required_product_group: Option<&str>,
50    now: DateTime<Utc>,
51) -> VerificationResult {
52    if !credential
53        .credential_type
54        .contains(&"VerifiableCredential".to_owned())
55    {
56        return VerificationResult::MalformedCredential(
57            "Missing 'VerifiableCredential' type".into(),
58        );
59    }
60
61    if now > credential.valid_until {
62        return VerificationResult::Expired {
63            expired_at: credential.valid_until,
64        };
65    }
66
67    if now < credential.valid_from {
68        return VerificationResult::MalformedCredential(
69            "Credential issuance date is in the future".into(),
70        );
71    }
72
73    if let Some(product_group) = required_product_group {
74        let subjects_product_groups = &credential.credential_subject.product_groups;
75        if !subjects_product_groups.is_empty()
76            && !subjects_product_groups.iter().any(|s| s == product_group)
77        {
78            return VerificationResult::OutOfScope {
79                reason: format!(
80                    "Credential covers product_groups {:?}, but '{}' was requested",
81                    subjects_product_groups, product_group
82                ),
83            };
84        }
85    }
86
87    let role = credential.credential_subject.role.clone();
88    let audience = role.audience();
89    VerificationResult::Valid {
90        audience,
91        role,
92        holder_did: credential.credential_subject.id.clone(),
93    }
94}
95
96/// Full credential verification **including revocation**, with a fail-closed
97/// policy (crypto Gap 5).
98///
99/// `status_list` is the result of fetching the credential's status list:
100/// `Some(list)` when fetched and verified, `None` when there is nothing to
101/// fetch **or** the fetch failed.
102///
103/// **Fail-closed:** a credential that *declares* a revocation status whose list
104/// is unavailable or unresolvable is treated as `Revoked`.
105pub fn verify_credential_with_revocation(
106    credential: &DppAccessCredential,
107    required_product_group: Option<&str>,
108    now: DateTime<Utc>,
109    status_list: Option<&StatusList>,
110) -> VerificationResult {
111    let base = verify_credential_claims(credential, required_product_group, now);
112    if !base.is_valid() {
113        return base;
114    }
115    apply_revocation_check(base, credential, status_list)
116}
117
118/// Apply the fail-closed revocation check on top of an already-`Valid` `base`
119/// result: no declared status → pass `base` through unchanged; a declared
120/// status whose list is unresolvable or revoking → `Revoked`. Shared by
121/// [`verify_credential_with_revocation`] and
122/// [`verify_credential_with_revocation_and_trust`], which differ only in the
123/// claims check performed before this point.
124fn apply_revocation_check(
125    base: VerificationResult,
126    credential: &DppAccessCredential,
127    status_list: Option<&StatusList>,
128) -> VerificationResult {
129    if credential.credential_status.is_none() {
130        return base;
131    }
132    match status_list {
133        None => VerificationResult::Revoked,
134        Some(list) => match check_revocation(credential, list) {
135            RevocationOutcome::NotRevoked => base,
136            RevocationOutcome::Revoked | RevocationOutcome::Indeterminate => {
137                VerificationResult::Revoked
138            }
139        },
140    }
141}
142
143/// Verify structural validity, scope, and **issuer trust** of a credential
144/// (no signature check — that is the JWS verifier's responsibility).
145pub fn verify_credential_claims_with_trust(
146    credential: &DppAccessCredential,
147    required_product_group: Option<&str>,
148    required_product_category: Option<&str>,
149    now: DateTime<Utc>,
150    trusted_issuers: &dyn TrustedIssuerRegistry,
151) -> VerificationResult {
152    let base = verify_credential_claims(credential, required_product_group, now);
153    if !base.is_valid() {
154        return base;
155    }
156
157    if let Some(required_cat) = required_product_category {
158        let cats = &credential.credential_subject.product_categories;
159        if !cats.is_empty() && !cats.iter().any(|c| c == required_cat) {
160            return VerificationResult::OutOfScope {
161                reason: format!(
162                    "Credential covers product categories {:?}, but '{}' was requested",
163                    cats, required_cat
164                ),
165            };
166        }
167    }
168
169    let required_audience = credential.credential_subject.role.audience();
170    if !trusted_issuers.is_trusted_for_audience(&credential.issuer, required_audience) {
171        return VerificationResult::UntrustedIssuer {
172            issuer_did: credential.issuer.clone(),
173        };
174    }
175
176    base
177}
178
179/// Full credential verification including **revocation** and **issuer trust**,
180/// with a fail-closed policy.
181pub fn verify_credential_with_revocation_and_trust(
182    credential: &DppAccessCredential,
183    required_product_group: Option<&str>,
184    required_product_category: Option<&str>,
185    now: DateTime<Utc>,
186    status_list: Option<&StatusList>,
187    trusted_issuers: &dyn TrustedIssuerRegistry,
188) -> VerificationResult {
189    let base = verify_credential_claims_with_trust(
190        credential,
191        required_product_group,
192        required_product_category,
193        now,
194        trusted_issuers,
195    );
196    if !base.is_valid() {
197        return base;
198    }
199    apply_revocation_check(base, credential, status_list)
200}