bh_sd_jwt/key_binding.rs
1// Copyright (C) 2020-2026 The Blockhouse Technology Limited (TBTL).
2//
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Affero General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or (at your
6// option) any later version.
7//
8// This program is distributed in the hope that it will be useful, but
9// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
10// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
11// License for more details.
12//
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16use bh_jws_utils::{
17 jwt, JwkPublic, JwtSigner, JwtVerifier as _, SignatureVerifier, SigningAlgorithm,
18};
19use bherror::{
20 traits::{ForeignBoxed, ForeignError, PropagateError},
21 Error,
22};
23use jwt::claims::SecondsSinceEpoch;
24use serde::{Deserialize, Serialize};
25
26use crate::{
27 holder::{HolderError, Result as HolderResult},
28 sd_jwt::{SdJwt, SdJwtKB, SD_JWT_DELIMITER},
29 utils,
30 verifier::{Result as VerifierResult, VerifierError},
31 Hasher, Result,
32};
33
34/// Error type related to Key Binding `JWT` operations.
35#[derive(strum_macros::Display, PartialEq, Debug, Clone)]
36pub enum KBError {
37 /// Error representing a missing key binding in the `SD-JWT`.
38 #[strum(to_string = "Missing key binding")]
39 MissingKeyBinding,
40
41 /// Error when the Key Binding JWT syntax is invalid.
42 #[strum(to_string = "Invalid KBJwt syntax: {0}")]
43 InvalidKBJwtSyntax(String),
44
45 /// Error when the Key Binding JWT signature is invalid.
46 #[strum(to_string = "Invalid KBJwt signature")]
47 InvalidKBJwtSignature,
48
49 /// Error when the Key Binding JWT `typ` field is not set to the expected
50 /// value.
51 #[strum(to_string = "Invalid KBJwt type {0}")]
52 InvalidKBJwtType(String),
53
54 /// Error when the Key Binding JWT is expired.
55 #[strum(to_string = "KBJwt expired: iat is {0}, expiration offset {1} and current time {2}")]
56 KBJwtExpired(u64, u64, u64),
57
58 /// Error when the Key Binding JWT nonce is invalid.
59 #[strum(to_string = "Invalid KBJwt nonce. Provided nonce was {0}")]
60 InvalidKBJwtNonce(String),
61
62 /// Error when the Key Binding JWT `aud` field is invalid.
63 #[strum(to_string = "Invalid KBJwt aud. Provided aud was `{0}`; expected `{1}`")]
64 InvalidKBJwtAud(String, String),
65
66 /// Error when the Key Binding JWT `sd_hash` field is the wrong value.
67 #[strum(to_string = "Invalid KBJwt hash. Claims hash was {0}, provided was {1}")]
68 InvalidKBJwtSdHash(String, String),
69
70 /// Error when the provided signing algorithm does not have a signature
71 /// verifier implementation.
72 #[strum(to_string = "Missing signature verifier: {0}")]
73 MissingSignatureVerifier(SigningAlgorithm),
74}
75
76impl bherror::BhError for KBError {}
77
78/// The required value of the Key Binding `JWT` header `typ` element, as
79/// specified [here].
80///
81/// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.1.2.1
82pub(crate) const KB_JWT_HEADER_TYP: &str = "kb+jwt";
83
84/// A maximum difference of the time when the Key Binding `JWT` was received by
85/// the Verifier and the time when it was created by the Holder, expressed in
86/// seconds.
87///
88/// The current default is set to 5 minutes.
89// TODO(issues/51)
90pub(crate) const KB_JWT_EXPIRATION_OFFSET: SecondsSinceEpoch = 5 * 60;
91
92/// Header of the Key Binding `JWT`, as specified [here].
93///
94/// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.1.1
95#[derive(Debug, Serialize, Deserialize)]
96#[non_exhaustive]
97pub(crate) struct KBJwtHeader {
98 /// The Key Binding `JWT` type. The value of this attribute **MUST** always
99 /// be `kb+jwt`, as specified [here].
100 ///
101 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.1.2.1
102 pub(crate) typ: String,
103
104 /// A digital signature algorithm identifier, as specified [here].
105 ///
106 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.1.2.2
107 pub(crate) alg: SigningAlgorithm,
108}
109
110impl KBJwtHeader {
111 /// Constructs and returns a new Key Binding `JWT` header from the provided
112 /// [`SigningAlgorithm`](crate::traits::SigningAlgorithm).
113 ///
114 /// The `typ` attribute is always set to [`KB_JWT_HEADER_TYP`].
115 pub(crate) fn new(alg: SigningAlgorithm) -> Self {
116 Self {
117 typ: KB_JWT_HEADER_TYP.to_owned(),
118 alg,
119 }
120 }
121}
122
123impl jwt::JoseHeader for KBJwtHeader {
124 fn algorithm_type(&self) -> jwt::AlgorithmType {
125 self.alg.into()
126 }
127}
128
129/// Claims of the Key Binding `JWT`, as specified [here].
130///
131/// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.1
132#[derive(Debug, Serialize, Deserialize)]
133#[non_exhaustive]
134pub(crate) struct KBJwtClaims {
135 /// The time at which the Key Binding `JWT` was issued, as specified [here].
136 ///
137 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.1
138 pub(crate) iat: SecondsSinceEpoch,
139
140 /// The intended receiver of the Key Binding `JWT`, as specified [here].
141 ///
142 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.2
143 /// See also: [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)
144 pub(crate) aud: String,
145
146 /// A value used to ensure the freshness of the signature, as specified
147 /// [here].
148 ///
149 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.3
150 pub(crate) nonce: String,
151
152 /// The `base64url`-encoded hash digest over the Issuer-signed `JWT` and the
153 /// selected disclosures, as specified [here].
154 ///
155 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.4
156 pub(crate) sd_hash: String,
157}
158
159impl KBJwtClaims {
160 /// Constructs a new Key Binding `JWT` from its parameters.
161 ///
162 /// The `aud` field is created to hold only a single value provided within
163 /// the [`KeyBindingChallenge`].
164 pub(crate) fn new(
165 challenge: KeyBindingChallenge,
166 current_time: SecondsSinceEpoch,
167 sd_hash: String,
168 ) -> Self {
169 Self {
170 iat: current_time,
171 aud: challenge.aud,
172 nonce: challenge.nonce,
173 sd_hash,
174 }
175 }
176}
177
178/// The challenge to be sent to the holder. The purpose of the
179/// challenge is to ensure the freshness of the key binding signature, as
180/// well as the proper audience.
181#[derive(Debug, Clone)]
182pub struct KeyBindingChallenge {
183 /// The intended receiver of the Key Binding `JWT`, as specified [here].
184 ///
185 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.2
186 /// See also: [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)
187 pub aud: String,
188 /// A value used to ensure the freshness of the signature, as specified
189 /// [here].
190 ///
191 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-5.3-3.2.2.3
192 pub nonce: String,
193}
194
195pub(crate) struct KBJwt<Status>(jwt::Token<KBJwtHeader, KBJwtClaims, Status>);
196
197type KBJwtUnverified<'a> = jwt::Token<KBJwtHeader, KBJwtClaims, jwt::Unverified<'a>>;
198
199impl<Status> KBJwt<Status> {
200 pub(crate) fn header(&self) -> &KBJwtHeader {
201 self.0.header()
202 }
203
204 pub(crate) fn claims(&self) -> &KBJwtClaims {
205 self.0.claims()
206 }
207}
208
209impl KBJwt<jwt::token::Signed> {
210 /// Creates a new **signed** Key Binding `JWT` based on its claims, using
211 /// the provided [`Signer`] implementation to sign the payload.
212 pub(crate) fn new(claims: KBJwtClaims, signer: &impl JwtSigner) -> HolderResult<Self> {
213 let header = KBJwtHeader::new(signer.algorithm());
214
215 let token_unsigned = jwt::Token::new(header, claims);
216 let token_signed = signer
217 .sign_jwt(token_unsigned)
218 .foreign_boxed_err(|| HolderError::KBJwtSigningFailed)?;
219
220 Ok(KBJwt(token_signed))
221 }
222
223 /// Returns the `String` representation of the underlying **signed** `JWT`,
224 /// consuming `self`.
225 pub(crate) fn into_string(self) -> String {
226 self.0.into()
227 }
228}
229
230impl KBJwt<jwt::token::Verified> {
231 /// Parses and verifies a Key Binding `JWT` by verifying the signature
232 /// against the provided `holder_public_key` and using the given
233 /// [`Verifier`], and validating its header and claims.
234 pub(crate) fn validate<'a>(
235 kb_jwt: &str,
236 holder_public_key: &JwkPublic,
237 get_signature_verifier: impl FnOnce(SigningAlgorithm) -> Option<&'a dyn SignatureVerifier>,
238 challenge: &KeyBindingChallenge,
239 current_time: SecondsSinceEpoch,
240 sd_hash: &str,
241 ) -> Result<Self, KBError> {
242 // !!! Start of direct access to not-yet-integrity-verified fields
243 let (token_unverified, verifier) =
244 Self::get_signature_verifier(kb_jwt, get_signature_verifier)?;
245 // !!! End of direct access to not-yet-integrity-verified fields
246
247 let token_verified = verifier
248 .verify_jwt_signature(token_unverified, holder_public_key)
249 .foreign_boxed_err(|| KBError::InvalidKBJwtSignature)?;
250
251 let jwt = Self(token_verified);
252
253 jwt.validate_header()?;
254
255 jwt.validate_claims(challenge, current_time, sd_hash)?;
256
257 Ok(jwt)
258 }
259
260 /// Directly access not-yet-integrity-verified fields in order to look up the
261 /// signature verifier implementation.
262 ///
263 /// This is sound because:
264 ///
265 /// - The signature verifier implementations are all for known and
266 /// known-secure asymmetric signature algorithms, i.e. there is no
267 /// possibility of e.g. `alg: none`.
268 /// At worst the attacker could:
269 ///
270 /// - point us to a different secure algorithm which doesn't correspond
271 /// to the public key's and will as such result in a verification error
272 /// within `verify_with_key`.
273 fn get_signature_verifier<'a>(
274 kb_jwt: &str,
275 get_signature_verifier: impl FnOnce(SigningAlgorithm) -> Option<&'a dyn SignatureVerifier>,
276 ) -> Result<(KBJwtUnverified<'_>, &'a dyn SignatureVerifier), KBError> {
277 // Despite the documentation of `jwt::Token::parse_unverified`
278 // (rightfully) not recommending using it (to prevent reading the
279 // contents without prior verification), we need to do this in order to
280 // get access to the header's `alg` claim which we will need for
281 // the verifier implementation lookup.
282
283 let token_unverified: KBJwtUnverified = jwt::Token::parse_unverified(kb_jwt)
284 .foreign_err(|| KBError::InvalidKBJwtSyntax(kb_jwt.to_string()))?;
285
286 let signing_algorithm = token_unverified.header().alg;
287 let verifier = get_signature_verifier(signing_algorithm)
288 .ok_or_else(|| Error::root(KBError::MissingSignatureVerifier(signing_algorithm)))?;
289
290 Ok((token_unverified, verifier))
291 }
292
293 /// Validates the Key Binding `JWT` header.
294 ///
295 /// The header is valid if its `typ` field is set to `kb+jwt`.
296 fn validate_header(&self) -> Result<(), KBError> {
297 let header = self.header();
298
299 // check that `typ` is equal to `KB_JWT_HEADER_TYP`
300 if header.typ != KB_JWT_HEADER_TYP {
301 return Err(Error::root(KBError::InvalidKBJwtType(header.typ.clone())));
302 }
303
304 Ok(())
305 }
306
307 /// Validates the Key Binding `JWT` claims.
308 ///
309 /// The following validation steps are performed:
310 /// - `iat`: the creation time of the Key Binding `JWT` needs to be within
311 /// an acceptable time window,
312 /// - `nonce`: it needs to be the same as the one from the `challenge`,
313 /// - `aud`: it needs to contain the value from the `challenge`,
314 /// - `sd_hash`: it needs to be the same as the computed `sd_hash` value.
315 fn validate_claims(
316 &self,
317 challenge: &KeyBindingChallenge,
318 current_time: SecondsSinceEpoch,
319 sd_hash: &str,
320 ) -> Result<(), KBError> {
321 let claims = self.claims();
322
323 // check that `iat` >= `current_time` - `KB_JWT_EXPIRATION_OFFSET`
324 if claims.iat + KB_JWT_EXPIRATION_OFFSET < current_time {
325 return Err(Error::root(KBError::KBJwtExpired(
326 claims.iat,
327 KB_JWT_EXPIRATION_OFFSET,
328 current_time,
329 )));
330 }
331
332 // check that `nonce` is equal to the one from the `challenge`
333 if claims.nonce != challenge.nonce {
334 return Err(Error::root(KBError::InvalidKBJwtNonce(
335 claims.nonce.clone(),
336 )));
337 }
338
339 // check that `aud` contains the `aud` from the challenge
340 if claims.aud != challenge.aud {
341 return Err(Error::root(KBError::InvalidKBJwtAud(
342 claims.aud.clone(),
343 challenge.aud.clone(),
344 )));
345 }
346
347 // check that `sd_hash` is equal to the one provided
348 if claims.sd_hash != sd_hash {
349 return Err(Error::root(KBError::InvalidKBJwtSdHash(
350 claims.sd_hash.clone(),
351 sd_hash.to_string(),
352 )));
353 }
354
355 Ok(())
356 }
357}
358
359impl SdJwt {
360 /// Constructs and signs the Key Binding `JWT`.
361 ///
362 /// The `sd_hash` claim of the Key Binding `JWT` is generated by computing
363 /// the `base64url`-encoded hash digest over the Issuer-signed `JWT` and the
364 /// selected disclosures, as defined [here].
365 ///
366 /// # Note
367 /// The provided `hasher` **MUST** use the same algorithm that was used to
368 /// hide the claims of the `SD-JWT`.
369 ///
370 /// # Errors
371 /// An error will be returned if the computation of the Key Binding `JWT` signature fails.
372 ///
373 /// [here]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#name-integrity-protection-of-the
374 pub(crate) fn calc_key_binding_jwt(
375 &self,
376 hasher: impl Hasher,
377 challenge: KeyBindingChallenge,
378 current_time: SecondsSinceEpoch,
379 signer: &impl JwtSigner,
380 ) -> HolderResult<String> {
381 let sd_hash = sd_hash(self, hasher);
382 let claims = KBJwtClaims::new(challenge, current_time, sd_hash);
383
384 let signed_kb_jwt = KBJwt::<jwt::token::Signed>::new(claims, signer)?;
385
386 Ok(signed_kb_jwt.into_string())
387 }
388
389 /// Constructs, signs and adds the Key Binding JWT to this `SD-JWT`, resulting
390 /// in a `SD-JWT+KB`.
391 pub(crate) fn add_key_binding_jwt(
392 self,
393 hasher: impl Hasher,
394 challenge: KeyBindingChallenge,
395 current_time: SecondsSinceEpoch,
396 signer: &impl JwtSigner,
397 ) -> HolderResult<SdJwtKB> {
398 let key_binding_jwt = self.calc_key_binding_jwt(hasher, challenge, current_time, signer)?;
399
400 Ok(SdJwtKB {
401 sd_jwt: self,
402 key_binding_jwt,
403 })
404 }
405}
406
407impl SdJwtKB {
408 /// Verifies a Key Binding `JWT` against the provided `holder_public_key`
409 /// and [`KeyBindingChallenge`], according to the [official documentation],
410 /// using the [`SignatureVerifier`] implementation looked up from the provided
411 /// callback.
412 ///
413 /// # Notes
414 /// The provided `hasher` **MUST** use the same algorithm that was used to
415 /// hide the claims of the `SD-JWT`.
416 ///
417 /// The signing algorithm has not been verified to comply with the security
418 /// standards of the `SD-JWT` specification, but it should not be necessary
419 /// because it is already one of the
420 /// [`SigningAlgorithm`](crate::SigningAlgorithm) variants, that are all
421 /// deemed secure.
422 ///
423 /// # Errors
424 /// An error will be returned if the Key Binding `JWT` is not present,
425 /// if its verification fails, or there is no available verifier for that algorithm.
426 ///
427 /// [official documentation]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-07#section-8.3-4.3.1
428 pub(crate) fn verify_key_binding_jwt<'a>(
429 &self,
430 hasher: impl Hasher,
431 holder_public_key: &JwkPublic,
432 challenge: &KeyBindingChallenge,
433 current_time: SecondsSinceEpoch,
434 get_signature_verifier: impl FnOnce(SigningAlgorithm) -> Option<&'a dyn SignatureVerifier>,
435 ) -> VerifierResult<KBJwt<jwt::token::Verified>> {
436 let kb_jwt = &self.key_binding_jwt;
437
438 let sd_hash = sd_hash(&self.sd_jwt, hasher);
439
440 let verified = KBJwt::<jwt::token::Verified>::validate(
441 kb_jwt,
442 holder_public_key,
443 get_signature_verifier,
444 challenge,
445 current_time,
446 &sd_hash,
447 )
448 .match_err(|kb_error| VerifierError::KeyBinding(kb_error.clone()))?;
449
450 Ok(verified)
451 }
452}
453
454/// Generates a payload for the Key Binding `JWT` `sd_hash` value of the provided `SD-JWT`
455///
456/// It is generated by concatenating the Issuer-signed `JWT` followed by a
457/// `~` character and the list of disclosures, each followed by a `~`
458/// character.
459fn sd_hash_payload(sd_jwt: &SdJwt) -> String {
460 let mut payload = String::new();
461
462 payload += &sd_jwt.jwt;
463 payload += SD_JWT_DELIMITER;
464
465 for disclosure in &sd_jwt.disclosures {
466 payload += disclosure;
467 payload += SD_JWT_DELIMITER;
468 }
469
470 payload
471}
472
473/// Computes a `sd_hash` value of the provided `SD_JWT` using the provided `Hasher`,
474/// as a `base64url`-encoded hash digest of the payload.
475fn sd_hash(sd_jwt: &SdJwt, hasher: impl Hasher) -> String {
476 utils::base64_url_digest(sd_hash_payload(sd_jwt).as_bytes(), hasher)
477}
478
479// TODO(issues/52) unit tests