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_sector: 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(sector) = required_sector {
74        let subjects_sectors = &credential.credential_subject.sectors;
75        if !subjects_sectors.is_empty() && !subjects_sectors.iter().any(|s| s == sector) {
76            return VerificationResult::OutOfScope {
77                reason: format!(
78                    "Credential covers sectors {:?}, but '{}' was requested",
79                    subjects_sectors, sector
80                ),
81            };
82        }
83    }
84
85    let role = credential.credential_subject.role.clone();
86    let audience = role.audience();
87    VerificationResult::Valid {
88        audience,
89        role,
90        holder_did: credential.credential_subject.id.clone(),
91    }
92}
93
94/// Full credential verification **including revocation**, with a fail-closed
95/// policy (crypto Gap 5).
96///
97/// `status_list` is the result of fetching the credential's status list:
98/// `Some(list)` when fetched and verified, `None` when there is nothing to
99/// fetch **or** the fetch failed.
100///
101/// **Fail-closed:** a credential that *declares* a revocation status whose list
102/// is unavailable or unresolvable is treated as `Revoked`.
103pub fn verify_credential_with_revocation(
104    credential: &DppAccessCredential,
105    required_sector: Option<&str>,
106    now: DateTime<Utc>,
107    status_list: Option<&StatusList>,
108) -> VerificationResult {
109    let base = verify_credential_claims(credential, required_sector, now);
110    if !base.is_valid() {
111        return base;
112    }
113    apply_revocation_check(base, credential, status_list)
114}
115
116/// Apply the fail-closed revocation check on top of an already-`Valid` `base`
117/// result: no declared status → pass `base` through unchanged; a declared
118/// status whose list is unresolvable or revoking → `Revoked`. Shared by
119/// [`verify_credential_with_revocation`] and
120/// [`verify_credential_with_revocation_and_trust`], which differ only in the
121/// claims check performed before this point.
122fn apply_revocation_check(
123    base: VerificationResult,
124    credential: &DppAccessCredential,
125    status_list: Option<&StatusList>,
126) -> VerificationResult {
127    if credential.credential_status.is_none() {
128        return base;
129    }
130    match status_list {
131        None => VerificationResult::Revoked,
132        Some(list) => match check_revocation(credential, list) {
133            RevocationOutcome::NotRevoked => base,
134            RevocationOutcome::Revoked | RevocationOutcome::Indeterminate => {
135                VerificationResult::Revoked
136            }
137        },
138    }
139}
140
141/// Verify structural validity, scope, and **issuer trust** of a credential
142/// (no signature check — that is the JWS verifier's responsibility).
143pub fn verify_credential_claims_with_trust(
144    credential: &DppAccessCredential,
145    required_sector: Option<&str>,
146    required_product_category: Option<&str>,
147    now: DateTime<Utc>,
148    trusted_issuers: &dyn TrustedIssuerRegistry,
149) -> VerificationResult {
150    let base = verify_credential_claims(credential, required_sector, now);
151    if !base.is_valid() {
152        return base;
153    }
154
155    if let Some(required_cat) = required_product_category {
156        let cats = &credential.credential_subject.product_categories;
157        if !cats.is_empty() && !cats.iter().any(|c| c == required_cat) {
158            return VerificationResult::OutOfScope {
159                reason: format!(
160                    "Credential covers product categories {:?}, but '{}' was requested",
161                    cats, required_cat
162                ),
163            };
164        }
165    }
166
167    let required_audience = credential.credential_subject.role.audience();
168    if !trusted_issuers.is_trusted_for_audience(&credential.issuer, required_audience) {
169        return VerificationResult::UntrustedIssuer {
170            issuer_did: credential.issuer.clone(),
171        };
172    }
173
174    base
175}
176
177/// Full credential verification including **revocation** and **issuer trust**,
178/// with a fail-closed policy.
179pub fn verify_credential_with_revocation_and_trust(
180    credential: &DppAccessCredential,
181    required_sector: Option<&str>,
182    required_product_category: Option<&str>,
183    now: DateTime<Utc>,
184    status_list: Option<&StatusList>,
185    trusted_issuers: &dyn TrustedIssuerRegistry,
186) -> VerificationResult {
187    let base = verify_credential_claims_with_trust(
188        credential,
189        required_sector,
190        required_product_category,
191        now,
192        trusted_issuers,
193    );
194    if !base.is_valid() {
195        return base;
196    }
197    apply_revocation_check(base, credential, status_list)
198}