baseid_wallet_core/
matcher.rs

1//! Credential matching against presentation requests.
2//!
3//! Provides matching logic for finding stored credentials that satisfy a
4//! presentation request, supporting both DIF Presentation Exchange and DCQL
5//! (Digital Credentials Query Language) query formats.
6
7use crate::error::WalletError;
8use crate::presenter::CredentialMatch;
9use crate::{AnyCredential, CredentialRecord};
10use baseid_core::types::CredentialFormat;
11
12/// A presentation request — either DIF Presentation Exchange or DCQL.
13#[derive(Debug, Clone)]
14pub enum PresentationRequest {
15    /// DIF Presentation Exchange definition.
16    PresentationExchange(baseid_oid4vp::PresentationDefinition),
17    /// Digital Credentials Query Language query.
18    Dcql(baseid_oid4vp::DcqlQuery),
19}
20
21impl PresentationRequest {
22    /// Try to parse a presentation request from a JSON value.
23    ///
24    /// If the value has an `"input_descriptors"` field, it is treated as a
25    /// Presentation Exchange definition. If it has a `"credentials"` field,
26    /// it is treated as a DCQL query. Otherwise, returns an error.
27    pub fn from_value(value: &serde_json::Value) -> baseid_core::Result<Self> {
28        if value.get("input_descriptors").is_some() {
29            let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(value.clone())
30                .map_err(|e| {
31                WalletError::MatchingFailed(format!("failed to parse PresentationDefinition: {e}"))
32            })?;
33            Ok(PresentationRequest::PresentationExchange(pd))
34        } else if value.get("credentials").is_some() {
35            let query: baseid_oid4vp::DcqlQuery =
36                serde_json::from_value(value.clone()).map_err(|e| {
37                    WalletError::MatchingFailed(format!("failed to parse DcqlQuery: {e}"))
38                })?;
39            Ok(PresentationRequest::Dcql(query))
40        } else {
41            Err(WalletError::MatchingFailed("unknown request format".to_string()).into())
42        }
43    }
44}
45
46/// Check whether a descriptor format map is compatible with the given credential format.
47///
48/// The format field in a PE InputDescriptor is a JSON object where keys are format
49/// identifiers (e.g. `"jwt_vc_json"`, `"mso_mdoc"`, `"dc+sd-jwt"`).
50fn format_matches_pe(format_value: &serde_json::Value, cred_format: CredentialFormat) -> bool {
51    let obj = match format_value.as_object() {
52        Some(o) => o,
53        None => return false,
54    };
55    for key in obj.keys() {
56        let compatible = match key.as_str() {
57            "jwt_vc" | "jwt_vc_json" | "ldp_vc" => cred_format == CredentialFormat::W3cVc,
58            "mso_mdoc" => cred_format == CredentialFormat::Mdl,
59            "dc+sd-jwt" | "vc+sd-jwt" => cred_format == CredentialFormat::SdJwtVc,
60            _ => false,
61        };
62        if compatible {
63            return true;
64        }
65    }
66    false
67}
68
69/// Extract the last segment of a JSONPath-like path string.
70///
71/// For example, `"$.credentialSubject.degree"` returns `"degree"`,
72/// and `"$.vc.type"` returns `"type"`.
73fn path_last_segment(path: &str) -> &str {
74    path.rsplit('.').next().unwrap_or(path)
75}
76
77/// Check whether a field path plausibly matches a W3C VC credential.
78///
79/// This performs simplified matching — it does not implement full JSONPath
80/// evaluation. Instead, it checks whether the path references known VC fields
81/// (type, credentialSubject keys) and whether those fields exist.
82fn field_matches_w3c_vc(vc: &baseid_vc::VerifiableCredential, path: &str) -> bool {
83    let lower = path.to_lowercase();
84
85    // Paths referencing credential type
86    if lower.contains("type") {
87        return !vc.r#type.is_empty();
88    }
89
90    // Paths referencing credentialSubject fields
91    if lower.contains("credentialsubject") {
92        let segment = path_last_segment(path);
93        if segment.eq_ignore_ascii_case("credentialSubject") {
94            // Path is just "$.credentialSubject" — match if subject exists
95            return true;
96        }
97        // Check if the credential_subject has the referenced key
98        if let Some(obj) = vc.credential_subject.as_object() {
99            return obj.contains_key(segment);
100        }
101        return false;
102    }
103
104    // For any other path, optimistically match — the credential could plausibly
105    // contain the field (we lack full JSONPath support).
106    true
107}
108
109/// Match stored credentials against a Presentation Exchange definition.
110///
111/// For each input descriptor, iterates over stored credentials and returns
112/// a [`CredentialMatch`] for each credential that satisfies the descriptor's
113/// format and field constraints.
114pub fn match_credentials_pe(
115    credentials: &[CredentialRecord],
116    definition: &baseid_oid4vp::PresentationDefinition,
117) -> Vec<CredentialMatch> {
118    let mut matches = Vec::new();
119
120    for descriptor in &definition.input_descriptors {
121        for record in credentials {
122            // Check format compatibility
123            if let Some(ref fmt) = descriptor.format {
124                if !format_matches_pe(fmt, record.credential.credential_format()) {
125                    continue;
126                }
127            }
128
129            // Check field constraints
130            let mut matching_fields = Vec::new();
131            let mut any_field_matched = descriptor.constraints.fields.is_empty();
132
133            for field in &descriptor.constraints.fields {
134                let field_matched = field.path.iter().any(|path| match &record.credential {
135                    AnyCredential::W3cVc(vc) => field_matches_w3c_vc(vc, path),
136                    AnyCredential::Mdoc(_) => {
137                        // For mDocs, optimistically match any path
138                        true
139                    }
140                    AnyCredential::SdJwtVc(_) => {
141                        // For SD-JWT, optimistically match any path
142                        true
143                    }
144                    AnyCredential::Bbs(_) => {
145                        // For BBS+, optimistically match any path
146                        true
147                    }
148                    AnyCredential::AnonCreds(cred) => {
149                        // For AnonCreds, check if the path references a known attribute
150                        let segment = path_last_segment(path);
151                        cred.values.contains_key(segment)
152                    }
153                });
154
155                if field_matched {
156                    any_field_matched = true;
157                    // Add all path variants for this field to matching_fields
158                    for path in &field.path {
159                        matching_fields.push(path.clone());
160                    }
161                }
162            }
163
164            if any_field_matched {
165                matches.push(CredentialMatch {
166                    credential_id: record.id.clone(),
167                    format: record.credential.credential_format(),
168                    matching_fields,
169                });
170            }
171        }
172    }
173
174    matches
175}
176
177/// Check whether a DCQL format string is compatible with the given credential format.
178fn format_matches_dcql(dcql_format: &str, cred_format: CredentialFormat) -> bool {
179    match dcql_format {
180        "dc+sd-jwt" | "vc+sd-jwt" => cred_format == CredentialFormat::SdJwtVc,
181        "mso_mdoc" => cred_format == CredentialFormat::Mdl,
182        "jwt_vc_json" | "ldp_vc" => cred_format == CredentialFormat::W3cVc,
183        _ => false,
184    }
185}
186
187/// Check whether a W3C VC's type array contains any of the required type arrays.
188///
189/// `type_values` is a list of acceptable type arrays. The credential matches if
190/// its type array contains all elements of at least one of the acceptable arrays.
191fn vc_type_matches(vc: &baseid_vc::VerifiableCredential, type_values: &[Vec<String>]) -> bool {
192    type_values.iter().any(|required_types| {
193        required_types
194            .iter()
195            .all(|required| vc.r#type.iter().any(|t| t == required))
196    })
197}
198
199/// Match stored credentials against a DCQL query.
200///
201/// For each credential request in the query, iterates over stored credentials
202/// and returns a [`CredentialMatch`] for each credential that satisfies the
203/// format, metadata, and claim constraints.
204pub fn match_credentials_dcql(
205    credentials: &[CredentialRecord],
206    query: &baseid_oid4vp::DcqlQuery,
207) -> Vec<CredentialMatch> {
208    let mut matches = Vec::new();
209
210    for dcql_cred in &query.credentials {
211        for record in credentials {
212            // Step 1: Filter by format
213            if !format_matches_dcql(&dcql_cred.format, record.credential.credential_format()) {
214                continue;
215            }
216
217            // Step 2: Apply metadata filters if present
218            if let Some(ref meta) = dcql_cred.meta {
219                match &record.credential {
220                    AnyCredential::W3cVc(vc) => {
221                        // Check type_values for W3C VC
222                        if let Some(ref type_values) = meta.type_values {
223                            if !vc_type_matches(vc, type_values) {
224                                continue;
225                            }
226                        }
227                    }
228                    AnyCredential::Mdoc(_mdoc) => {
229                        // For mDocs, doctype_value would check doc_type.
230                        // Simplified: format already matched, so we accept it.
231                    }
232                    AnyCredential::SdJwtVc(_) => {
233                        // For SD-JWT, vct_values would check the vct claim.
234                        // Simplified: format already matched, so we accept it.
235                    }
236                    AnyCredential::Bbs(_) => {
237                        // For BBS+, metadata matching not yet implemented.
238                        // Simplified: format already matched, so we accept it.
239                    }
240                    AnyCredential::AnonCreds(_) => {
241                        // For AnonCreds, metadata matching not yet implemented.
242                        // Simplified: format already matched, so we accept it.
243                    }
244                }
245            }
246
247            // Step 3: Collect claim paths as matching_fields
248            let matching_fields: Vec<String> = dcql_cred
249                .claims
250                .iter()
251                .map(|claim| claim.path.join("."))
252                .collect();
253
254            matches.push(CredentialMatch {
255                credential_id: record.id.clone(),
256                format: record.credential.credential_format(),
257                matching_fields,
258            });
259        }
260    }
261
262    matches
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use baseid_core::types::CredentialId;
269    use baseid_vc::credential::Issuer;
270    use baseid_vc::VerifiableCredential;
271
272    /// Helper to create a W3C VC with given types and credential_subject.
273    fn make_vc(types: &[&str], subject: serde_json::Value) -> CredentialRecord {
274        let vc = VerifiableCredential {
275            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
276            id: None,
277            r#type: types.iter().map(|s| s.to_string()).collect(),
278            issuer: Issuer::Uri("did:key:z6Mk-test-issuer".to_string()),
279            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
280            valid_until: None,
281            credential_subject: subject,
282            credential_status: None,
283            proof: None,
284        };
285        CredentialRecord {
286            id: CredentialId(format!("vc-{}", types.last().unwrap_or(&"unknown"))),
287            credential: AnyCredential::W3cVc(vc),
288            raw: None,
289        }
290    }
291
292    /// Helper to create an SD-JWT credential record.
293    fn make_sd_jwt(id: &str) -> CredentialRecord {
294        let sd_jwt = baseid_sd_jwt::SdJwt {
295            jwt: "eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6a2V5OnRlc3QifQ.sig"
296                .to_string(),
297            disclosures: vec![],
298            key_binding_jwt: None,
299        };
300        CredentialRecord {
301            id: CredentialId(id.to_string()),
302            credential: AnyCredential::SdJwtVc(sd_jwt),
303            raw: None,
304        }
305    }
306
307    /// Helper to create an mDL credential record.
308    fn make_mdoc(id: &str) -> CredentialRecord {
309        let mdoc = baseid_mdl::MobileDocument {
310            doc_type: "org.iso.18013.5.1.mDL".to_string(),
311            namespaces: std::collections::BTreeMap::new(),
312        };
313        CredentialRecord {
314            id: CredentialId(id.to_string()),
315            credential: AnyCredential::Mdoc(mdoc),
316            raw: None,
317        }
318    }
319
320    // ---------------------------------------------------------------
321    // PresentationRequest::from_value tests
322    // ---------------------------------------------------------------
323
324    #[test]
325    fn parse_pe_request() {
326        let value = serde_json::json!({
327            "id": "pd-1",
328            "input_descriptors": [{
329                "id": "desc-1",
330                "constraints": { "fields": [] }
331            }]
332        });
333        let req = PresentationRequest::from_value(&value).unwrap();
334        assert!(matches!(req, PresentationRequest::PresentationExchange(_)));
335    }
336
337    #[test]
338    fn parse_dcql_request() {
339        let value = serde_json::json!({
340            "credentials": [{
341                "id": "cred-1",
342                "format": "dc+sd-jwt"
343            }]
344        });
345        let req = PresentationRequest::from_value(&value).unwrap();
346        assert!(matches!(req, PresentationRequest::Dcql(_)));
347    }
348
349    #[test]
350    fn parse_unknown_request() {
351        let value = serde_json::json!({ "foo": "bar" });
352        let result = PresentationRequest::from_value(&value);
353        assert!(result.is_err());
354    }
355
356    // ---------------------------------------------------------------
357    // match_credentials_pe tests
358    // ---------------------------------------------------------------
359
360    #[test]
361    fn pe_match_by_format() {
362        let creds = vec![make_vc(
363            &["VerifiableCredential", "UniversityDegreeCredential"],
364            serde_json::json!({"id": "did:key:holder"}),
365        )];
366        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
367            "id": "pd-1",
368            "input_descriptors": [{
369                "id": "desc-1",
370                "constraints": { "fields": [] },
371                "format": { "jwt_vc_json": {} }
372            }]
373        }))
374        .unwrap();
375
376        let results = match_credentials_pe(&creds, &pd);
377        assert_eq!(results.len(), 1);
378        assert_eq!(results[0].format, CredentialFormat::W3cVc);
379    }
380
381    #[test]
382    fn pe_match_by_field_path() {
383        let creds = vec![make_vc(
384            &["VerifiableCredential", "UniversityDegreeCredential"],
385            serde_json::json!({"degree": {"type": "BachelorDegree"}}),
386        )];
387        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
388            "id": "pd-1",
389            "input_descriptors": [{
390                "id": "desc-1",
391                "constraints": {
392                    "fields": [{
393                        "path": ["$.credentialSubject.degree"]
394                    }]
395                }
396            }]
397        }))
398        .unwrap();
399
400        let results = match_credentials_pe(&creds, &pd);
401        assert_eq!(results.len(), 1);
402        assert!(results[0]
403            .matching_fields
404            .contains(&"$.credentialSubject.degree".to_string()));
405    }
406
407    #[test]
408    fn pe_no_match_wrong_format() {
409        let creds = vec![make_vc(
410            &["VerifiableCredential"],
411            serde_json::json!({"id": "did:key:holder"}),
412        )];
413        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
414            "id": "pd-1",
415            "input_descriptors": [{
416                "id": "desc-1",
417                "constraints": { "fields": [] },
418                "format": { "mso_mdoc": {} }
419            }]
420        }))
421        .unwrap();
422
423        let results = match_credentials_pe(&creds, &pd);
424        assert!(results.is_empty());
425    }
426
427    #[test]
428    fn pe_multiple_descriptors() {
429        let creds = vec![
430            make_vc(
431                &["VerifiableCredential", "UniversityDegreeCredential"],
432                serde_json::json!({"degree": "BSc"}),
433            ),
434            make_sd_jwt("sd-jwt-1"),
435        ];
436        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
437            "id": "pd-1",
438            "input_descriptors": [
439                {
440                    "id": "desc-vc",
441                    "constraints": { "fields": [] },
442                    "format": { "jwt_vc_json": {} }
443                },
444                {
445                    "id": "desc-sd",
446                    "constraints": { "fields": [] },
447                    "format": { "dc+sd-jwt": {} }
448                }
449            ]
450        }))
451        .unwrap();
452
453        let results = match_credentials_pe(&creds, &pd);
454        assert_eq!(results.len(), 2);
455
456        let formats: Vec<CredentialFormat> = results.iter().map(|m| m.format).collect();
457        assert!(formats.contains(&CredentialFormat::W3cVc));
458        assert!(formats.contains(&CredentialFormat::SdJwtVc));
459    }
460
461    // ---------------------------------------------------------------
462    // match_credentials_dcql tests
463    // ---------------------------------------------------------------
464
465    #[test]
466    fn dcql_match_by_format() {
467        let creds = vec![make_vc(
468            &["VerifiableCredential", "IDCredential"],
469            serde_json::json!({"id": "did:key:holder"}),
470        )];
471        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
472            "credentials": [{
473                "id": "req-1",
474                "format": "jwt_vc_json"
475            }]
476        }))
477        .unwrap();
478
479        let results = match_credentials_dcql(&creds, &query);
480        assert_eq!(results.len(), 1);
481        assert_eq!(results[0].format, CredentialFormat::W3cVc);
482    }
483
484    #[test]
485    fn dcql_match_sd_jwt_format() {
486        let creds = vec![make_sd_jwt("sd-1")];
487        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
488            "credentials": [{
489                "id": "req-1",
490                "format": "dc+sd-jwt",
491                "claims": [
492                    {"path": ["family_name"]},
493                    {"path": ["given_name"]}
494                ]
495            }]
496        }))
497        .unwrap();
498
499        let results = match_credentials_dcql(&creds, &query);
500        assert_eq!(results.len(), 1);
501        assert_eq!(results[0].format, CredentialFormat::SdJwtVc);
502        assert_eq!(
503            results[0].matching_fields,
504            vec!["family_name", "given_name"]
505        );
506    }
507
508    #[test]
509    fn dcql_no_match() {
510        let creds = vec![make_vc(&["VerifiableCredential"], serde_json::json!({}))];
511        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
512            "credentials": [{
513                "id": "req-1",
514                "format": "mso_mdoc"
515            }]
516        }))
517        .unwrap();
518
519        let results = match_credentials_dcql(&creds, &query);
520        assert!(results.is_empty());
521    }
522
523    #[test]
524    fn dcql_match_vc_type() {
525        let creds = vec![make_vc(
526            &["VerifiableCredential", "IDCredential"],
527            serde_json::json!({"name": "Alice"}),
528        )];
529        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
530            "credentials": [{
531                "id": "req-1",
532                "format": "jwt_vc_json",
533                "meta": {
534                    "type_values": [["IDCredential"]]
535                },
536                "claims": [
537                    {"path": ["name"]}
538                ]
539            }]
540        }))
541        .unwrap();
542
543        let results = match_credentials_dcql(&creds, &query);
544        assert_eq!(results.len(), 1);
545        assert_eq!(results[0].matching_fields, vec!["name"]);
546    }
547
548    #[test]
549    fn dcql_no_match_wrong_vc_type() {
550        let creds = vec![make_vc(
551            &["VerifiableCredential", "UniversityDegreeCredential"],
552            serde_json::json!({}),
553        )];
554        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
555            "credentials": [{
556                "id": "req-1",
557                "format": "jwt_vc_json",
558                "meta": {
559                    "type_values": [["IDCredential"]]
560                }
561            }]
562        }))
563        .unwrap();
564
565        let results = match_credentials_dcql(&creds, &query);
566        assert!(results.is_empty());
567    }
568
569    #[test]
570    fn dcql_match_mdoc_format() {
571        let creds = vec![make_mdoc("mdoc-1")];
572        let query: baseid_oid4vp::DcqlQuery = serde_json::from_value(serde_json::json!({
573            "credentials": [{
574                "id": "req-1",
575                "format": "mso_mdoc",
576                "meta": {
577                    "doctype_value": "org.iso.18013.5.1.mDL"
578                },
579                "claims": [
580                    {"path": ["org.iso.18013.5.1", "given_name"]}
581                ]
582            }]
583        }))
584        .unwrap();
585
586        let results = match_credentials_dcql(&creds, &query);
587        assert_eq!(results.len(), 1);
588        assert_eq!(results[0].format, CredentialFormat::Mdl);
589        assert_eq!(
590            results[0].matching_fields,
591            vec!["org.iso.18013.5.1.given_name"]
592        );
593    }
594
595    #[test]
596    fn pe_field_path_credential_subject_missing_key() {
597        let creds = vec![make_vc(
598            &["VerifiableCredential"],
599            serde_json::json!({"name": "Alice"}),
600        )];
601        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
602            "id": "pd-1",
603            "input_descriptors": [{
604                "id": "desc-1",
605                "constraints": {
606                    "fields": [{
607                        "path": ["$.credentialSubject.degree"]
608                    }]
609                }
610            }]
611        }))
612        .unwrap();
613
614        // "degree" is not in credential_subject, so no match
615        let results = match_credentials_pe(&creds, &pd);
616        assert!(results.is_empty());
617    }
618
619    #[test]
620    fn pe_field_path_type_matches() {
621        let creds = vec![make_vc(
622            &["VerifiableCredential", "IDCredential"],
623            serde_json::json!({}),
624        )];
625        let pd: baseid_oid4vp::PresentationDefinition = serde_json::from_value(serde_json::json!({
626            "id": "pd-1",
627            "input_descriptors": [{
628                "id": "desc-1",
629                "constraints": {
630                    "fields": [{
631                        "path": ["$.vc.type"]
632                    }]
633                }
634            }]
635        }))
636        .unwrap();
637
638        let results = match_credentials_pe(&creds, &pd);
639        assert_eq!(results.len(), 1);
640        assert!(results[0]
641            .matching_fields
642            .contains(&"$.vc.type".to_string()));
643    }
644}