baseid_sd_jwt/
verifier.rs

1//! SD-JWT verification.
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
4use baseid_core::error::CryptoError;
5use baseid_crypto::jwt;
6use baseid_crypto::signer::Verifier;
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::disclosure::Disclosure;
11use crate::SdJwt;
12
13/// Verifier for SD-JWT presentations.
14pub struct SdJwtVerifier<'a> {
15    verifier: &'a dyn Verifier,
16}
17
18impl<'a> SdJwtVerifier<'a> {
19    /// Create a new SD-JWT verifier with the given signature verifier.
20    pub fn new(verifier: &'a dyn Verifier) -> Self {
21        Self { verifier }
22    }
23
24    /// Verify an SD-JWT and return the disclosed claims.
25    ///
26    /// 1. Verifies the JWT signature.
27    /// 2. Decodes each disclosure and computes its digest.
28    /// 3. Matches digests against the `_sd` array in the JWT claims.
29    /// 4. Merges disclosed claims into the result.
30    /// 5. Removes `_sd` and `_sd_alg` from the output.
31    pub fn verify(&self, sd_jwt: &SdJwt) -> baseid_core::Result<Value> {
32        // Verify signature and decode claims
33        let (_header, mut claims) = jwt::decode_jwt(&sd_jwt.jwt, self.verifier)?;
34
35        // Extract the _sd array of digests
36        let sd_digests: Vec<String> = match claims.get("_sd") {
37            Some(Value::Array(arr)) => arr
38                .iter()
39                .filter_map(|v| v.as_str().map(|s| s.to_string()))
40                .collect(),
41            Some(_) => return Err(CryptoError::VerificationFailed.into()),
42            None => Vec::new(),
43        };
44
45        // Decode each disclosure and match against digests
46        let claims_obj = claims
47            .as_object_mut()
48            .ok_or(CryptoError::VerificationFailed)?;
49
50        for encoded_disclosure in &sd_jwt.disclosures {
51            // Compute digest of the raw encoded disclosure
52            let digest = {
53                let hash = Sha256::digest(encoded_disclosure.as_bytes());
54                URL_SAFE_NO_PAD.encode(hash)
55            };
56
57            // Check that the digest is in _sd
58            if !sd_digests.contains(&digest) {
59                return Err(CryptoError::VerificationFailed.into());
60            }
61
62            // Decode the disclosure and merge the claim
63            let disclosure = Disclosure::decode(encoded_disclosure)?;
64            if let Some(name) = &disclosure.claim_name {
65                claims_obj.insert(name.clone(), disclosure.claim_value);
66            }
67        }
68
69        // Remove SD-JWT metadata
70        claims_obj.remove("_sd");
71        claims_obj.remove("_sd_alg");
72
73        Ok(claims)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::issuer::SdJwtIssuer;
81    use baseid_core::types::KeyType;
82    use baseid_crypto::KeyPair;
83
84    #[test]
85    fn full_issue_verify_roundtrip() {
86        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
87        let kid = "did:key:test#key-0";
88
89        let sd_jwt = SdJwtIssuer::new(&kp, kid)
90            .add_plain_claim("iss", Value::String("did:key:test".to_string()))
91            .add_sd_claim("name", Value::String("Alice".to_string()))
92            .add_sd_claim("age", Value::Number(30.into()))
93            .add_sd_claim("email", Value::String("alice@example.com".to_string()))
94            .build()
95            .unwrap();
96
97        assert_eq!(sd_jwt.disclosures.len(), 3);
98
99        // Verify with all disclosures
100        let verifier = SdJwtVerifier::new(&kp.public);
101        let claims = verifier.verify(&sd_jwt).unwrap();
102
103        assert_eq!(claims["iss"], "did:key:test");
104        assert_eq!(claims["name"], "Alice");
105        assert_eq!(claims["age"], 30);
106        assert_eq!(claims["email"], "alice@example.com");
107        // _sd and _sd_alg should be removed
108        assert!(claims.get("_sd").is_none());
109        assert!(claims.get("_sd_alg").is_none());
110    }
111
112    #[test]
113    fn wrong_key_rejected() {
114        let kp1 = KeyPair::generate(KeyType::Ed25519).unwrap();
115        let kp2 = KeyPair::generate(KeyType::Ed25519).unwrap();
116
117        let sd_jwt = SdJwtIssuer::new(&kp1, "kid")
118            .add_sd_claim("name", Value::String("Alice".to_string()))
119            .build()
120            .unwrap();
121
122        let verifier = SdJwtVerifier::new(&kp2.public);
123        assert!(verifier.verify(&sd_jwt).is_err());
124    }
125
126    #[test]
127    fn tampered_disclosure_rejected() {
128        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
129
130        let mut sd_jwt = SdJwtIssuer::new(&kp, "kid")
131            .add_sd_claim("name", Value::String("Alice".to_string()))
132            .build()
133            .unwrap();
134
135        // Tamper with the disclosure
136        let fake = crate::disclosure::Disclosure::new(
137            Some("name".to_string()),
138            Value::String("Eve".to_string()),
139        );
140        sd_jwt.disclosures = vec![fake.encode().unwrap()];
141
142        let verifier = SdJwtVerifier::new(&kp.public);
143        assert!(verifier.verify(&sd_jwt).is_err());
144    }
145
146    #[test]
147    fn selective_presentation() {
148        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
149
150        let sd_jwt = SdJwtIssuer::new(&kp, "kid")
151            .add_plain_claim("iss", Value::String("issuer".to_string()))
152            .add_sd_claim("name", Value::String("Alice".to_string()))
153            .add_sd_claim("age", Value::Number(30.into()))
154            .add_sd_claim("email", Value::String("alice@example.com".to_string()))
155            .build()
156            .unwrap();
157
158        // Present only 2 of the 3 disclosures (drop email)
159        let mut presented = sd_jwt.clone();
160        presented.disclosures.pop(); // remove the last disclosure (email)
161
162        let verifier = SdJwtVerifier::new(&kp.public);
163        let claims = verifier.verify(&presented).unwrap();
164
165        assert_eq!(claims["iss"], "issuer");
166        assert_eq!(claims["name"], "Alice");
167        assert_eq!(claims["age"], 30);
168        // email should NOT be present since its disclosure was withheld
169        assert!(claims.get("email").is_none());
170    }
171
172    #[test]
173    fn compact_serialization_roundtrip() {
174        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
175
176        let sd_jwt = SdJwtIssuer::new(&kp, "kid")
177            .add_sd_claim("name", Value::String("Alice".to_string()))
178            .build()
179            .unwrap();
180
181        let compact = sd_jwt.serialize();
182        let parsed = SdJwt::parse(&compact).unwrap();
183
184        let verifier = SdJwtVerifier::new(&kp.public);
185        let claims = verifier.verify(&parsed).unwrap();
186        assert_eq!(claims["name"], "Alice");
187    }
188}