baseid_sd_jwt/
lifecycle.rs

1//! SD-JWT implementation of the unified credential lifecycle traits.
2//!
3//! `SdJwtLifecycle` implements `CredentialIssuer`, `CredentialVerifier`, and
4//! `CredentialPresenter` for the SD-JWT format. Predicates are not supported
5//! and return `UnsupportedPredicate`.
6
7use baseid_core::claims::{ClaimSet, DisclosureSelection};
8use baseid_core::error::CredentialError;
9use baseid_core::lifecycle::{
10    CredentialIssuer, CredentialPresenter, CredentialVerifier, IssuanceOptions, IssuedCredential,
11    PresentationOptions, PresentedCredential, RevocationStatus, VerificationOutcome,
12};
13use baseid_core::types::CredentialFormat;
14use baseid_crypto::signer::{Signer, Verifier};
15
16use crate::issuer::SdJwtIssuer;
17use crate::verifier::SdJwtVerifier;
18use crate::SdJwt;
19
20/// SD-JWT credential lifecycle implementation.
21///
22/// Wraps the lower-level `SdJwtIssuer` and `SdJwtVerifier` behind the
23/// unified lifecycle traits. All claims in the default namespace are issued
24/// as selectively-disclosable by default; plain claims (iss, iat, etc.)
25/// are added automatically.
26pub struct SdJwtLifecycle<'a> {
27    signer: &'a dyn Signer,
28    verifier: &'a dyn Verifier,
29    kid: String,
30}
31
32impl<'a> SdJwtLifecycle<'a> {
33    /// Create a new SD-JWT lifecycle handler.
34    ///
35    /// # Arguments
36    /// * `signer` - Key for signing (issuance and presentation)
37    /// * `verifier` - Key for verification
38    /// * `kid` - Key ID for the JWT header
39    pub fn new(signer: &'a dyn Signer, verifier: &'a dyn Verifier, kid: &str) -> Self {
40        Self {
41            signer,
42            verifier,
43            kid: kid.to_string(),
44        }
45    }
46}
47
48impl CredentialIssuer for SdJwtLifecycle<'_> {
49    fn format(&self) -> CredentialFormat {
50        CredentialFormat::SdJwtVc
51    }
52
53    fn issue(
54        &self,
55        issuer_did: &str,
56        subject_did: Option<&str>,
57        claims: &ClaimSet,
58        options: &IssuanceOptions,
59    ) -> baseid_core::Result<IssuedCredential> {
60        let mut builder = SdJwtIssuer::new(self.signer, &self.kid)
61            .add_plain_claim("iss", serde_json::json!(issuer_did));
62
63        if let Some(sub) = subject_did {
64            builder = builder.add_plain_claim("sub", serde_json::json!(sub));
65        }
66
67        if let Some(ref vf) = options.valid_from {
68            builder = builder.add_plain_claim("iat", serde_json::json!(vf));
69        }
70
71        if let Some(ref vu) = options.valid_until {
72            builder = builder.add_plain_claim("exp", serde_json::json!(vu));
73        }
74
75        if let Some(ref id) = options.credential_id {
76            builder = builder.add_plain_claim("jti", serde_json::json!(id));
77        }
78
79        // Add all claims from the default namespace as selectively-disclosable
80        if let Some(ns_claims) = claims.namespace("") {
81            for (name, value) in ns_claims {
82                builder = builder.add_sd_claim(name, value.clone());
83            }
84        }
85
86        let sd_jwt = builder.build()?;
87        let serialized = sd_jwt.serialize();
88
89        Ok(IssuedCredential {
90            data: serialized.into_bytes(),
91            format: CredentialFormat::SdJwtVc,
92            id: options.credential_id.clone(),
93            issuer: issuer_did.to_string(),
94            subject: subject_did.map(|s| s.to_string()),
95        })
96    }
97}
98
99impl CredentialVerifier for SdJwtLifecycle<'_> {
100    fn format(&self) -> CredentialFormat {
101        CredentialFormat::SdJwtVc
102    }
103
104    fn verify(&self, credential_data: &[u8]) -> baseid_core::Result<VerificationOutcome> {
105        let compact =
106            std::str::from_utf8(credential_data).map_err(|_| CredentialError::InvalidCredential)?;
107
108        let sd_jwt = SdJwt::parse(compact)?;
109        let verifier = SdJwtVerifier::new(self.verifier);
110        let claims_value = verifier.verify(&sd_jwt)?;
111
112        // Extract standard JWT claims
113        let issuer = claims_value
114            .get("iss")
115            .and_then(|v| v.as_str())
116            .unwrap_or("")
117            .to_string();
118
119        let subject = claims_value
120            .get("sub")
121            .and_then(|v| v.as_str())
122            .map(|s| s.to_string());
123
124        let valid_until = claims_value
125            .get("exp")
126            .and_then(|v| v.as_str())
127            .map(|s| s.to_string());
128
129        // Build ClaimSet from non-standard claims
130        let mut claim_set = ClaimSet::new();
131        if let Some(obj) = claims_value.as_object() {
132            for (k, v) in obj {
133                // Skip standard JWT claims
134                match k.as_str() {
135                    "iss" | "sub" | "iat" | "exp" | "jti" | "nbf" | "aud" => continue,
136                    _ => claim_set.insert("", k, v.clone()),
137                }
138            }
139        }
140
141        Ok(VerificationOutcome {
142            valid: true,
143            format: CredentialFormat::SdJwtVc,
144            issuer,
145            subject,
146            claims: claim_set,
147            unlinkable: false,
148            predicates_verified: vec![],
149            valid_until,
150            revocation_status: RevocationStatus::NotChecked,
151        })
152    }
153}
154
155impl CredentialPresenter for SdJwtLifecycle<'_> {
156    fn format(&self) -> CredentialFormat {
157        CredentialFormat::SdJwtVc
158    }
159
160    fn present(
161        &self,
162        credential_data: &[u8],
163        disclosure: &DisclosureSelection,
164        _options: &PresentationOptions,
165    ) -> baseid_core::Result<PresentedCredential> {
166        // SD-JWT does not support predicates
167        if disclosure.has_predicates() {
168            return Err(CredentialError::UnsupportedPredicate.into());
169        }
170
171        let compact =
172            std::str::from_utf8(credential_data).map_err(|_| CredentialError::InvalidCredential)?;
173
174        let sd_jwt = SdJwt::parse(compact)?;
175
176        // Determine which disclosures to include based on revealed claims.
177        // We need to decode each disclosure to check its claim name.
178        let revealed = disclosure.revealed_claims();
179        let mut selected_disclosures = Vec::new();
180
181        for encoded_disc in &sd_jwt.disclosures {
182            let disc = crate::disclosure::Disclosure::decode(encoded_disc)?;
183            if let Some(ref name) = disc.claim_name {
184                // Include this disclosure if the claim is revealed
185                // (or if no selection was specified for it — default to include)
186                let should_include =
187                    revealed.contains(&name.as_str()) || disclosure.get("", name).is_none();
188                if should_include {
189                    selected_disclosures.push(encoded_disc.clone());
190                }
191            } else {
192                // Array element disclosures: include by default
193                selected_disclosures.push(encoded_disc.clone());
194            }
195        }
196
197        let presented = SdJwt {
198            jwt: sd_jwt.jwt,
199            disclosures: selected_disclosures,
200            key_binding_jwt: sd_jwt.key_binding_jwt,
201        };
202
203        Ok(PresentedCredential {
204            data: presented.serialize().into_bytes(),
205            format: CredentialFormat::SdJwtVc,
206            unlinkable: false,
207        })
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use baseid_core::claims::PredicateType;
215    use baseid_core::types::KeyType;
216    use baseid_crypto::KeyPair;
217    use serde_json::json;
218
219    fn setup() -> (KeyPair, ClaimSet) {
220        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
221        let mut claims = ClaimSet::new();
222        claims.insert("", "given_name", json!("Alice"));
223        claims.insert("", "family_name", json!("Smith"));
224        claims.insert("", "birth_date", json!("1990-01-15"));
225        claims.insert("", "email", json!("alice@example.com"));
226        (kp, claims)
227    }
228
229    #[test]
230    fn issue_and_verify() {
231        let (kp, claims) = setup();
232        let lifecycle = SdJwtLifecycle::new(&kp, &kp.public, "kid-1");
233
234        let issued = lifecycle
235            .issue(
236                "did:key:issuer",
237                Some("did:key:holder"),
238                &claims,
239                &IssuanceOptions::default(),
240            )
241            .unwrap();
242
243        assert_eq!(issued.format, CredentialFormat::SdJwtVc);
244        assert_eq!(issued.issuer, "did:key:issuer");
245        assert_eq!(issued.subject, Some("did:key:holder".to_string()));
246
247        let outcome = lifecycle.verify(&issued.data).unwrap();
248        assert!(outcome.valid);
249        assert_eq!(outcome.issuer, "did:key:issuer");
250        assert_eq!(outcome.subject, Some("did:key:holder".to_string()));
251        assert_eq!(outcome.claims.get("", "given_name"), Some(&json!("Alice")));
252        assert_eq!(outcome.claims.get("", "family_name"), Some(&json!("Smith")));
253        assert!(!outcome.unlinkable);
254    }
255
256    #[test]
257    fn present_selective_disclosure() {
258        let (kp, claims) = setup();
259        let lifecycle = SdJwtLifecycle::new(&kp, &kp.public, "kid-1");
260
261        let issued = lifecycle
262            .issue(
263                "did:key:issuer",
264                Some("did:key:holder"),
265                &claims,
266                &IssuanceOptions::default(),
267            )
268            .unwrap();
269
270        // Only reveal given_name and family_name, hide the rest
271        let disclosure = DisclosureSelection::new()
272            .reveal("given_name")
273            .reveal("family_name")
274            .hide("birth_date")
275            .hide("email");
276
277        let presented = lifecycle
278            .present(&issued.data, &disclosure, &PresentationOptions::default())
279            .unwrap();
280
281        assert_eq!(presented.format, CredentialFormat::SdJwtVc);
282        assert!(!presented.unlinkable);
283
284        // Verify the presentation — should only have revealed claims
285        let outcome = lifecycle.verify(&presented.data).unwrap();
286        assert!(outcome.valid);
287        assert_eq!(outcome.claims.get("", "given_name"), Some(&json!("Alice")));
288        assert_eq!(outcome.claims.get("", "family_name"), Some(&json!("Smith")));
289        assert_eq!(outcome.claims.get("", "birth_date"), None);
290        assert_eq!(outcome.claims.get("", "email"), None);
291    }
292
293    #[test]
294    fn predicate_returns_unsupported() {
295        let (kp, claims) = setup();
296        let lifecycle = SdJwtLifecycle::new(&kp, &kp.public, "kid-1");
297
298        let issued = lifecycle
299            .issue("did:key:issuer", None, &claims, &IssuanceOptions::default())
300            .unwrap();
301
302        let disclosure = DisclosureSelection::new()
303            .reveal("given_name")
304            .predicate("birth_date", PredicateType::LessThan(json!("2008-03-01")));
305
306        let result = lifecycle.present(&issued.data, &disclosure, &PresentationOptions::default());
307        assert!(result.is_err());
308
309        let err = result.unwrap_err();
310        assert!(
311            format!("{err}").contains("Predicate"),
312            "Error should mention predicate: {err}"
313        );
314    }
315
316    #[test]
317    fn issuance_with_options() {
318        let (kp, claims) = setup();
319        let lifecycle = SdJwtLifecycle::new(&kp, &kp.public, "kid-1");
320
321        let opts = IssuanceOptions {
322            credential_id: Some("urn:uuid:1234".to_string()),
323            types: vec!["IdentityCredential".to_string()],
324            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
325            valid_until: Some("2025-01-01T00:00:00Z".to_string()),
326            status: None,
327        };
328
329        let issued = lifecycle
330            .issue("did:key:issuer", Some("did:key:holder"), &claims, &opts)
331            .unwrap();
332
333        let outcome = lifecycle.verify(&issued.data).unwrap();
334        assert_eq!(
335            outcome.valid_until,
336            Some("2025-01-01T00:00:00Z".to_string())
337        );
338    }
339}