baseid_wallet_core/
wallet.rs

1//! Wallet orchestrator — composes storage, matching, and presentation.
2
3use baseid_core::credential::CredentialSummary;
4use baseid_core::types::CredentialId;
5
6use crate::holder::WalletStore;
7use crate::matcher::{self, PresentationRequest};
8use crate::presenter::CredentialMatch;
9use crate::receive;
10use crate::{AnyCredential, CredentialFilter, CredentialRecord};
11
12/// A digital identity wallet that manages credentials.
13///
14/// Generic over the store implementation — use `InMemoryStore` for testing,
15/// encrypted stores for production.
16pub struct Wallet<S: WalletStore> {
17    store: S,
18    holder_did: String,
19}
20
21impl<S: WalletStore> Wallet<S> {
22    /// Create a new wallet with the given store and holder DID.
23    pub fn new(store: S, holder_did: String) -> Self {
24        Self { store, holder_did }
25    }
26
27    /// Receive credentials from an OID4VCI response and store them.
28    pub async fn receive_credential(
29        &self,
30        response: &baseid_oid4vci::credential::CredentialResponse,
31        format_hint: &str,
32    ) -> baseid_core::Result<Vec<CredentialId>> {
33        receive::receive_oid4vci_credential(&self.store, response, format_hint).await
34    }
35
36    /// Find credentials matching a presentation request (PE or DCQL).
37    pub async fn find_matching_credentials(
38        &self,
39        request: &serde_json::Value,
40    ) -> baseid_core::Result<Vec<CredentialMatch>> {
41        let pr = PresentationRequest::from_value(request)?;
42        // Get all credentials as CredentialRecords for the matcher
43        let summaries = self
44            .store
45            .list_credentials(CredentialFilter::default())
46            .await?;
47        let mut records = Vec::new();
48        for summary in &summaries {
49            let cred = self.store.get_credential(&summary.metadata.id).await?;
50            records.push(CredentialRecord {
51                id: summary.metadata.id.clone(),
52                credential: cred,
53                raw: None,
54            });
55        }
56        Ok(match pr {
57            PresentationRequest::PresentationExchange(pd) => {
58                matcher::match_credentials_pe(&records, &pd)
59            }
60            PresentationRequest::Dcql(dcql) => matcher::match_credentials_dcql(&records, &dcql),
61        })
62    }
63
64    /// List all stored credentials with optional filtering.
65    pub async fn list_credentials(
66        &self,
67        filter: CredentialFilter,
68    ) -> baseid_core::Result<Vec<CredentialSummary>> {
69        self.store.list_credentials(filter).await
70    }
71
72    /// Get a credential by ID.
73    pub async fn get_credential(&self, id: &CredentialId) -> baseid_core::Result<AnyCredential> {
74        self.store.get_credential(id).await
75    }
76
77    /// Delete a credential.
78    pub async fn delete_credential(&self, id: &CredentialId) -> baseid_core::Result<()> {
79        self.store.delete_credential(id).await
80    }
81
82    /// Get the holder DID.
83    pub fn holder_did(&self) -> &str {
84        &self.holder_did
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::store::InMemoryStore;
92    use baseid_core::types::{CredentialFormat, KeyType};
93    use baseid_crypto::KeyPair;
94    use baseid_did::DidKeyResolver;
95    use baseid_oid4vci::credential::{CredentialEntry, CredentialResponse};
96    use baseid_vc::credential::Issuer;
97    use baseid_vc::VerifiableCredential;
98
99    /// Create a signed JWT VC for testing.
100    fn make_signed_vc(type_name: &str, subject: serde_json::Value) -> (String, KeyPair, String) {
101        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
102        let doc = DidKeyResolver::create(&kp.public).unwrap();
103        let kid = doc.verification_method[0].id.clone();
104
105        let vc = VerifiableCredential {
106            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
107            id: None,
108            r#type: vec!["VerifiableCredential".to_string(), type_name.to_string()],
109            issuer: Issuer::Uri(doc.id.clone()),
110            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
111            valid_until: None,
112            credential_subject: subject,
113            credential_status: None,
114            proof: None,
115        };
116        let jwt = baseid_vc::sign_credential_jwt(&vc, &kp, &kid).unwrap();
117        (jwt, kp, doc.id)
118    }
119
120    /// Build a mock OID4VCI CredentialResponse containing a JWT string.
121    fn mock_credential_response(jwt: &str) -> CredentialResponse {
122        CredentialResponse {
123            credentials: Some(vec![CredentialEntry {
124                credential: serde_json::Value::String(jwt.to_string()),
125            }]),
126            transaction_id: None,
127            notification_id: None,
128            interval: None,
129        }
130    }
131
132    /// Build a deferred OID4VCI CredentialResponse (no credentials).
133    fn mock_deferred_response() -> CredentialResponse {
134        CredentialResponse {
135            credentials: None,
136            transaction_id: Some("txn-deferred-1".to_string()),
137            notification_id: None,
138            interval: Some(5),
139        }
140    }
141
142    /// Helper: create a wallet, sign a VC, receive it, return the wallet and credential IDs.
143    async fn wallet_with_vc(
144        type_name: &str,
145        subject: serde_json::Value,
146    ) -> (Wallet<InMemoryStore>, Vec<CredentialId>) {
147        let (jwt, _kp, holder_did) = make_signed_vc(type_name, subject);
148        let wallet = Wallet::new(InMemoryStore::new(), holder_did);
149        let response = mock_credential_response(&jwt);
150        let ids = wallet
151            .receive_credential(&response, "jwt_vc_json")
152            .await
153            .unwrap();
154        (wallet, ids)
155    }
156
157    // ------------------------------------------------------------------
158    // Phase 4: Wallet orchestrator tests
159    // ------------------------------------------------------------------
160
161    #[tokio::test]
162    async fn wallet_new() {
163        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
164        assert_eq!(wallet.holder_did(), "did:key:holder");
165    }
166
167    #[tokio::test]
168    async fn wallet_list_empty() {
169        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
170        let list = wallet
171            .list_credentials(CredentialFilter::default())
172            .await
173            .unwrap();
174        assert!(list.is_empty());
175    }
176
177    #[tokio::test]
178    async fn wallet_receive_and_list() {
179        let (wallet, ids) = wallet_with_vc(
180            "UniversityDegreeCredential",
181            serde_json::json!({"id": "did:key:holder", "degree": {"type": "BachelorDegree"}}),
182        )
183        .await;
184
185        assert_eq!(ids.len(), 1);
186        let list = wallet
187            .list_credentials(CredentialFilter::default())
188            .await
189            .unwrap();
190        assert_eq!(list.len(), 1);
191    }
192
193    #[tokio::test]
194    async fn wallet_receive_and_get() {
195        let (wallet, ids) = wallet_with_vc(
196            "UniversityDegreeCredential",
197            serde_json::json!({"id": "did:key:holder", "degree": {"type": "BachelorDegree"}}),
198        )
199        .await;
200
201        let cred = wallet.get_credential(&ids[0]).await.unwrap();
202        assert_eq!(cred.credential_format(), CredentialFormat::W3cVc);
203        match &cred {
204            AnyCredential::W3cVc(vc) => {
205                assert!(vc
206                    .r#type
207                    .contains(&"UniversityDegreeCredential".to_string()));
208            }
209            _ => panic!("Expected W3cVc variant"),
210        }
211    }
212
213    #[tokio::test]
214    async fn wallet_delete() {
215        let (wallet, ids) = wallet_with_vc(
216            "UniversityDegreeCredential",
217            serde_json::json!({"id": "did:key:holder"}),
218        )
219        .await;
220
221        wallet.delete_credential(&ids[0]).await.unwrap();
222        let list = wallet
223            .list_credentials(CredentialFilter::default())
224            .await
225            .unwrap();
226        assert!(list.is_empty());
227    }
228
229    #[tokio::test]
230    async fn wallet_find_matching_pe() {
231        let (wallet, _ids) = wallet_with_vc(
232            "UniversityDegreeCredential",
233            serde_json::json!({"id": "did:key:holder", "degree": {"type": "BachelorDegree"}}),
234        )
235        .await;
236
237        let pe_request = serde_json::json!({
238            "id": "pd-1",
239            "input_descriptors": [{
240                "id": "degree",
241                "constraints": {
242                    "fields": [{
243                        "path": ["$.credentialSubject.degree"]
244                    }]
245                },
246                "format": {"jwt_vc_json": {}}
247            }]
248        });
249
250        let matches = wallet.find_matching_credentials(&pe_request).await.unwrap();
251        assert_eq!(matches.len(), 1);
252        assert_eq!(matches[0].format, CredentialFormat::W3cVc);
253        assert!(matches[0]
254            .matching_fields
255            .contains(&"$.credentialSubject.degree".to_string()));
256    }
257
258    #[tokio::test]
259    async fn wallet_find_matching_dcql() {
260        let (wallet, _ids) = wallet_with_vc(
261            "UniversityDegreeCredential",
262            serde_json::json!({"id": "did:key:holder", "degree": {"type": "BachelorDegree"}}),
263        )
264        .await;
265
266        let dcql_request = serde_json::json!({
267            "credentials": [{
268                "id": "req-1",
269                "format": "jwt_vc_json",
270                "meta": {
271                    "type_values": [["UniversityDegreeCredential"]]
272                },
273                "claims": [
274                    {"path": ["degree"]}
275                ]
276            }]
277        });
278
279        let matches = wallet
280            .find_matching_credentials(&dcql_request)
281            .await
282            .unwrap();
283        assert_eq!(matches.len(), 1);
284        assert_eq!(matches[0].format, CredentialFormat::W3cVc);
285        assert_eq!(matches[0].matching_fields, vec!["degree"]);
286    }
287
288    #[tokio::test]
289    async fn wallet_find_no_match() {
290        let (wallet, _ids) = wallet_with_vc(
291            "UniversityDegreeCredential",
292            serde_json::json!({"id": "did:key:holder"}),
293        )
294        .await;
295
296        // Request mDL format — the stored VC won't match
297        let dcql_request = serde_json::json!({
298            "credentials": [{
299                "id": "req-1",
300                "format": "mso_mdoc"
301            }]
302        });
303
304        let matches = wallet
305            .find_matching_credentials(&dcql_request)
306            .await
307            .unwrap();
308        assert!(matches.is_empty());
309    }
310
311    #[tokio::test]
312    async fn e2e_issue_store_match() {
313        // Full flow: sign VC -> wrap as OID4VCI response -> receive -> match against PD
314        let kp = KeyPair::generate(KeyType::P256).unwrap();
315        let doc = DidKeyResolver::create(&kp.public).unwrap();
316        let kid = doc.verification_method[0].id.clone();
317
318        let vc = VerifiableCredential {
319            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
320            id: None,
321            r#type: vec![
322                "VerifiableCredential".to_string(),
323                "UniversityDegreeCredential".to_string(),
324            ],
325            issuer: Issuer::Uri(doc.id.clone()),
326            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
327            valid_until: None,
328            credential_subject: serde_json::json!({
329                "id": "did:key:holder",
330                "degree": {"type": "BachelorDegree"}
331            }),
332            credential_status: None,
333            proof: None,
334        };
335        let jwt = baseid_vc::sign_credential_jwt(&vc, &kp, &kid).unwrap();
336
337        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
338        let response = mock_credential_response(&jwt);
339        let ids = wallet
340            .receive_credential(&response, "jwt_vc_json")
341            .await
342            .unwrap();
343        assert_eq!(ids.len(), 1);
344
345        // Match with a PE definition
346        let pe = serde_json::json!({
347            "id": "pd-e2e",
348            "input_descriptors": [{
349                "id": "degree",
350                "constraints": {
351                    "fields": [{
352                        "path": ["$.credentialSubject.degree"]
353                    }]
354                },
355                "format": {"jwt_vc_json": {}}
356            }]
357        });
358
359        let matches = wallet.find_matching_credentials(&pe).await.unwrap();
360        assert_eq!(matches.len(), 1);
361        assert_eq!(matches[0].credential_id, ids[0]);
362        assert_eq!(matches[0].format, CredentialFormat::W3cVc);
363        assert!(matches[0]
364            .matching_fields
365            .contains(&"$.credentialSubject.degree".to_string()));
366
367        // Verify we can get the stored credential back
368        let cred = wallet.get_credential(&ids[0]).await.unwrap();
369        match &cred {
370            AnyCredential::W3cVc(stored_vc) => {
371                assert!(stored_vc
372                    .r#type
373                    .contains(&"UniversityDegreeCredential".to_string()));
374                assert_eq!(
375                    stored_vc.credential_subject["degree"]["type"],
376                    "BachelorDegree"
377                );
378            }
379            _ => panic!("Expected W3cVc variant"),
380        }
381    }
382
383    #[tokio::test]
384    async fn wallet_receive_multiple_formats() {
385        let (jwt_vc, _kp, holder_did) = make_signed_vc(
386            "UniversityDegreeCredential",
387            serde_json::json!({"id": "did:key:holder"}),
388        );
389
390        let wallet = Wallet::new(InMemoryStore::new(), holder_did);
391
392        // Receive a JWT VC
393        let vc_response = mock_credential_response(&jwt_vc);
394        let vc_ids = wallet
395            .receive_credential(&vc_response, "jwt_vc_json")
396            .await
397            .unwrap();
398        assert_eq!(vc_ids.len(), 1);
399
400        // Receive an SD-JWT credential
401        let sd_jwt = baseid_sd_jwt::SdJwt {
402            jwt: "eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJ0ZXN0In0.c2ln"
403                .to_string(),
404            disclosures: vec![],
405            key_binding_jwt: None,
406        };
407        let compact = sd_jwt.serialize();
408        let sd_response = CredentialResponse {
409            credentials: Some(vec![CredentialEntry {
410                credential: serde_json::Value::String(compact),
411            }]),
412            transaction_id: None,
413            notification_id: None,
414            interval: None,
415        };
416        let sd_ids = wallet
417            .receive_credential(&sd_response, "dc+sd-jwt")
418            .await
419            .unwrap();
420        assert_eq!(sd_ids.len(), 1);
421
422        // List all — should be 2
423        let all = wallet
424            .list_credentials(CredentialFilter::default())
425            .await
426            .unwrap();
427        assert_eq!(all.len(), 2);
428
429        // Filter by W3C VC format
430        let vc_only = wallet
431            .list_credentials(CredentialFilter {
432                format: Some(CredentialFormat::W3cVc),
433                issuer: None,
434            })
435            .await
436            .unwrap();
437        assert_eq!(vc_only.len(), 1);
438        assert_eq!(vc_only[0].metadata.format, CredentialFormat::W3cVc);
439
440        // Filter by SD-JWT format
441        let sd_only = wallet
442            .list_credentials(CredentialFilter {
443                format: Some(CredentialFormat::SdJwtVc),
444                issuer: None,
445            })
446            .await
447            .unwrap();
448        assert_eq!(sd_only.len(), 1);
449        assert_eq!(sd_only[0].metadata.format, CredentialFormat::SdJwtVc);
450    }
451
452    // ------------------------------------------------------------------
453    // Phase 5: Hardening edge case tests
454    // ------------------------------------------------------------------
455
456    #[tokio::test]
457    async fn wallet_get_nonexistent() {
458        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
459        let result = wallet
460            .get_credential(&CredentialId("nonexistent".to_string()))
461            .await;
462        assert!(result.is_err());
463    }
464
465    #[tokio::test]
466    async fn wallet_delete_nonexistent() {
467        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
468        let result = wallet
469            .delete_credential(&CredentialId("nonexistent".to_string()))
470            .await;
471        assert!(result.is_err());
472    }
473
474    #[tokio::test]
475    async fn wallet_find_matching_empty_store() {
476        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
477        let pe_request = serde_json::json!({
478            "id": "pd-1",
479            "input_descriptors": [{
480                "id": "desc-1",
481                "constraints": { "fields": [] },
482                "format": {"jwt_vc_json": {}}
483            }]
484        });
485
486        let matches = wallet.find_matching_credentials(&pe_request).await.unwrap();
487        assert!(matches.is_empty());
488    }
489
490    #[tokio::test]
491    async fn wallet_receive_deferred() {
492        let wallet = Wallet::new(InMemoryStore::new(), "did:key:holder".to_string());
493        let response = mock_deferred_response();
494        let ids = wallet
495            .receive_credential(&response, "jwt_vc_json")
496            .await
497            .unwrap();
498        assert!(ids.is_empty());
499        let list = wallet
500            .list_credentials(CredentialFilter::default())
501            .await
502            .unwrap();
503        assert!(list.is_empty());
504    }
505}