Skip to main content

web4_core/
sd_jwt_vc.rs

1// Copyright (c) 2026 MetaLINXX Inc.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//
4// This software is covered by US Patents 11,477,027 and 12,278,913,
5// and pending application 19/178,619. See PATENTS.md for details.
6
7//! SD-JWT-VC issuance — Web4 attestations as selectively-disclosable
8//! Verifiable Credentials (EUDI / IETF interop, Phase 1).
9//!
10//! Implements the issuance half of IETF SD-JWT (`draft-ietf-oauth-
11//! selective-disclosure-jwt`) + SD-JWT-VC (`draft-ietf-oauth-sd-jwt-vc`):
12//! a JWT whose selected claims are replaced by salted digests (`_sd`), with the
13//! cleartext carried in detached *disclosures*. The holder presents only the
14//! disclosures it chooses; the issuer signature still verifies.
15//!
16//! The issuer is an LCT (identified by a `did:web4` / `did:web`), signing with
17//! its Ed25519 key (`EdDSA` JWS). This is the bridge from a Web4 attestation
18//! into a wallet-consumable credential — lossy by design (the rich witness
19//! structure collapses to signed claims; see `docs/strategy/
20//! eudi-resolvability-plan.md`).
21//!
22//! Generic and public-eligible: no novel mechanism, just the standard format.
23//!
24//! Compact serialization: `<JWS>~<disclosure>~<disclosure>~` (trailing `~`;
25//! a holder Key-Binding JWT is appended after the last `~` at presentation).
26
27use 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/// A salted disclosure for one object claim: `[salt, name, value]`.
42#[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    /// Deterministic salt — for tests and reproducible issuance.
57    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    /// The base64url(JSON `[salt, name, value]`) disclosure string.
62    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    /// The `_sd` digest: base64url(SHA-256(ASCII(encoded))).
68    pub fn digest(&self) -> String {
69        let enc = self.encoded();
70        b64(&Sha256::digest(enc.as_bytes()))
71    }
72}
73
74/// Builder for an SD-JWT-VC.
75pub struct SdJwtVc {
76    vct: String,
77    issuer: String,
78    typ: String,
79    iat: i64,
80    /// Optional holder key binding (`cnf`) — the holder's Ed25519 public key.
81    cnf_holder_pubkey: Option<[u8; 32]>,
82    plain: Map<String, Value>,
83    disclosures: Vec<Disclosure>,
84}
85
86impl SdJwtVc {
87    /// `vct` = credential type (e.g. "Web4Presence"); `issuer` = the issuer DID.
88    pub fn new(vct: impl Into<String>, issuer: impl Into<String>) -> Self {
89        Self {
90            vct: vct.into(),
91            issuer: issuer.into(),
92            // The IETF SD-JWT-VC draft is renaming `vc+sd-jwt` → `dc+sd-jwt`
93            // ("Digital Credential"). `vc+sd-jwt` is the most widely deployed
94            // value; override with `.typ()` for newer verifiers.
95            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    /// Override issued-at (unix seconds) — for reproducible issuance/tests.
108    pub fn iat(mut self, iat: i64) -> Self {
109        self.iat = iat;
110        self
111    }
112    /// Bind the credential to a holder key (`cnf`). The holder must later sign a
113    /// Key-Binding JWT with the matching private key to present (see `present`).
114    /// Emitted as a standard JWK (`OKP`/`Ed25519`).
115    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    /// An always-disclosed claim (appears in cleartext in the JWT payload).
121    pub fn claim(mut self, name: impl Into<String>, value: Value) -> Self {
122        self.plain.insert(name.into(), value);
123        self
124    }
125
126    /// A selectively-disclosable claim (replaced by a digest; cleartext in a
127    /// detached disclosure the holder may withhold).
128    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    /// SD claim with a fixed salt — reproducible issuance/tests.
134    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    /// Build the unsigned signing input (header + payload) and the disclosures,
145    /// for issuance through an *external* signer (HSM, remote vault, the Web4
146    /// hub's `RemoteSigner`) that never exposes a raw [`KeyPair`]. The signer
147    /// EdDSA-signs [`UnsignedSdJwtVc::signing_bytes`]; the resulting signature
148    /// is assembled via [`UnsignedSdJwtVc::into_compact`]. `issue` is the
149    /// in-process convenience over this. `kid` is the issuer verification
150    /// method id (e.g. `<did>#key-0`).
151    pub fn prepare(&self, kid: &str) -> UnsignedSdJwtVc {
152        // Digests, sorted so order doesn't leak insertion sequence.
153        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    /// Issue: build + sign the JWS and emit the compact SD-JWT-VC.
178    /// `kid` is the issuer verification method id (e.g. `<did>#key-0`).
179    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
186/// An SD-JWT-VC prepared but not yet signed. Lets an issuer sign through an
187/// external signer (HSM / remote vault / hub `RemoteSigner`) without exposing
188/// a private key. Produced by [`SdJwtVc::prepare`].
189pub struct UnsignedSdJwtVc {
190    /// The exact bytes to EdDSA-sign: `base64url(header).base64url(payload)`.
191    signing_input: String,
192    /// Disclosures (already base64url-encoded) appended after the JWS.
193    encoded_disclosures: Vec<String>,
194}
195
196impl UnsignedSdJwtVc {
197    /// The bytes the external signer must EdDSA-sign.
198    pub fn signing_bytes(&self) -> &[u8] {
199        self.signing_input.as_bytes()
200    }
201
202    /// Assemble the compact SD-JWT-VC from the 64-byte Ed25519 signature the
203    /// external signer returned over [`signing_bytes`](Self::signing_bytes).
204    pub fn into_compact(self, signature: &[u8; 64]) -> String {
205        // JWS ~ disclosures ~ (trailing tilde; KB-JWT slot left empty)
206        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/// The result of verifying an SD-JWT-VC: the issuer-signed claims, with the
217/// presented disclosures merged back in.
218#[derive(Clone, Debug)]
219pub struct VerifiedCredential {
220    pub vct: String,
221    pub issuer: String,
222    pub claims: Map<String, Value>,
223}
224
225/// Verify the issuer signature and reconstruct the disclosed claims.
226///
227/// - Verifies the EdDSA JWS against `issuer_pubkey`.
228/// - For each presented disclosure: recomputes its digest, requires it to be in
229///   the JWT's `_sd`, and merges `name → value` into the result.
230/// - Always-disclosed claims pass through. Withheld disclosures simply don't
231///   appear (selective disclosure). A disclosure whose digest isn't in `_sd`
232///   is rejected (tamper).
233pub 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    // remaining parts are disclosures; a trailing '~' yields a final empty part
237    // and the (unused here) KB-JWT slot.
238    let disclosures: Vec<&str> = parts.filter(|p| !p.is_empty()).collect();
239
240    // 1. JWS signature
241    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    // 2. payload
253    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    // 3. reconstruct claims: start from cleartext (minus protocol fields)
262    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    // 4. merge presented disclosures whose digest is in `_sd`
270    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
292// ─────────────────────── presentation (holder Key-Binding) ───────────────────────
293
294/// Present an issued SD-JWT-VC to a verifier: optionally narrow to a subset of
295/// disclosures, then append a holder-signed Key-Binding JWT bound to the
296/// verifier's `nonce` + `aud`. The credential MUST have been issued with a
297/// `cnf` holder binding (`holder_binding`).
298///
299/// `disclose` selects which claim names to reveal; `None` reveals all. The
300/// KB-JWT's `sd_hash` binds to *exactly* the presented disclosures, so a
301/// verifier knows which claims this presentation covers.
302pub 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    // Select disclosures to reveal.
315    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    // The presentation prefix: JWS ~ kept-disclosures ~ (trailing tilde).
331    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    // sd_hash binds the KB-JWT to this exact presentation.
339    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
351/// Verify a *presented* SD-JWT-VC: issuer signature + disclosure digests + the
352/// holder Key-Binding JWT (signed by the `cnf` key, bound to `expected_nonce` /
353/// `expected_aud`, fresh, and matching `sd_hash`). Returns the disclosed claims.
354pub 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    // The KB-JWT is the final '~'-segment (a 3-part JWT). Split it off; the
363    // prefix is everything up to and including the last '~'.
364    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    // 1. issuer side (sig + disclosures) over the prefix.
372    let cred = verify_issuer(prefix, issuer_pubkey)?;
373
374    // 2. holder key from cnf.jwk in the issuer-signed payload.
375    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    // 3. KB-JWT signature + claims.
385    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
415// ─────────────── Web4 credential helpers (the pattern, not the policy) ───────────────
416
417/// Build a `Web4Presence` SD-JWT-VC: an assurance attestation about a subject,
418/// with the assurance level selectively-disclosable. Inputs are primitives
419/// (no hestia/witness types) so web4-core stays dependency-clean.
420pub 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        // prepare()+into_compact() (external-signer path) must produce a
437        // byte-identical credential to issue() (in-process path).
438        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        // External path: hub holds no KeyPair; signs the prepared bytes.
448        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        // And it verifies under the issuer key.
454        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        // cleartext claim present
473        assert_eq!(v.claims.get("sub").unwrap(), &json!("did:web4:hub.example.com:def"));
474        // both SD claims reconstructed
475        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        // Holder presents ONLY the assurance disclosure, withholds email.
489        let jws_and_first = {
490            let mut it = compact.split('~');
491            let jws = it.next().unwrap();
492            // find the assurance disclosure (the one decoding to "assurance_level")
493            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")); // withheld
505    }
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        // Forge a disclosure claiming hardware_backed and splice it in.
526        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        // its digest won't be in _sd → rejected
530        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        // Holder presents to verifier "did:verifier" with nonce, revealing only
560        // assurance_level.
561        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")); // withheld at presentation
568        assert!(v.claims.contains_key("sub"));     // always-disclosed
569    }
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        // verifier expects a different nonce → reject (replay defense)
581        assert!(verify_presentation(&vp, &issuer.verifying_key(), "bad-nonce", "did:v", 300, 1000).is_err());
582        // wrong audience → reject
583        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        // imposter (not the cnf key) tries to present → KB-JWT sig won't match cnf
596        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}