baseid_oid4vci/
proof.rs

1//! Proof-of-possession JWT creation for OID4VCI credential requests.
2
3use baseid_crypto::{alg_to_str, encode_jwt, JwtHeader, Signer};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6/// Create a proof-of-possession JWT for an OID4VCI credential request.
7///
8/// The JWT proves the holder controls the private key associated with `did`.
9///
10/// Header: `{ "typ": "openid4vci-proof+jwt", "alg": "<alg>", "kid": "<did>#key-1" }`
11/// Payload: `{ "iss": "<did>", "aud": "<issuer_url>", "iat": <now>, "nonce": "<c_nonce>" }`
12pub fn create_proof_jwt(
13    signer: &dyn Signer,
14    issuer_url: &str,
15    c_nonce: &str,
16    did: &str,
17) -> baseid_core::Result<String> {
18    let alg_str = alg_to_str(signer.algorithm());
19
20    let header = JwtHeader {
21        alg: alg_str.to_string(),
22        typ: Some("openid4vci-proof+jwt".to_string()),
23        kid: Some(format!("{did}#key-1")),
24        additional: serde_json::Map::new(),
25    };
26
27    let iat = SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .unwrap_or_default()
30        .as_secs();
31
32    let claims = serde_json::json!({
33        "iss": did,
34        "aud": issuer_url,
35        "iat": iat,
36        "nonce": c_nonce,
37    });
38
39    encode_jwt(&header, &claims, signer)
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use baseid_core::types::KeyType;
46    use baseid_crypto::{decode_jwt_unverified, KeyPair};
47
48    #[test]
49    fn proof_jwt_structure() {
50        let kp = KeyPair::generate(KeyType::P256).unwrap();
51        let jwt = create_proof_jwt(
52            &kp,
53            "https://issuer.example.com",
54            "server-nonce-123",
55            "did:key:zDnae...",
56        )
57        .unwrap();
58
59        let (header, claims) = decode_jwt_unverified(&jwt).unwrap();
60
61        assert_eq!(header.typ.as_deref(), Some("openid4vci-proof+jwt"));
62        assert_eq!(header.alg, "ES256");
63        assert_eq!(header.kid.as_deref(), Some("did:key:zDnae...#key-1"));
64        assert_eq!(claims["iss"], "did:key:zDnae...");
65        assert_eq!(claims["aud"], "https://issuer.example.com");
66        assert_eq!(claims["nonce"], "server-nonce-123");
67        assert!(claims["iat"].is_u64());
68    }
69
70    #[test]
71    fn proof_jwt_ed25519() {
72        let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
73        let jwt = create_proof_jwt(
74            &kp,
75            "https://issuer.example.com",
76            "nonce",
77            "did:key:z6Mk...",
78        )
79        .unwrap();
80
81        let (header, _) = decode_jwt_unverified(&jwt).unwrap();
82        assert_eq!(header.alg, "EdDSA");
83    }
84
85    // --- Phase C: Cross-library interop tests (proof JWT with jsonwebtoken crate) ---
86
87    mod interop {
88        use super::*;
89        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
90        use base64::Engine as _;
91
92        /// Sign a proof JWT with baseid, verify with jsonwebtoken (Ed25519).
93        #[test]
94        fn baseid_proof_jwt_jsonwebtoken_verify_ed25519() {
95            let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
96            let jwt = create_proof_jwt(
97                &kp,
98                "https://issuer.example.com",
99                "nonce-abc",
100                "did:key:z6MkTest",
101            )
102            .unwrap();
103
104            // Build jsonwebtoken DecodingKey from raw Ed25519 public key bytes
105            let x_b64 = URL_SAFE_NO_PAD.encode(&kp.public.bytes);
106            let dk = jsonwebtoken::DecodingKey::from_ed_components(&x_b64).unwrap();
107
108            let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
109            validation.set_required_spec_claims::<String>(&[]);
110            validation.validate_exp = false;
111            validation.validate_aud = false;
112
113            let token_data: jsonwebtoken::TokenData<serde_json::Value> =
114                jsonwebtoken::decode(&jwt, &dk, &validation).unwrap();
115
116            assert_eq!(token_data.claims["iss"], "did:key:z6MkTest");
117            assert_eq!(token_data.claims["aud"], "https://issuer.example.com");
118            assert_eq!(token_data.claims["nonce"], "nonce-abc");
119            assert!(token_data.claims["iat"].is_u64());
120
121            // Verify header fields match OID4VCI spec requirements
122            assert_eq!(
123                token_data.header.typ,
124                Some("openid4vci-proof+jwt".to_string())
125            );
126            assert_eq!(token_data.header.alg, jsonwebtoken::Algorithm::EdDSA);
127            assert_eq!(
128                token_data.header.kid,
129                Some("did:key:z6MkTest#key-1".to_string())
130            );
131        }
132
133        /// Sign a proof JWT with baseid, verify with jsonwebtoken (P-256).
134        #[test]
135        fn baseid_proof_jwt_jsonwebtoken_verify_p256() {
136            let kp = KeyPair::generate(KeyType::P256).unwrap();
137            let jwt = create_proof_jwt(
138                &kp,
139                "https://issuer.example.com",
140                "nonce-xyz",
141                "did:key:zDnaePK",
142            )
143            .unwrap();
144
145            // Decompress P-256 public key to get x,y coordinates
146            let (x, y) = decompress_p256(&kp.public.bytes);
147            let x_b64 = URL_SAFE_NO_PAD.encode(&x);
148            let y_b64 = URL_SAFE_NO_PAD.encode(&y);
149
150            let dk = jsonwebtoken::DecodingKey::from_ec_components(&x_b64, &y_b64).unwrap();
151            let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
152            validation.set_required_spec_claims::<String>(&[]);
153            validation.validate_exp = false;
154            validation.validate_aud = false;
155
156            let token_data: jsonwebtoken::TokenData<serde_json::Value> =
157                jsonwebtoken::decode(&jwt, &dk, &validation).unwrap();
158
159            assert_eq!(token_data.claims["iss"], "did:key:zDnaePK");
160            assert_eq!(token_data.claims["aud"], "https://issuer.example.com");
161            assert_eq!(token_data.claims["nonce"], "nonce-xyz");
162
163            // Verify header fields match OID4VCI spec
164            assert_eq!(
165                token_data.header.typ,
166                Some("openid4vci-proof+jwt".to_string())
167            );
168            assert_eq!(token_data.header.alg, jsonwebtoken::Algorithm::ES256);
169        }
170
171        /// Verify proof JWT header fields comply with OID4VCI spec via unverified decode.
172        #[test]
173        fn baseid_proof_jwt_header_spec_compliance() {
174            for key_type in [KeyType::Ed25519, KeyType::P256] {
175                let kp = KeyPair::generate(key_type).unwrap();
176                let jwt = create_proof_jwt(
177                    &kp,
178                    "https://issuer.example.com",
179                    "server-nonce",
180                    "did:key:zTest",
181                )
182                .unwrap();
183
184                // Decode header without verification (like a foreign library would)
185                let parts: Vec<&str> = jwt.split('.').collect();
186                assert_eq!(parts.len(), 3, "JWT must have 3 parts");
187
188                let header_bytes = URL_SAFE_NO_PAD.decode(parts[0]).unwrap();
189                let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
190
191                // OID4VCI 1.0 Section 7.2.1: typ MUST be "openid4vci-proof+jwt"
192                assert_eq!(header["typ"], "openid4vci-proof+jwt");
193
194                // alg must be a valid JWA algorithm
195                let alg = header["alg"].as_str().unwrap();
196                assert!(
197                    ["EdDSA", "ES256", "ES384", "ES256K"].contains(&alg),
198                    "Unexpected alg: {alg}"
199                );
200
201                // kid must be present and include fragment
202                let kid = header["kid"].as_str().unwrap();
203                assert!(
204                    kid.contains('#'),
205                    "kid must contain '#' fragment separator: {kid}"
206                );
207                assert!(kid.starts_with("did:"), "kid must start with 'did:': {kid}");
208            }
209        }
210
211        /// Decompress a P-256 compressed public key to (x, y).
212        fn decompress_p256(compressed: &[u8]) -> (Vec<u8>, Vec<u8>) {
213            use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
214            let point = p256::EncodedPoint::from_bytes(compressed).unwrap();
215            let affine = p256::AffinePoint::from_encoded_point(&point).unwrap();
216            let uncompressed = affine.to_encoded_point(false);
217            (
218                uncompressed.x().unwrap().to_vec(),
219                uncompressed.y().unwrap().to_vec(),
220            )
221        }
222    }
223}