baseid_vc/
signing.rs

1//! JWT-VC signing and verification.
2//!
3//! Signs and verifies W3C Verifiable Credentials and Presentations as JWTs
4//! per the VC-JWT specification. Includes `nbf`, `exp`, and `iat` claims
5//! per the VC-JWT mapping, with time validation on verification.
6
7use baseid_core::error::{CredentialError, SerializationError};
8use baseid_crypto::jwt::{self, alg_to_str, JwtHeader};
9use baseid_crypto::signer::{Signer, Verifier};
10use serde_json::Value;
11
12use crate::credential::{Issuer, VerifiableCredential};
13use crate::presentation::VerifiablePresentation;
14
15/// Clock skew tolerance in seconds for `nbf`/`exp` validation.
16const TIME_LEEWAY_SECS: i64 = 60;
17
18/// Extract the DID string from an `Issuer`.
19pub fn issuer_id(issuer: &Issuer) -> &str {
20    match issuer {
21        Issuer::Uri(uri) => uri.as_str(),
22        Issuer::Object { id, .. } => id.as_str(),
23    }
24}
25
26/// Sign a `VerifiableCredential` as a JWT.
27///
28/// Returns the compact JWT serialization. The `kid` should be a DID URL
29/// identifying the verification method (e.g., `did:key:z6Mk...#z6Mk...`).
30///
31/// Per the VC-JWT spec, `valid_from` maps to `nbf` and `valid_until` maps
32/// to `exp`. An `iat` (issued-at) claim is always included.
33pub fn sign_credential_jwt(
34    vc: &VerifiableCredential,
35    signer: &dyn Signer,
36    kid: &str,
37) -> baseid_core::Result<String> {
38    let header = JwtHeader {
39        alg: alg_to_str(signer.algorithm()).to_string(),
40        typ: Some("JWT".to_string()),
41        kid: Some(kid.to_string()),
42        additional: serde_json::Map::new(),
43    };
44
45    let vc_value = serde_json::to_value(vc).map_err(SerializationError::Json)?;
46
47    let mut claims = serde_json::Map::new();
48    claims.insert(
49        "iss".to_string(),
50        Value::String(issuer_id(&vc.issuer).to_string()),
51    );
52    if let Some(id) = &vc.id {
53        claims.insert("jti".to_string(), Value::String(id.clone()));
54    }
55    // Extract subject id if present
56    if let Some(sub) = vc.credential_subject.get("id").and_then(|v| v.as_str()) {
57        claims.insert("sub".to_string(), Value::String(sub.to_string()));
58    }
59
60    // VC-JWT time claims: nbf from valid_from, exp from valid_until, iat = now
61    if let Some(ref valid_from) = vc.valid_from {
62        if let Some(epoch) = parse_rfc3339_to_epoch(valid_from) {
63            claims.insert("nbf".to_string(), Value::Number(epoch.into()));
64        }
65    }
66    if let Some(ref valid_until) = vc.valid_until {
67        if let Some(epoch) = parse_rfc3339_to_epoch(valid_until) {
68            claims.insert("exp".to_string(), Value::Number(epoch.into()));
69        }
70    }
71    claims.insert("iat".to_string(), Value::Number(current_epoch().into()));
72
73    claims.insert("vc".to_string(), vc_value);
74
75    jwt::encode_jwt(&header, &Value::Object(claims), signer)
76}
77
78/// Verify a JWT-encoded Verifiable Credential.
79///
80/// Verifies the signature, validates `nbf`/`exp` time claims (with 60s
81/// leeway for clock skew), and extracts the `VerifiableCredential` from
82/// the `vc` claim.
83///
84/// Returns `CredentialError::Expired` if the credential has expired, and
85/// `CredentialError::InvalidCredential` if the credential is not yet valid.
86pub fn verify_credential_jwt(
87    jwt_str: &str,
88    verifier: &dyn Verifier,
89) -> baseid_core::Result<VerifiableCredential> {
90    let (_header, claims) = jwt::decode_jwt(jwt_str, verifier)?;
91
92    // Validate time claims
93    let now = current_epoch();
94    if let Some(nbf) = claims.get("nbf").and_then(|v| v.as_i64()) {
95        if now < nbf - TIME_LEEWAY_SECS {
96            return Err(CredentialError::InvalidCredential.into());
97        }
98    }
99    if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) {
100        if now > exp + TIME_LEEWAY_SECS {
101            return Err(CredentialError::Expired.into());
102        }
103    }
104
105    let vc_value = claims.get("vc").ok_or(CredentialError::InvalidCredential)?;
106
107    let vc: VerifiableCredential =
108        serde_json::from_value(vc_value.clone()).map_err(SerializationError::Json)?;
109
110    Ok(vc)
111}
112
113/// Sign a `VerifiablePresentation` as a JWT.
114///
115/// The `nonce` and `audience` parameters are included as `nonce` and `aud`
116/// claims respectively, as required by the VP JWT spec.
117pub fn sign_presentation_jwt(
118    vp: &VerifiablePresentation,
119    signer: &dyn Signer,
120    kid: &str,
121    nonce: &str,
122    audience: &str,
123) -> baseid_core::Result<String> {
124    let header = JwtHeader {
125        alg: alg_to_str(signer.algorithm()).to_string(),
126        typ: Some("JWT".to_string()),
127        kid: Some(kid.to_string()),
128        additional: serde_json::Map::new(),
129    };
130
131    let vp_value = serde_json::to_value(vp).map_err(SerializationError::Json)?;
132
133    let mut claims = serde_json::Map::new();
134    if let Some(holder) = &vp.holder {
135        claims.insert("iss".to_string(), Value::String(holder.clone()));
136    }
137    claims.insert("vp".to_string(), vp_value);
138    claims.insert("nonce".to_string(), Value::String(nonce.to_string()));
139    claims.insert("aud".to_string(), Value::String(audience.to_string()));
140
141    jwt::encode_jwt(&header, &Value::Object(claims), signer)
142}
143
144/// Verify a JWT-encoded Verifiable Presentation.
145///
146/// Verifies the signature and extracts the `VerifiablePresentation` from the
147/// `vp` claim. If `expected_nonce` or `expected_audience` are provided, the
148/// corresponding JWT claims are validated.
149pub fn verify_presentation_jwt(
150    jwt_str: &str,
151    verifier: &dyn Verifier,
152    expected_nonce: Option<&str>,
153    expected_audience: Option<&str>,
154) -> baseid_core::Result<VerifiablePresentation> {
155    let (_header, claims) = jwt::decode_jwt(jwt_str, verifier)?;
156
157    // Validate nonce if expected
158    if let Some(expected) = expected_nonce {
159        let actual = claims
160            .get("nonce")
161            .and_then(|v| v.as_str())
162            .ok_or(CredentialError::InvalidCredential)?;
163        if actual != expected {
164            return Err(CredentialError::InvalidCredential.into());
165        }
166    }
167
168    // Validate audience if expected
169    if let Some(expected) = expected_audience {
170        let actual = claims
171            .get("aud")
172            .and_then(|v| v.as_str())
173            .ok_or(CredentialError::InvalidCredential)?;
174        if actual != expected {
175            return Err(CredentialError::InvalidCredential.into());
176        }
177    }
178
179    let vp_value = claims.get("vp").ok_or(CredentialError::InvalidCredential)?;
180
181    let vp: VerifiablePresentation =
182        serde_json::from_value(vp_value.clone()).map_err(SerializationError::Json)?;
183
184    Ok(vp)
185}
186
187/// Get current Unix epoch seconds.
188fn current_epoch() -> i64 {
189    std::time::SystemTime::now()
190        .duration_since(std::time::UNIX_EPOCH)
191        .unwrap_or_default()
192        .as_secs() as i64
193}
194
195/// Parse an RFC 3339 timestamp to Unix epoch seconds.
196///
197/// Handles `Z` (UTC), `+HH:MM`, and `-HH:MM` timezone offsets, and
198/// optional fractional seconds.
199fn parse_rfc3339_to_epoch(s: &str) -> Option<i64> {
200    // Minimum: "YYYY-MM-DDTHH:MM:SS" = 19 chars
201    if s.len() < 19 {
202        return None;
203    }
204    let b = s.as_bytes();
205    // Validate separators
206    if b[4] != b'-'
207        || b[7] != b'-'
208        || (b[10] != b'T' && b[10] != b't')
209        || b[13] != b':'
210        || b[16] != b':'
211    {
212        return None;
213    }
214    let year = parse_u32(&b[0..4])? as i64;
215    let month = parse_u32(&b[5..7])? as i64;
216    let day = parse_u32(&b[8..10])? as i64;
217    let hour = parse_u32(&b[11..13])? as i64;
218    let minute = parse_u32(&b[14..16])? as i64;
219    let second = parse_u32(&b[17..19])? as i64;
220
221    // Parse optional fractional seconds and timezone
222    let rest = &s[19..];
223    let tz_part = if let Some(rest) = rest.strip_prefix('.') {
224        // Skip fractional seconds digits
225        let end = rest
226            .find(|c: char| !c.is_ascii_digit())
227            .unwrap_or(rest.len());
228        &rest[end..]
229    } else {
230        rest
231    };
232
233    let offset_seconds = match tz_part {
234        "" | "Z" | "z" => 0i64,
235        s if s.len() >= 6 && (s.starts_with('+') || s.starts_with('-')) => {
236            let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
237            let oh = parse_u32(&s.as_bytes()[1..3])? as i64;
238            let om = parse_u32(&s.as_bytes()[4..6])? as i64;
239            sign * (oh * 3600 + om * 60)
240        }
241        _ => return None,
242    };
243
244    let days = days_from_civil(year, month, day)?;
245    Some(days * 86400 + hour * 3600 + minute * 60 + second - offset_seconds)
246}
247
248fn parse_u32(b: &[u8]) -> Option<u32> {
249    std::str::from_utf8(b).ok()?.parse().ok()
250}
251
252/// Compute days from 1970-01-01 to the given civil date.
253/// Uses Howard Hinnant's algorithm.
254fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
255    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
256        return None;
257    }
258    let y = if m <= 2 { y - 1 } else { y };
259    let era = if y >= 0 { y } else { y - 399 } / 400;
260    let yoe = (y - era * 400) as u64;
261    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
262    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
263    Some(era * 146097 + doe as i64 - 719468)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use baseid_core::types::KeyType;
270    use baseid_crypto::KeyPair;
271
272    fn sample_vc(issuer_did: &str) -> VerifiableCredential {
273        VerifiableCredential {
274            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
275            id: Some("urn:uuid:12345678-1234-1234-1234-123456789abc".to_string()),
276            r#type: vec![
277                "VerifiableCredential".to_string(),
278                "ExampleCredential".to_string(),
279            ],
280            issuer: Issuer::Uri(issuer_did.to_string()),
281            valid_from: Some("2024-01-01T00:00:00Z".to_string()),
282            valid_until: None,
283            credential_subject: serde_json::json!({
284                "id": "did:key:z6MkHolder...",
285                "name": "Alice",
286            }),
287            credential_status: None,
288            proof: None,
289        }
290    }
291
292    fn vc_sign_verify_roundtrip(key_type: KeyType) {
293        let kp = KeyPair::generate(key_type).unwrap();
294        let kid = "did:key:test#key-0";
295        let vc = sample_vc("did:key:test");
296
297        let jwt = sign_credential_jwt(&vc, &kp, kid).unwrap();
298        let decoded = verify_credential_jwt(&jwt, &kp.public).unwrap();
299
300        assert_eq!(decoded.id, vc.id);
301        assert_eq!(decoded.r#type, vc.r#type);
302        assert_eq!(decoded.issuer, vc.issuer);
303        assert_eq!(decoded.credential_subject, vc.credential_subject);
304    }
305
306    #[test]
307    fn vc_roundtrip_ed25519() {
308        vc_sign_verify_roundtrip(KeyType::Ed25519);
309    }
310
311    #[test]
312    fn vc_roundtrip_p256() {
313        vc_sign_verify_roundtrip(KeyType::P256);
314    }
315
316    #[test]
317    fn vc_roundtrip_p384() {
318        vc_sign_verify_roundtrip(KeyType::P384);
319    }
320
321    #[test]
322    fn vc_roundtrip_secp256k1() {
323        vc_sign_verify_roundtrip(KeyType::Secp256k1);
324    }
325
326    #[test]
327    fn vc_wrong_key_rejected() {
328        let kp1 = KeyPair::generate(KeyType::Ed25519).unwrap();
329        let kp2 = KeyPair::generate(KeyType::Ed25519).unwrap();
330        let vc = sample_vc("did:key:test");
331        let jwt = sign_credential_jwt(&vc, &kp1, "did:key:test#key-0").unwrap();
332
333        assert!(verify_credential_jwt(&jwt, &kp2.public).is_err());
334    }
335
336    #[test]
337    fn vp_roundtrip() {
338        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
339        let vc = sample_vc("did:key:issuer");
340        let vp = VerifiablePresentation {
341            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
342            r#type: vec!["VerifiablePresentation".to_string()],
343            verifiable_credential: vec![vc],
344            holder: Some("did:key:holder".to_string()),
345            proof: None,
346        };
347
348        let jwt = sign_presentation_jwt(
349            &vp,
350            &kp,
351            "did:key:holder#key-0",
352            "nonce123",
353            "did:key:verifier",
354        )
355        .unwrap();
356        let decoded =
357            verify_presentation_jwt(&jwt, &kp.public, Some("nonce123"), Some("did:key:verifier"))
358                .unwrap();
359
360        assert_eq!(decoded.holder, vp.holder);
361        assert_eq!(decoded.verifiable_credential.len(), 1);
362    }
363
364    #[test]
365    fn jwt_claims_structure() {
366        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
367        let vc = sample_vc("did:key:issuer123");
368        let jwt = sign_credential_jwt(&vc, &kp, "did:key:issuer123#key-0").unwrap();
369
370        let (_header, claims) = baseid_crypto::decode_jwt_unverified(&jwt).unwrap();
371        assert_eq!(claims["iss"], "did:key:issuer123");
372        assert_eq!(
373            claims["jti"],
374            "urn:uuid:12345678-1234-1234-1234-123456789abc"
375        );
376        assert_eq!(claims["sub"], "did:key:z6MkHolder...");
377        assert!(claims.get("vc").is_some());
378        // nbf should be present (from valid_from)
379        assert!(claims.get("nbf").is_some(), "nbf claim should be present");
380        assert!(claims.get("iat").is_some(), "iat claim should be present");
381    }
382
383    #[test]
384    fn jwt_includes_exp_when_valid_until_set() {
385        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
386        let mut vc = sample_vc("did:key:issuer");
387        vc.valid_until = Some("2030-12-31T23:59:59Z".to_string());
388
389        let jwt = sign_credential_jwt(&vc, &kp, "kid").unwrap();
390        let (_header, claims) = baseid_crypto::decode_jwt_unverified(&jwt).unwrap();
391
392        let exp = claims["exp"].as_i64().expect("exp should be a number");
393        // 2030-12-31T23:59:59Z = 1924991999
394        assert_eq!(exp, 1924991999);
395    }
396
397    #[test]
398    fn expired_credential_rejected() {
399        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
400        let mut vc = sample_vc("did:key:issuer");
401        vc.valid_until = Some("2020-01-01T00:00:00Z".to_string());
402
403        let jwt = sign_credential_jwt(&vc, &kp, "kid").unwrap();
404        let result = verify_credential_jwt(&jwt, &kp.public);
405
406        assert!(result.is_err());
407        let err = format!("{}", result.unwrap_err());
408        assert!(
409            err.contains("expired") || err.contains("Expired"),
410            "Expected expired error, got: {err}"
411        );
412    }
413
414    #[test]
415    fn future_credential_rejected() {
416        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
417        let mut vc = sample_vc("did:key:issuer");
418        vc.valid_from = Some("2099-01-01T00:00:00Z".to_string());
419
420        let jwt = sign_credential_jwt(&vc, &kp, "kid").unwrap();
421        let result = verify_credential_jwt(&jwt, &kp.public);
422
423        assert!(
424            result.is_err(),
425            "Credential not yet valid should be rejected"
426        );
427    }
428
429    #[test]
430    fn parse_rfc3339_basic() {
431        assert_eq!(
432            parse_rfc3339_to_epoch("2024-01-01T00:00:00Z"),
433            Some(1704067200)
434        );
435        assert_eq!(parse_rfc3339_to_epoch("1970-01-01T00:00:00Z"), Some(0));
436    }
437
438    #[test]
439    fn parse_rfc3339_with_offset() {
440        // UTC+05:00 means the local time is 5 hours ahead, so UTC = local - 5h
441        let utc = parse_rfc3339_to_epoch("2024-01-01T00:00:00Z").unwrap();
442        let plus5 = parse_rfc3339_to_epoch("2024-01-01T05:00:00+05:00").unwrap();
443        assert_eq!(utc, plus5);
444    }
445
446    #[test]
447    fn parse_rfc3339_with_fractional_seconds() {
448        let without = parse_rfc3339_to_epoch("2024-01-01T00:00:00Z").unwrap();
449        let with = parse_rfc3339_to_epoch("2024-01-01T00:00:00.123456Z").unwrap();
450        assert_eq!(without, with);
451    }
452
453    #[test]
454    fn vp_wrong_nonce_rejected() {
455        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
456        let vc = sample_vc("did:key:issuer");
457        let vp = VerifiablePresentation {
458            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
459            r#type: vec!["VerifiablePresentation".to_string()],
460            verifiable_credential: vec![vc],
461            holder: Some("did:key:holder".to_string()),
462            proof: None,
463        };
464
465        let jwt = sign_presentation_jwt(&vp, &kp, "kid", "real-nonce", "aud").unwrap();
466        let result = verify_presentation_jwt(&jwt, &kp.public, Some("wrong-nonce"), None);
467        assert!(result.is_err(), "Wrong nonce should be rejected");
468    }
469
470    #[test]
471    fn vp_wrong_audience_rejected() {
472        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
473        let vc = sample_vc("did:key:issuer");
474        let vp = VerifiablePresentation {
475            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
476            r#type: vec!["VerifiablePresentation".to_string()],
477            verifiable_credential: vec![vc],
478            holder: Some("did:key:holder".to_string()),
479            proof: None,
480        };
481
482        let jwt = sign_presentation_jwt(&vp, &kp, "kid", "nonce", "did:key:real-verifier").unwrap();
483        let result =
484            verify_presentation_jwt(&jwt, &kp.public, None, Some("did:key:wrong-verifier"));
485        assert!(result.is_err(), "Wrong audience should be rejected");
486    }
487
488    #[test]
489    fn vp_nonce_and_audience_accepted_when_correct() {
490        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
491        let vc = sample_vc("did:key:issuer");
492        let vp = VerifiablePresentation {
493            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
494            r#type: vec!["VerifiablePresentation".to_string()],
495            verifiable_credential: vec![vc],
496            holder: Some("did:key:holder".to_string()),
497            proof: None,
498        };
499
500        let jwt =
501            sign_presentation_jwt(&vp, &kp, "kid", "correct-nonce", "did:key:verifier").unwrap();
502        let result = verify_presentation_jwt(
503            &jwt,
504            &kp.public,
505            Some("correct-nonce"),
506            Some("did:key:verifier"),
507        );
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn vp_no_validation_when_none_expected() {
513        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
514        let vc = sample_vc("did:key:issuer");
515        let vp = VerifiablePresentation {
516            context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
517            r#type: vec!["VerifiablePresentation".to_string()],
518            verifiable_credential: vec![vc],
519            holder: Some("did:key:holder".to_string()),
520            proof: None,
521        };
522
523        let jwt = sign_presentation_jwt(&vp, &kp, "kid", "any-nonce", "any-aud").unwrap();
524        // When None is passed, no validation — should succeed
525        let result = verify_presentation_jwt(&jwt, &kp.public, None, None);
526        assert!(result.is_ok());
527    }
528}