1use crate::crypto::{KeyPair, PublicKey, SignatureBytes};
28use base64::engine::general_purpose::URL_SAFE_NO_PAD;
29use base64::Engine;
30use rand::RngCore;
31use serde_json::{json, Map, Value};
32use sha2::{Digest, Sha256};
33
34fn b64(bytes: &[u8]) -> String {
35 URL_SAFE_NO_PAD.encode(bytes)
36}
37fn unb64(s: &str) -> Result<Vec<u8>, String> {
38 URL_SAFE_NO_PAD.decode(s).map_err(|e| e.to_string())
39}
40
41#[derive(Clone, Debug)]
43pub struct Disclosure {
44 pub salt: String,
45 pub name: String,
46 pub value: Value,
47}
48
49impl Disclosure {
50 pub fn new(name: impl Into<String>, value: Value) -> Self {
51 let mut salt_bytes = [0u8; 16];
52 rand::thread_rng().fill_bytes(&mut salt_bytes);
53 Self { salt: b64(&salt_bytes), name: name.into(), value }
54 }
55
56 pub fn with_salt(salt: impl Into<String>, name: impl Into<String>, value: Value) -> Self {
58 Self { salt: salt.into(), name: name.into(), value }
59 }
60
61 pub fn encoded(&self) -> String {
63 let arr = json!([self.salt, self.name, self.value]);
64 b64(serde_json::to_string(&arr).expect("disclosure serializes").as_bytes())
65 }
66
67 pub fn digest(&self) -> String {
69 let enc = self.encoded();
70 b64(&Sha256::digest(enc.as_bytes()))
71 }
72}
73
74pub struct SdJwtVc {
76 vct: String,
77 issuer: String,
78 typ: String,
79 iat: i64,
80 cnf_holder_pubkey: Option<[u8; 32]>,
82 plain: Map<String, Value>,
83 disclosures: Vec<Disclosure>,
84}
85
86impl SdJwtVc {
87 pub fn new(vct: impl Into<String>, issuer: impl Into<String>) -> Self {
89 Self {
90 vct: vct.into(),
91 issuer: issuer.into(),
92 typ: "vc+sd-jwt".to_string(),
96 iat: chrono::Utc::now().timestamp(),
97 cnf_holder_pubkey: None,
98 plain: Map::new(),
99 disclosures: Vec::new(),
100 }
101 }
102
103 pub fn typ(mut self, typ: impl Into<String>) -> Self {
104 self.typ = typ.into();
105 self
106 }
107 pub fn iat(mut self, iat: i64) -> Self {
109 self.iat = iat;
110 self
111 }
112 pub fn holder_binding(mut self, holder_pubkey: &PublicKey) -> Self {
116 self.cnf_holder_pubkey = Some(holder_pubkey.to_bytes());
117 self
118 }
119
120 pub fn claim(mut self, name: impl Into<String>, value: Value) -> Self {
122 self.plain.insert(name.into(), value);
123 self
124 }
125
126 pub fn sd_claim(mut self, name: impl Into<String>, value: Value) -> Self {
129 self.disclosures.push(Disclosure::new(name, value));
130 self
131 }
132
133 pub fn sd_claim_salted(
135 mut self,
136 salt: impl Into<String>,
137 name: impl Into<String>,
138 value: Value,
139 ) -> Self {
140 self.disclosures.push(Disclosure::with_salt(salt, name, value));
141 self
142 }
143
144 pub fn prepare(&self, kid: &str) -> UnsignedSdJwtVc {
152 let mut digests: Vec<String> = self.disclosures.iter().map(|d| d.digest()).collect();
154 digests.sort();
155
156 let mut payload = self.plain.clone();
157 payload.insert("iss".into(), json!(self.issuer));
158 payload.insert("vct".into(), json!(self.vct));
159 payload.insert("iat".into(), json!(self.iat));
160 payload.insert("_sd_alg".into(), json!("sha-256"));
161 payload.insert("_sd".into(), json!(digests));
162 if let Some(pk) = &self.cnf_holder_pubkey {
163 payload.insert("cnf".into(), json!({
164 "jwk": { "kty": "OKP", "crv": "Ed25519", "x": b64(pk) }
165 }));
166 }
167
168 let header = json!({ "alg": "EdDSA", "typ": self.typ, "kid": kid });
169 let header_b64 = b64(serde_json::to_string(&header).unwrap().as_bytes());
170 let payload_b64 = b64(serde_json::to_string(&Value::Object(payload)).unwrap().as_bytes());
171 UnsignedSdJwtVc {
172 signing_input: format!("{header_b64}.{payload_b64}"),
173 encoded_disclosures: self.disclosures.iter().map(|d| d.encoded()).collect(),
174 }
175 }
176
177 pub fn issue(&self, issuer_key: &KeyPair, kid: &str) -> String {
180 let unsigned = self.prepare(kid);
181 let sig = issuer_key.sign(unsigned.signing_bytes());
182 unsigned.into_compact(&sig.bytes)
183 }
184}
185
186pub struct UnsignedSdJwtVc {
190 signing_input: String,
192 encoded_disclosures: Vec<String>,
194}
195
196impl UnsignedSdJwtVc {
197 pub fn signing_bytes(&self) -> &[u8] {
199 self.signing_input.as_bytes()
200 }
201
202 pub fn into_compact(self, signature: &[u8; 64]) -> String {
205 let mut out = format!("{}.{}", self.signing_input, b64(signature));
207 for d in &self.encoded_disclosures {
208 out.push('~');
209 out.push_str(d);
210 }
211 out.push('~');
212 out
213 }
214}
215
216#[derive(Clone, Debug)]
219pub struct VerifiedCredential {
220 pub vct: String,
221 pub issuer: String,
222 pub claims: Map<String, Value>,
223}
224
225pub fn verify_issuer(compact: &str, issuer_pubkey: &PublicKey) -> Result<VerifiedCredential, String> {
234 let mut parts = compact.split('~');
235 let jws = parts.next().ok_or("empty credential")?;
236 let disclosures: Vec<&str> = parts.filter(|p| !p.is_empty()).collect();
239
240 let jp: Vec<&str> = jws.split('.').collect();
242 if jp.len() != 3 {
243 return Err("malformed JWS".into());
244 }
245 let signing_input = format!("{}.{}", jp[0], jp[1]);
246 let sig_raw = unb64(jp[2])?;
247 let sig_arr: [u8; 64] = sig_raw.as_slice().try_into().map_err(|_| "bad sig length")?;
248 issuer_pubkey
249 .verify(signing_input.as_bytes(), &SignatureBytes::from_bytes(sig_arr))
250 .map_err(|_| "issuer signature invalid".to_string())?;
251
252 let payload: Value = serde_json::from_slice(&unb64(jp[1])?).map_err(|e| e.to_string())?;
254 let obj = payload.as_object().ok_or("payload not an object")?;
255 let sd: Vec<String> = obj
256 .get("_sd")
257 .and_then(|v| v.as_array())
258 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
259 .unwrap_or_default();
260
261 let mut claims = Map::new();
263 for (k, v) in obj {
264 if !matches!(k.as_str(), "_sd" | "_sd_alg") {
265 claims.insert(k.clone(), v.clone());
266 }
267 }
268
269 for enc in disclosures {
271 let digest = b64(&Sha256::digest(enc.as_bytes()));
272 if !sd.contains(&digest) {
273 return Err(format!("disclosure digest not in _sd (tamper?): {enc}"));
274 }
275 let raw = unb64(enc)?;
276 let arr: Value = serde_json::from_slice(&raw).map_err(|e| e.to_string())?;
277 let a = arr.as_array().ok_or("disclosure not an array")?;
278 if a.len() != 3 {
279 return Err("object disclosure must be [salt, name, value]".into());
280 }
281 let name = a[1].as_str().ok_or("disclosure name not a string")?;
282 claims.insert(name.to_string(), a[2].clone());
283 }
284
285 Ok(VerifiedCredential {
286 vct: obj.get("vct").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
287 issuer: obj.get("iss").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
288 claims,
289 })
290}
291
292pub fn present(
303 issued_compact: &str,
304 holder_key: &KeyPair,
305 nonce: &str,
306 aud: &str,
307 now: i64,
308 disclose: Option<&[&str]>,
309) -> Result<String, String> {
310 let mut it = issued_compact.split('~');
311 let jws = it.next().ok_or("empty credential")?;
312 let all: Vec<&str> = it.filter(|p| !p.is_empty()).collect();
313
314 let kept: Vec<&str> = match disclose {
316 None => all,
317 Some(names) => all
318 .into_iter()
319 .filter(|enc| {
320 unb64(enc)
321 .ok()
322 .and_then(|r| serde_json::from_slice::<Value>(&r).ok())
323 .and_then(|a| a.as_array().and_then(|a| a.get(1).and_then(|n| n.as_str()).map(String::from)))
324 .map(|name| names.contains(&name.as_str()))
325 .unwrap_or(false)
326 })
327 .collect(),
328 };
329
330 let mut prefix = jws.to_string();
332 for d in &kept {
333 prefix.push('~');
334 prefix.push_str(d);
335 }
336 prefix.push('~');
337
338 let sd_hash = b64(&Sha256::digest(prefix.as_bytes()));
340 let kb_header = json!({ "alg": "EdDSA", "typ": "kb+jwt" });
341 let kb_payload = json!({ "iat": now, "aud": aud, "nonce": nonce, "sd_hash": sd_hash });
342 let kh = b64(serde_json::to_string(&kb_header).unwrap().as_bytes());
343 let kp_b64 = b64(serde_json::to_string(&kb_payload).unwrap().as_bytes());
344 let kb_signing = format!("{kh}.{kp_b64}");
345 let kb_sig = holder_key.sign(kb_signing.as_bytes());
346 let kb_jwt = format!("{kb_signing}.{}", b64(&kb_sig.bytes));
347
348 Ok(format!("{prefix}{kb_jwt}"))
349}
350
351pub fn verify_presentation(
355 compact: &str,
356 issuer_pubkey: &PublicKey,
357 expected_nonce: &str,
358 expected_aud: &str,
359 max_age_secs: i64,
360 now: i64,
361) -> Result<VerifiedCredential, String> {
362 let last_tilde = compact.rfind('~').ok_or("no key-binding JWT (missing '~')")?;
365 let prefix = &compact[..=last_tilde];
366 let kb_jwt = &compact[last_tilde + 1..];
367 if kb_jwt.is_empty() {
368 return Err("presentation is missing the Key-Binding JWT".into());
369 }
370
371 let cred = verify_issuer(prefix, issuer_pubkey)?;
373
374 let payload_b64 = compact.split('~').next().unwrap().split('.').nth(1).ok_or("malformed JWS")?;
376 let payload: Value = serde_json::from_slice(&unb64(payload_b64)?).map_err(|e| e.to_string())?;
377 let x = payload
378 .get("cnf").and_then(|c| c.get("jwk")).and_then(|j| j.get("x")).and_then(|x| x.as_str())
379 .ok_or("credential has no cnf holder key — not presentable with key binding")?;
380 let holder_raw = unb64(x)?;
381 let holder_arr: [u8; 32] = holder_raw.as_slice().try_into().map_err(|_| "bad holder key length")?;
382 let holder_pk = PublicKey::from_bytes(&holder_arr).map_err(|e| e.to_string())?;
383
384 let kp: Vec<&str> = kb_jwt.split('.').collect();
386 if kp.len() != 3 {
387 return Err("malformed Key-Binding JWT".into());
388 }
389 let kb_signing = format!("{}.{}", kp[0], kp[1]);
390 let kb_sig_raw = unb64(kp[2])?;
391 let kb_sig_arr: [u8; 64] = kb_sig_raw.as_slice().try_into().map_err(|_| "bad KB sig length")?;
392 holder_pk
393 .verify(kb_signing.as_bytes(), &SignatureBytes::from_bytes(kb_sig_arr))
394 .map_err(|_| "Key-Binding JWT signature invalid (holder key mismatch)".to_string())?;
395
396 let kb: Value = serde_json::from_slice(&unb64(kp[1])?).map_err(|e| e.to_string())?;
397 if kb.get("nonce").and_then(|v| v.as_str()) != Some(expected_nonce) {
398 return Err("KB-JWT nonce mismatch (replay?)".into());
399 }
400 if kb.get("aud").and_then(|v| v.as_str()) != Some(expected_aud) {
401 return Err("KB-JWT audience mismatch (credential meant for another verifier)".into());
402 }
403 let iat = kb.get("iat").and_then(|v| v.as_i64()).ok_or("KB-JWT missing iat")?;
404 if now.saturating_sub(iat) > max_age_secs {
405 return Err("KB-JWT expired".into());
406 }
407 let expected_sd_hash = b64(&Sha256::digest(prefix.as_bytes()));
408 if kb.get("sd_hash").and_then(|v| v.as_str()) != Some(expected_sd_hash.as_str()) {
409 return Err("KB-JWT sd_hash does not match the presented disclosures".into());
410 }
411
412 Ok(cred)
413}
414
415pub fn web4_presence_credential(
421 issuer_did: &str,
422 subject_did: &str,
423 assurance_level: &str,
424) -> SdJwtVc {
425 SdJwtVc::new("Web4Presence", issuer_did)
426 .claim("sub", json!(subject_did))
427 .sd_claim("assurance_level", json!(assurance_level))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn external_signer_path_matches_issue() {
436 let issuer = KeyPair::generate();
439 let kid = "did:web4:hub.example:abc#key-0";
440 let builder = SdJwtVc::new("Web4Membership", "did:web4:hub.example:abc")
441 .iat(1_700_000_000)
442 .claim("sub", json!("did:web4:hub.example:member"))
443 .sd_claim_salted("s1", "role", json!("citizen"));
444
445 let in_process = builder.issue(&issuer, kid);
446
447 let unsigned = builder.prepare(kid);
449 let sig = issuer.sign(unsigned.signing_bytes());
450 let external = unsigned.into_compact(&sig.bytes);
451
452 assert_eq!(in_process, external);
453 verify_issuer(&external, &issuer.verifying_key()).expect("external-signed must verify");
455 }
456
457 #[test]
458 fn test_issue_and_verify_roundtrip() {
459 let issuer = KeyPair::generate();
460 let pk = issuer.verifying_key();
461
462 let compact = SdJwtVc::new("Web4Presence", "did:web4:hub.example.com:abc")
463 .iat(1_700_000_000)
464 .claim("sub", json!("did:web4:hub.example.com:def"))
465 .sd_claim_salted("salt1", "assurance_level", json!("multi_device"))
466 .sd_claim_salted("salt2", "github", json!("https://github.com/dp-web4"))
467 .issue(&issuer, "did:web4:hub.example.com:abc#key-0");
468
469 let v = verify_issuer(&compact, &pk).expect("must verify");
470 assert_eq!(v.vct, "Web4Presence");
471 assert_eq!(v.issuer, "did:web4:hub.example.com:abc");
472 assert_eq!(v.claims.get("sub").unwrap(), &json!("did:web4:hub.example.com:def"));
474 assert_eq!(v.claims.get("assurance_level").unwrap(), &json!("multi_device"));
476 assert_eq!(v.claims.get("github").unwrap(), &json!("https://github.com/dp-web4"));
477 }
478
479 #[test]
480 fn test_selective_disclosure() {
481 let issuer = KeyPair::generate();
482 let pk = issuer.verifying_key();
483 let compact = SdJwtVc::new("Web4Presence", "did:iss")
484 .sd_claim_salted("s1", "assurance_level", json!("hardware_backed"))
485 .sd_claim_salted("s2", "email", json!("dp@metalinxx.io"))
486 .issue(&issuer, "did:iss#key-0");
487
488 let jws_and_first = {
490 let mut it = compact.split('~');
491 let jws = it.next().unwrap();
492 let discs: Vec<&str> = it.filter(|p| !p.is_empty()).collect();
494 let keep = discs.iter().find(|d| {
495 let raw = unb64(d).unwrap();
496 let a: Value = serde_json::from_slice(&raw).unwrap();
497 a[1] == json!("assurance_level")
498 }).unwrap();
499 format!("{jws}~{keep}~")
500 };
501
502 let v = verify_issuer(&jws_and_first, &pk).expect("partial presentation verifies");
503 assert_eq!(v.claims.get("assurance_level").unwrap(), &json!("hardware_backed"));
504 assert!(!v.claims.contains_key("email")); }
506
507 #[test]
508 fn test_wrong_issuer_key_rejected() {
509 let issuer = KeyPair::generate();
510 let imposter = KeyPair::generate();
511 let compact = SdJwtVc::new("Web4Presence", "did:iss")
512 .sd_claim_salted("s1", "x", json!(1))
513 .issue(&issuer, "did:iss#key-0");
514 assert!(verify_issuer(&compact, &imposter.verifying_key()).is_err());
515 }
516
517 #[test]
518 fn test_tampered_disclosure_rejected() {
519 let issuer = KeyPair::generate();
520 let pk = issuer.verifying_key();
521 let compact = SdJwtVc::new("Web4Presence", "did:iss")
522 .sd_claim_salted("s1", "assurance_level", json!("single_device"))
523 .issue(&issuer, "did:iss#key-0");
524
525 let forged = Disclosure::with_salt("s1", "assurance_level", json!("hardware_backed")).encoded();
527 let jws = compact.split('~').next().unwrap();
528 let tampered = format!("{jws}~{forged}~");
529 assert!(verify_issuer(&tampered, &pk).is_err());
531 }
532
533 #[test]
534 fn test_holder_binding_jwk_in_payload() {
535 let issuer = KeyPair::generate();
536 let holder = KeyPair::generate();
537 let compact = SdJwtVc::new("Web4Presence", "did:iss")
538 .holder_binding(&holder.verifying_key())
539 .claim("sub", json!("did:holder"))
540 .issue(&issuer, "did:iss#key-0");
541 let payload_b64 = compact.split('~').next().unwrap().split('.').nth(1).unwrap();
542 let payload: Value = serde_json::from_slice(&unb64(payload_b64).unwrap()).unwrap();
543 assert_eq!(payload["cnf"]["jwk"]["kty"], json!("OKP"));
544 assert_eq!(payload["cnf"]["jwk"]["crv"], json!("Ed25519"));
545 assert!(payload["cnf"]["jwk"]["x"].is_string());
546 }
547
548 #[test]
549 fn test_present_and_verify_presentation() {
550 let issuer = KeyPair::generate();
551 let holder = KeyPair::generate();
552 let issued = SdJwtVc::new("Web4Presence", "did:iss")
553 .holder_binding(&holder.verifying_key())
554 .claim("sub", json!("did:holder"))
555 .sd_claim_salted("s1", "assurance_level", json!("multi_device"))
556 .sd_claim_salted("s2", "email", json!("dp@metalinxx.io"))
557 .issue(&issuer, "did:iss#key-0");
558
559 let vp = present(&issued, &holder, "nonce-xyz", "did:verifier", 1_700_000_000,
562 Some(&["assurance_level"])).unwrap();
563
564 let v = verify_presentation(&vp, &issuer.verifying_key(),
565 "nonce-xyz", "did:verifier", 300, 1_700_000_100).unwrap();
566 assert_eq!(v.claims.get("assurance_level").unwrap(), &json!("multi_device"));
567 assert!(!v.claims.contains_key("email")); assert!(v.claims.contains_key("sub")); }
570
571 #[test]
572 fn test_presentation_nonce_mismatch_rejected() {
573 let issuer = KeyPair::generate();
574 let holder = KeyPair::generate();
575 let issued = SdJwtVc::new("Web4Presence", "did:iss")
576 .holder_binding(&holder.verifying_key())
577 .sd_claim_salted("s1", "x", json!(1))
578 .issue(&issuer, "did:iss#key-0");
579 let vp = present(&issued, &holder, "good-nonce", "did:v", 1000, None).unwrap();
580 assert!(verify_presentation(&vp, &issuer.verifying_key(), "bad-nonce", "did:v", 300, 1000).is_err());
582 assert!(verify_presentation(&vp, &issuer.verifying_key(), "good-nonce", "other-v", 300, 1000).is_err());
584 }
585
586 #[test]
587 fn test_presentation_wrong_holder_key_rejected() {
588 let issuer = KeyPair::generate();
589 let holder = KeyPair::generate();
590 let imposter = KeyPair::generate();
591 let issued = SdJwtVc::new("Web4Presence", "did:iss")
592 .holder_binding(&holder.verifying_key())
593 .sd_claim_salted("s1", "x", json!(1))
594 .issue(&issuer, "did:iss#key-0");
595 let vp = present(&issued, &imposter, "n", "did:v", 1000, None).unwrap();
597 assert!(verify_presentation(&vp, &issuer.verifying_key(), "n", "did:v", 300, 1000).is_err());
598 }
599
600 #[test]
601 fn test_web4_presence_helper() {
602 let issuer = KeyPair::generate();
603 let pk = issuer.verifying_key();
604 let compact = web4_presence_credential("did:iss", "did:sub", "multi_device")
605 .issue(&issuer, "did:iss#key-0");
606 let v = verify_issuer(&compact, &pk).unwrap();
607 assert_eq!(v.vct, "Web4Presence");
608 assert_eq!(v.claims.get("sub").unwrap(), &json!("did:sub"));
609 assert_eq!(v.claims.get("assurance_level").unwrap(), &json!("multi_device"));
610 }
611}