Skip to main content

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