From e2e6a9032fddef81703b49a758283273db747d69 Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Fri, 20 Mar 2026 00:31:48 -0700 Subject: [PATCH 1/2] feat(jose-jkt): JWK Thumbprint Implement RFC 7638 JWK thumbprint support in the `jose-jkt` crate. --- .github/workflows/jose-jkt.yml | 64 ++++++ Cargo.lock | 36 ++++ Cargo.toml | 2 + jose-jkt/Cargo.toml | 27 +++ jose-jkt/README.md | 72 +++++++ jose-jkt/src/lib.rs | 140 +++++++++++++ jose-jkt/tests/thumbprint.rs | 360 +++++++++++++++++++++++++++++++++ jose-jwk/src/key/ec.rs | 13 ++ jose-jwk/src/key/okp.rs | 13 ++ 9 files changed, 727 insertions(+) create mode 100644 .github/workflows/jose-jkt.yml create mode 100644 jose-jkt/Cargo.toml create mode 100644 jose-jkt/README.md create mode 100644 jose-jkt/src/lib.rs create mode 100644 jose-jkt/tests/thumbprint.rs diff --git a/.github/workflows/jose-jkt.yml b/.github/workflows/jose-jkt.yml new file mode 100644 index 0000000..3b35255 --- /dev/null +++ b/.github/workflows/jose-jkt.yml @@ -0,0 +1,64 @@ +name: jose-jwk + +on: + pull_request: + paths: + - ".github/workflows/jose-jkt.yml" + - "jose-b64/**" + - "jose-jkt/**" + - "jose-jwa/**" + - "jose-jwk/**" + - "Cargo.*" + push: + branches: + - master + +defaults: + run: + working-directory: jose-jkt + +env: + RUSTFLAGS: "-Dwarnings" + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + +jobs: + minimal-versions: + if: false + uses: RustCrypto/actions/.github/workflows/minimal-versions.yml@master + with: + working-directory: ${{ github.workflow }} + + no_std: + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + matrix: + rust: + - 1.85.0 # MSRV + - stable + target: + - thumbv7em-none-eabi + - wasm32-unknown-unknown + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + targets: ${{ matrix.target }} + toolchain: ${{ matrix.rust }} + - run: cargo build --target ${{ matrix.target }} --no-default-features + + test: + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + matrix: + rust: + - 1.85.0 # MSRV + - stable + # TODO(tarcieri): switch to cargo-hack + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - run: cargo test --no-default-features diff --git a/Cargo.lock b/Cargo.lock index 837bfc8..26fbc42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.7.0-rc.4" @@ -197,6 +206,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "jose-jkt" +version = "0.1.0" +dependencies = [ + "jose-b64", + "jose-jwk", + "serde", + "sha2", +] + [[package]] name = "jose-jwa" version = "0.2.0-pre" @@ -255,6 +274,12 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + [[package]] name = "libm" version = "0.2.15" @@ -464,6 +489,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signature" version = "3.0.0-rc.3" diff --git a/Cargo.toml b/Cargo.toml index a33d919..7868ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "jose-b64", + "jose-jkt", "jose-jwa", "jose-jwe", "jose-jwk", @@ -14,6 +15,7 @@ opt-level = 2 [patch.crates-io] jose-b64 = { path = "./jose-b64" } +jose-jkt = { path = "./jose-jkt" } jose-jwa = { path = "./jose-jwa" } jose-jwe = { path = "./jose-jwe" } jose-jwk = { path = "./jose-jwk" } diff --git a/jose-jkt/Cargo.toml b/jose-jkt/Cargo.toml new file mode 100644 index 0000000..d47adbc --- /dev/null +++ b/jose-jkt/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "jose-jkt" +version = "0.1.0" +authors = ["RustCrypto Developers"] +license = "Apache-2.0 OR MIT" +description = """ +Pure Rust implementation of JWK Thumbprint (RFC 7638) for the +Javascript Object Signing and Encryption (JOSE) specification. +""" +documentation = "https://docs.rs/jose-jkt" +homepage = "https://github.com/RustCrypto/JOSE/tree/master/jose-jkt" +repository = "https://github.com/RustCrypto/JOSE" +categories = ["cryptography", "data-structures", "encoding", "parser-implementations"] +keywords = ["json", "jwk", "jwk-thumbprint", "jkt", "thumbprint", "rfc7638"] +readme = "README.md" +edition = "2024" +rust-version = "1.85" + +[dependencies] +jose-jwk = { version = "=0.2.0-pre", default-features = false } +jose-b64 = { version = "=0.2.0-pre", default-features = false } +sha2 = { version = "=0.11.0-rc.2", default-features = false } +serde = { version = "1", default-features = false, features = ["alloc", "derive"] } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/jose-jkt/README.md b/jose-jkt/README.md new file mode 100644 index 0000000..e8c2e49 --- /dev/null +++ b/jose-jkt/README.md @@ -0,0 +1,72 @@ +# [RustCrypto]: jose-jkt + +[![Crate][crate-image]][crate-link] +[![Docs][docs-image]][docs-link] +[![Build Status][build-image]][build-link] +![Apache2.0 OR MIT licensed][license-image] +![Rust Version][rustc-image] +[![Project Chat][chat-image]][chat-link] + +Pure Rust implementation of the JWK Thumbprint component of the Javascript +Object Signing and Encryption ([JOSE]) specification as described in [RFC 7638]. + +A JWK Thumbprint is a hash of the required members of a JSON Web Key ([JWK]), +and provides a deterministic and unique identifier for the key. + +```rust +use jose_jwk::{Jwk, Rsa}; +use jose_jkt::JwkThumbprint; +use sha2::Sha512; + +let jwk = Jwk { + key: Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xAB, 0xCD, 0xEF].into(), + prv: None, + }.into(), + prm: Default::default(), +}; + +assert_eq!(jwk.thumbprint(), "I5r6_zYlxFlKVu1cnIWv8q0RLoX2fRe2XQ40lwJ6Rvk"); +assert_eq!( + jwk.thumbprint_with_digest::(), + "uUALByNLLxO2A7sasEiV-YLOVT97AzQ8NqIRhLfj_6TZHQas2L-sGMX7y0SApYjyemE4v0wn-aeVcq3ZIUYbjg" +); +``` + +[Documentation][docs-link] + +## License + +Licensed under either of: + +- [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) +- [MIT license](http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in the work by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. + +[//]: # "badges" + +[crate-image]: https://img.shields.io/crates/v/jose-jkt.svg +[crate-link]: https://crates.io/crates/jose-jkt +[docs-image]: https://docs.rs/jose-jkt/badge.svg +[docs-link]: https://docs.rs/jose-jkt/ +[license-image]: https://img.shields.io/badge/license-Apache2.0_OR_MIT-blue.svg +[rustc-image]: https://img.shields.io/badge/rustc-1.65+-blue.svg +[chat-image]: https://img.shields.io/badge/zulip-join_chat-blue.svg +[chat-link]: https://rustcrypto.zulipchat.com/#narrow/stream/300570-formats +[build-image]: https://github.com/RustCrypto/JOSE/actions/workflows/jose-jkt.yml/badge.svg +[build-link]: https://github.com/RustCrypto/JOSE/actions/workflows/jose-jkt.yml + +[//]: # "links" + +[RustCrypto]: https://github.com/RustCrypto/ +[JWK]: https://jose.readthedocs.io/en/latest/#jwk +[JOSE]: https://jose.readthedocs.io/ +[RFC 7638]: https://datatracker.ietf.org/doc/html/rfc7638 diff --git a/jose-jkt/src/lib.rs b/jose-jkt/src/lib.rs new file mode 100644 index 0000000..7061cf9 --- /dev/null +++ b/jose-jkt/src/lib.rs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2025 Phantom Technologies, Inc. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! JWK Thumbprint as defined in RFC 7638. +//! +//! This module provides methods for computing JWK Thumbprints, which are +//! cryptographic hash values computed over the required members of a JWK. + +#![no_std] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg", + html_favicon_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg" +)] +#![forbid(unsafe_code)] +#![warn( + clippy::panic, + clippy::panic_in_result_fn, + clippy::unwrap_used, + missing_docs, + rust_2018_idioms, + unused_lifetimes, + unused_qualifications +)] + +extern crate alloc; + +use alloc::string::String; + +use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; +use jose_b64::stream::{Encoder, Update}; +use jose_jwk::{Ec, Jwk, Key, Oct, Okp, Rsa}; +use sha2::{Digest, Sha256}; + +/// Trait for computing JWK thumbprints. +pub trait JwkThumbprint { + /// Compute the JWK thumbprint using SHA-256. + fn thumbprint(&self) -> String { + self.thumbprint_with_digest::() + } + + /// Compute the JWK thumbprint using a custom digest function. + fn thumbprint_with_digest(&self) -> String; +} + +impl JwkThumbprint for Ec { + fn thumbprint_with_digest(&self) -> String { + let mut digest = D::new(); + + // Required members in lexicographic order: crv, kty, x, y + digest.update(r#"{"crv":""#); + digest.update(self.crv.as_str()); + digest.update(r#"","kty":"EC","x":""#); + b64digest(&mut digest, &self.x); + digest.update(r#"","y":""#); + b64digest(&mut digest, &self.y); + digest.update(r#""}"#); + + Base64UrlUnpadded::encode_string(&digest.finalize()) + } +} + +impl JwkThumbprint for Rsa { + fn thumbprint_with_digest(&self) -> String { + let mut digest = D::new(); + + // Required members in lexicographic order: e, kty, n + digest.update(r#"{"e":""#); + b64digest(&mut digest, &self.e); + digest.update(r#"","kty":"RSA","n":""#); + b64digest(&mut digest, &self.n); + digest.update(r#""}"#); + + Base64UrlUnpadded::encode_string(&digest.finalize()) + } +} + +impl JwkThumbprint for Oct { + fn thumbprint_with_digest(&self) -> String { + let mut digest = D::new(); + + // Required members in lexicographic order: k, kty + digest.update(r#"{"k":""#); + b64digest(&mut digest, &*self.k); + digest.update(r#"","kty":"oct"}"#); + + Base64UrlUnpadded::encode_string(&digest.finalize()) + } +} + +impl JwkThumbprint for Okp { + fn thumbprint_with_digest(&self) -> String { + let mut digest = D::new(); + + // Required members in lexicographic order: crv, kty, x + digest.update(r#"{"crv":""#); + digest.update(self.crv.as_str()); + digest.update(r#"","kty":"OKP","x":""#); + b64digest(&mut digest, &self.x); + digest.update(r#""}"#); + + Base64UrlUnpadded::encode_string(&digest.finalize()) + } +} + +impl JwkThumbprint for Jwk { + fn thumbprint_with_digest(&self) -> String { + self.key.thumbprint_with_digest::() + } +} + +impl JwkThumbprint for Key { + fn thumbprint_with_digest(&self) -> String { + match self { + Key::Ec(ec) => ec.thumbprint_with_digest::(), + Key::Rsa(rsa) => rsa.thumbprint_with_digest::(), + Key::Oct(oct) => oct.thumbprint_with_digest::(), + Key::Okp(okp) => okp.thumbprint_with_digest::(), + _ => unreachable!("non-exhaustive enum should not have other variants"), + } + } +} + +struct DigestUpdater<'a, D>(&'a mut D); + +impl<'a, D: Digest> Update for DigestUpdater<'a, D> { + type Error = (); + + fn update(&mut self, chunk: impl AsRef<[u8]>) -> Result<(), Self::Error> { + self.0.update(chunk); + Ok(()) + } +} + +fn b64digest(digest: &mut D, chunk: impl AsRef<[u8]>) { + let mut encoder: Encoder> = Encoder::from(DigestUpdater(digest)); + let _ = encoder.update(chunk); + let _ = encoder.finish(); +} diff --git a/jose-jkt/tests/thumbprint.rs b/jose-jkt/tests/thumbprint.rs new file mode 100644 index 0000000..baff434 --- /dev/null +++ b/jose-jkt/tests/thumbprint.rs @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2025 Phantom Technologies, Inc. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! JWK Thumbprint tests including RFC 7638 examples. + +#![allow(clippy::indexing_slicing)] // usage is always valid + +use std::collections::HashSet; + +use jose_jkt::JwkThumbprint; +use jose_jwk::*; +use sha2::{Sha256, Sha384, Sha512}; + +/// Test the RSA key example from RFC 7638 Section 3.1. +#[test] +fn test_rfc7638_rsa_example() { + // This is the RSA key from RFC 7638 Section 3.1 + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), // "AQAB" in base64url + n: vec![ + 210, 252, 123, 106, 10, 30, 108, 103, 16, 74, 235, 143, 136, 178, 87, 102, 155, 77, + 246, 121, 221, 173, 9, 155, 92, 74, 108, 217, 168, 128, 21, 181, 161, 51, 191, 11, 133, + 108, 120, 113, 182, 223, 0, 11, 85, 79, 206, 179, 194, 237, 81, 43, 182, 143, 20, 92, + 110, 132, 52, 117, 47, 171, 82, 161, 207, 193, 36, 64, 143, 121, 181, 138, 69, 120, + 193, 100, 40, 133, 87, 137, 247, 162, 73, 227, 132, 203, 45, 159, 174, 45, 103, 253, + 150, 251, 146, 108, 25, 142, 7, 115, 153, 253, 200, 21, 192, 175, 9, 125, 222, 90, 173, + 239, 244, 77, 231, 14, 130, 127, 72, 120, 67, 36, 57, 191, 238, 185, 96, 104, 208, 71, + 79, 197, 13, 109, 144, 191, 58, 152, 223, 175, 16, 64, 200, 156, 2, 214, 146, 171, 59, + 60, 40, 150, 96, 157, 134, 253, 115, 183, 116, 206, 7, 64, 100, 124, 238, 234, 163, 16, + 189, 18, 249, 133, 168, 235, 159, 89, 253, 212, 38, 206, 165, 178, 18, 15, 79, 42, 52, + 188, 171, 118, 75, 126, 108, 84, 214, 132, 2, 56, 188, 196, 5, 135, 165, 158, 102, 237, + 31, 51, 137, 69, 119, 99, 92, 71, 10, 247, 92, 249, 44, 32, 209, 218, 67, 225, 191, + 196, 25, 226, 34, 166, 240, 208, 187, 53, 140, 94, 56, 249, 203, 5, 10, 234, 254, 144, + 72, 20, 241, 172, 26, 164, 156, 202, 158, 160, 202, 131, + ] + .into(), + prv: None, + }; + + let jwk = Jwk { + key: Key::Rsa(rsa_key), + prm: Parameters::default(), + }; + + let thumbprint = jwk.thumbprint(); + + // Expected result from RFC 7638 Section 3.1 + assert_eq!(thumbprint, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); +} + +/// Test EC P-256 key thumbprint using RFC 7517 Appendix A.1 example. +/// Reference: https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1 +#[test] +fn test_ec_p256_thumbprint() { + // Key values from RFC 7517 A.1: + // x: MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4 + // y: 4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM + // crv: P-256 + let ec_key = Ec { + crv: EcCurves::P256, + x: vec![ + 48, 160, 66, 76, 210, 28, 41, 68, 131, 138, 45, 117, 201, 43, 55, 231, 110, 162, 13, + 159, 0, 137, 58, 59, 78, 238, 138, 60, 10, 175, 236, 62, + ] + .into(), + y: vec![ + 224, 75, 101, 233, 36, 86, 217, 136, 139, 82, 179, 121, 189, 251, 213, 30, 232, 105, + 239, 31, 15, 198, 91, 102, 89, 105, 91, 108, 206, 8, 23, 35, + ] + .into(), + d: None, + }; + + let jwk = Jwk { + key: Key::Ec(ec_key), + prm: Parameters::default(), + }; + + let thumbprint = jwk.thumbprint(); + + // Computed from SHA-256 of: + // {"crv":"P-256","kty":"EC","x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4","y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"} + assert_eq!(thumbprint, "cn-I_WNMClehiVp51i_0VpOENW1upEerA8sEam5hn-s"); +} + +/// Test OKP Ed25519 key thumbprint using RFC 8037 Appendix A.2 example. +/// Reference: https://datatracker.ietf.org/doc/html/rfc8037#appendix-A.2 +#[test] +fn test_okp_ed25519_thumbprint() { + // Key values from RFC 8037 A.2: + // x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo + // crv: Ed25519 + let okp_key = Okp { + crv: OkpCurves::Ed25519, + x: vec![ + 215, 90, 152, 1, 130, 177, 10, 183, 213, 75, 254, 211, 201, 100, 7, 58, 14, 225, 114, + 243, 218, 166, 35, 37, 175, 2, 26, 104, 247, 7, 81, 26, + ] + .into(), + d: None, + }; + + let jwk = Jwk { + key: Key::Okp(okp_key), + prm: Parameters::default(), + }; + + let thumbprint = jwk.thumbprint(); + + // Computed from SHA-256 of: + // {"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"} + assert_eq!(thumbprint, "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"); +} + +/// Test Oct (symmetric) key thumbprint. +#[test] +fn test_oct_thumbprint() { + // k = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + // Base64url: AQIDBAUGBwgJCgsMDQ4PEA + let oct_key = Oct { + k: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].into(), + }; + + let jwk = Jwk { + key: Key::Oct(oct_key), + prm: Parameters::default(), + }; + + let thumbprint = jwk.thumbprint(); + + // Computed from SHA-256 of: {"k":"AQIDBAUGBwgJCgsMDQ4PEA","kty":"oct"} + assert_eq!(thumbprint, "enhFVP7_AlQw84MU6KWN9VKO0JkRsxKFtrIwGhIJhzo"); +} + +/// Test that private and public key representations produce the same thumbprint. +#[test] +fn test_private_public_key_same_thumbprint() { + // EC test + let ec_public = Ec { + crv: EcCurves::P256, + x: vec![1; 32].into(), + y: vec![2; 32].into(), + d: None, + }; + let ec_private = Ec { + crv: ec_public.crv, + x: ec_public.x.clone(), + y: ec_public.y.clone(), + d: Some(vec![100; 32].into()), + }; + assert_eq!(ec_public.thumbprint(), ec_private.thumbprint()); + + // OKP test + let okp_public = Okp { + crv: OkpCurves::Ed25519, + x: vec![1; 32].into(), + d: None, + }; + let okp_private = Okp { + crv: okp_public.crv, + x: okp_public.x.clone(), + d: Some(vec![100; 32].into()), + }; + assert_eq!(okp_public.thumbprint(), okp_private.thumbprint()); + + // RSA test + let rsa_public = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0x12, 0x34].into(), + prv: None, + }; + let rsa_private = Rsa { + e: rsa_public.e.clone(), + n: rsa_public.n.clone(), + prv: Some(RsaPrivate { + d: vec![0xAB].into(), + opt: None, + }), + }; + assert_eq!(rsa_public.thumbprint(), rsa_private.thumbprint()); +} + +/// Test different hash algorithms produce different results with expected lengths. +#[test] +fn test_hash_algorithms() { + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xAB, 0xCD, 0xEF, 0x12].into(), + prv: None, + }; + + let jwk = Jwk { + key: Key::Rsa(rsa_key), + prm: Parameters::default(), + }; + + let sha256_tp = jwk.thumbprint_with_digest::(); + let sha384_tp = jwk.thumbprint_with_digest::(); + let sha512_tp = jwk.thumbprint_with_digest::(); + + // Verify expected lengths + assert_eq!(sha256_tp.len(), 43); // 256 bits + assert_eq!(sha384_tp.len(), 64); // 384 bits + assert_eq!(sha512_tp.len(), 86); // 512 bits + + // All should be different + assert_ne!(sha256_tp, sha384_tp); + assert_ne!(sha256_tp, sha512_tp); + assert_ne!(sha384_tp, sha512_tp); + + // All should be valid base64url + for tp in [&sha256_tp, &sha384_tp, &sha512_tp] { + assert!(!tp.contains('=')); + assert!(!tp.contains('+')); + assert!(!tp.contains('/')); + } +} + +/// Test that different curves produce different thumbprints. +#[test] +fn test_curve_differentiation() { + // EC curves + let ec_curves = [ + EcCurves::P256, + EcCurves::P384, + EcCurves::P521, + EcCurves::P256K, + ]; + + let mut ec_thumbprints = Vec::new(); + for curve in ec_curves { + let ec = Ec { + crv: curve, + x: vec![1; 32].into(), + y: vec![2; 32].into(), + d: None, + }; + ec_thumbprints.push(ec.thumbprint()); + } + + // All EC thumbprints should be different + for i in 0..ec_thumbprints.len() { + for j in (i + 1)..ec_thumbprints.len() { + assert_ne!(ec_thumbprints[i], ec_thumbprints[j]); + } + } + + // OKP curves + let okp_curves = [ + OkpCurves::Ed25519, + OkpCurves::Ed448, + OkpCurves::X25519, + OkpCurves::X448, + ]; + + // All OKP thumbprints should be different + let mut okp_thumbprints = HashSet::new(); + for curve in okp_curves { + let okp = Okp { + crv: curve, + x: vec![1; 32].into(), + d: None, + }; + let thumbprint = okp.thumbprint(); + assert!(!okp_thumbprints.contains(&thumbprint)); + okp_thumbprints.insert(thumbprint); + } +} + +/// Test thumbprint consistency and determinism. +#[test] +fn test_thumbprint_determinism() { + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xDE, 0xAD, 0xBE, 0xEF].into(), + prv: None, + }; + + // Test Jwk vs Key consistency + let key_tp = rsa_key.thumbprint(); + let jwk_tp = Jwk { + key: Key::Rsa(rsa_key.clone()), + prm: Parameters::default(), + } + .thumbprint(); + assert_eq!(key_tp, jwk_tp); + + // Test determinism across multiple computations + let first = rsa_key.thumbprint(); + for _ in 0..10 { + assert_eq!(rsa_key.thumbprint(), first); + } +} + +/// Test various key sizes and edge cases. +#[test] +fn test_key_size_edge_cases() { + // RSA with different sizes + let test_keys = vec![ + Key::Rsa(Rsa { + e: vec![1].into(), + n: vec![3].into(), + prv: None, + }), + Key::Rsa(Rsa { + e: vec![0x00, 0x01, 0x00, 0x01].into(), // Leading zeros + n: vec![0xFF; 256].into(), // Large modulus + prv: None, + }), + Key::Ec(Ec { + crv: EcCurves::P256, + x: vec![1].into(), + y: vec![2].into(), + d: None, + }), + Key::Ec(Ec { + crv: EcCurves::P521, + x: vec![0xFF; 66].into(), + y: vec![0xEE; 66].into(), + d: None, + }), + Key::Oct(Oct { + k: vec![0x42].into(), + }), + Key::Oct(Oct { + k: vec![0x5A; 1024].into(), + }), + Key::Okp(Okp { + crv: OkpCurves::Ed25519, + x: vec![1].into(), + d: None, + }), + ]; + + for key in test_keys { + let thumbprint = key.thumbprint(); + assert!(!thumbprint.is_empty()); + assert!(!thumbprint.contains('=')); + } +} + +/// Test that different key types produce different thumbprints. +#[test] +fn test_key_type_differentiation() { + let same_bytes = vec![0x01, 0x02, 0x03, 0x04]; + + let oct_tp = Oct { + k: same_bytes.clone().into(), + } + .thumbprint(); + + let okp_tp = Okp { + crv: OkpCurves::Ed25519, + x: same_bytes.into(), + d: None, + } + .thumbprint(); + + assert_ne!(oct_tp, okp_tp); +} diff --git a/jose-jwk/src/key/ec.rs b/jose-jwk/src/key/ec.rs index bfa8e4a..4149255 100644 --- a/jose-jwk/src/key/ec.rs +++ b/jose-jwk/src/key/ec.rs @@ -44,3 +44,16 @@ pub enum EcCurves { #[serde(rename = "secp256k1")] P256K, } + +impl EcCurves { + /// Returns the string representation of the curve. + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + EcCurves::P256 => "P-256", + EcCurves::P384 => "P-384", + EcCurves::P521 => "P-521", + EcCurves::P256K => "secp256k1", + } + } +} diff --git a/jose-jwk/src/key/okp.rs b/jose-jwk/src/key/okp.rs index 00f7edd..97cb46c 100644 --- a/jose-jwk/src/key/okp.rs +++ b/jose-jwk/src/key/okp.rs @@ -39,3 +39,16 @@ pub enum OkpCurves { /// X448 X448, } + +impl OkpCurves { + /// Returns the string representation of the curve. + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + OkpCurves::Ed25519 => "Ed25519", + OkpCurves::Ed448 => "Ed448", + OkpCurves::X25519 => "X25519", + OkpCurves::X448 => "X448", + } + } +} From 95765972d0b762b8b45c3aad9060e8979dd916ec Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Fri, 20 Mar 2026 11:47:03 -0700 Subject: [PATCH 2/2] Merge jose-jkt into jose-jwt --- .github/workflows/jose-jkt.yml | 64 ----------------- .github/workflows/jose-jwk.yml | 3 +- Cargo.lock | 35 ++++----- Cargo.toml | 2 - jose-jkt/Cargo.toml | 27 ------- jose-jkt/README.md | 72 ------------------- jose-jwk/Cargo.toml | 2 + jose-jwk/README.md | 26 +++++-- jose-jwk/src/lib.rs | 3 + .../src/lib.rs => jose-jwk/src/thumbprint.rs | 21 +----- {jose-jkt => jose-jwk}/tests/thumbprint.rs | 2 +- 11 files changed, 45 insertions(+), 212 deletions(-) delete mode 100644 .github/workflows/jose-jkt.yml delete mode 100644 jose-jkt/Cargo.toml delete mode 100644 jose-jkt/README.md rename jose-jkt/src/lib.rs => jose-jwk/src/thumbprint.rs (85%) rename {jose-jkt => jose-jwk}/tests/thumbprint.rs (99%) diff --git a/.github/workflows/jose-jkt.yml b/.github/workflows/jose-jkt.yml deleted file mode 100644 index 3b35255..0000000 --- a/.github/workflows/jose-jkt.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: jose-jwk - -on: - pull_request: - paths: - - ".github/workflows/jose-jkt.yml" - - "jose-b64/**" - - "jose-jkt/**" - - "jose-jwa/**" - - "jose-jwk/**" - - "Cargo.*" - push: - branches: - - master - -defaults: - run: - working-directory: jose-jkt - -env: - RUSTFLAGS: "-Dwarnings" - CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - -jobs: - minimal-versions: - if: false - uses: RustCrypto/actions/.github/workflows/minimal-versions.yml@master - with: - working-directory: ${{ github.workflow }} - - no_std: - runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - matrix: - rust: - - 1.85.0 # MSRV - - stable - target: - - thumbv7em-none-eabi - - wasm32-unknown-unknown - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - targets: ${{ matrix.target }} - toolchain: ${{ matrix.rust }} - - run: cargo build --target ${{ matrix.target }} --no-default-features - - test: - runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - matrix: - rust: - - 1.85.0 # MSRV - - stable - # TODO(tarcieri): switch to cargo-hack - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - - run: cargo test --no-default-features diff --git a/.github/workflows/jose-jwk.yml b/.github/workflows/jose-jwk.yml index ceb6314..06c8b2f 100644 --- a/.github/workflows/jose-jwk.yml +++ b/.github/workflows/jose-jwk.yml @@ -62,8 +62,9 @@ jobs: - p256 - p384 - rsa + - thumbprint - url - - p256,p384,rsa,url + - p256,p384,rsa,thumbprint,url # Test all combinations of crypto enablement - p256,p384 diff --git a/Cargo.lock b/Cargo.lock index 26fbc42..6751a05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "block-buffer" -version = "0.11.0-rc.5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.0-rc.4" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ "hybrid-array", ] @@ -103,9 +103,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.0-rc.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4aae35a0fcbe22ff1be50fe96df72002d5a4a6fb4aae9193cf2da0daa36da2" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer", "const-oid", @@ -171,9 +171,9 @@ checksum = "bcaaec4551594c969335c98c903c1397853d4198408ea609190f420500f6be71" [[package]] name = "hybrid-array" -version = "0.4.0" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe39a812f039072707ce38020acbab2f769087952eddd9e2b890f37654b2349" +checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" dependencies = [ "typenum", "zeroize", @@ -206,16 +206,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "jose-jkt" -version = "0.1.0" -dependencies = [ - "jose-b64", - "jose-jwk", - "serde", - "sha2", -] - [[package]] name = "jose-jwa" version = "0.2.0-pre" @@ -244,6 +234,7 @@ dependencies = [ "serde", "serde_json", "serdect 0.2.0", + "sha2", "subtle", "url", "zeroize", @@ -276,9 +267,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.176" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -491,9 +482,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0-rc.2" +version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", "cpufeatures", diff --git a/Cargo.toml b/Cargo.toml index 7868ce6..a33d919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "jose-b64", - "jose-jkt", "jose-jwa", "jose-jwe", "jose-jwk", @@ -15,7 +14,6 @@ opt-level = 2 [patch.crates-io] jose-b64 = { path = "./jose-b64" } -jose-jkt = { path = "./jose-jkt" } jose-jwa = { path = "./jose-jwa" } jose-jwe = { path = "./jose-jwe" } jose-jwk = { path = "./jose-jwk" } diff --git a/jose-jkt/Cargo.toml b/jose-jkt/Cargo.toml deleted file mode 100644 index d47adbc..0000000 --- a/jose-jkt/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "jose-jkt" -version = "0.1.0" -authors = ["RustCrypto Developers"] -license = "Apache-2.0 OR MIT" -description = """ -Pure Rust implementation of JWK Thumbprint (RFC 7638) for the -Javascript Object Signing and Encryption (JOSE) specification. -""" -documentation = "https://docs.rs/jose-jkt" -homepage = "https://github.com/RustCrypto/JOSE/tree/master/jose-jkt" -repository = "https://github.com/RustCrypto/JOSE" -categories = ["cryptography", "data-structures", "encoding", "parser-implementations"] -keywords = ["json", "jwk", "jwk-thumbprint", "jkt", "thumbprint", "rfc7638"] -readme = "README.md" -edition = "2024" -rust-version = "1.85" - -[dependencies] -jose-jwk = { version = "=0.2.0-pre", default-features = false } -jose-b64 = { version = "=0.2.0-pre", default-features = false } -sha2 = { version = "=0.11.0-rc.2", default-features = false } -serde = { version = "1", default-features = false, features = ["alloc", "derive"] } - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/jose-jkt/README.md b/jose-jkt/README.md deleted file mode 100644 index e8c2e49..0000000 --- a/jose-jkt/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# [RustCrypto]: jose-jkt - -[![Crate][crate-image]][crate-link] -[![Docs][docs-image]][docs-link] -[![Build Status][build-image]][build-link] -![Apache2.0 OR MIT licensed][license-image] -![Rust Version][rustc-image] -[![Project Chat][chat-image]][chat-link] - -Pure Rust implementation of the JWK Thumbprint component of the Javascript -Object Signing and Encryption ([JOSE]) specification as described in [RFC 7638]. - -A JWK Thumbprint is a hash of the required members of a JSON Web Key ([JWK]), -and provides a deterministic and unique identifier for the key. - -```rust -use jose_jwk::{Jwk, Rsa}; -use jose_jkt::JwkThumbprint; -use sha2::Sha512; - -let jwk = Jwk { - key: Rsa { - e: vec![1, 0, 1].into(), - n: vec![0xAB, 0xCD, 0xEF].into(), - prv: None, - }.into(), - prm: Default::default(), -}; - -assert_eq!(jwk.thumbprint(), "I5r6_zYlxFlKVu1cnIWv8q0RLoX2fRe2XQ40lwJ6Rvk"); -assert_eq!( - jwk.thumbprint_with_digest::(), - "uUALByNLLxO2A7sasEiV-YLOVT97AzQ8NqIRhLfj_6TZHQas2L-sGMX7y0SApYjyemE4v0wn-aeVcq3ZIUYbjg" -); -``` - -[Documentation][docs-link] - -## License - -Licensed under either of: - -- [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) -- [MIT license](http://opensource.org/licenses/MIT) - -at your option. - -### Contribution - -Unless you explicitly state otherwise, any contribution intentionally submitted -for inclusion in the work by you, as defined in the Apache-2.0 license, shall be -dual licensed as above, without any additional terms or conditions. - -[//]: # "badges" - -[crate-image]: https://img.shields.io/crates/v/jose-jkt.svg -[crate-link]: https://crates.io/crates/jose-jkt -[docs-image]: https://docs.rs/jose-jkt/badge.svg -[docs-link]: https://docs.rs/jose-jkt/ -[license-image]: https://img.shields.io/badge/license-Apache2.0_OR_MIT-blue.svg -[rustc-image]: https://img.shields.io/badge/rustc-1.65+-blue.svg -[chat-image]: https://img.shields.io/badge/zulip-join_chat-blue.svg -[chat-link]: https://rustcrypto.zulipchat.com/#narrow/stream/300570-formats -[build-image]: https://github.com/RustCrypto/JOSE/actions/workflows/jose-jkt.yml/badge.svg -[build-link]: https://github.com/RustCrypto/JOSE/actions/workflows/jose-jkt.yml - -[//]: # "links" - -[RustCrypto]: https://github.com/RustCrypto/ -[JWK]: https://jose.readthedocs.io/en/latest/#jwk -[JOSE]: https://jose.readthedocs.io/ -[RFC 7638]: https://datatracker.ietf.org/doc/html/rfc7638 diff --git a/jose-jwk/Cargo.toml b/jose-jwk/Cargo.toml index f76c0ba..018b05a 100644 --- a/jose-jwk/Cargo.toml +++ b/jose-jwk/Cargo.toml @@ -21,6 +21,7 @@ rust-version = "1.85" default = ["crypto"] crypto = ["p256", "p384", "p521", "k256", "rsa"] legacy = ["dep:base64ct", "dep:elliptic-curve", "dep:serde_json", "dep:serdect", "dep:subtle"] +thumbprint = ["dep:sha2"] [dependencies] jose-b64 = { version = "=0.2.0-pre", default-features = false, features = ["secret"] } @@ -34,6 +35,7 @@ p384 = { version = "0.14.0-pre.9", default-features = false, optional = true, fe p521 = { version = "0.14.0-pre.9", default-features = false, optional = true, features = ["arithmetic"] } k256 = { version = "0.14.0-pre.9", default-features = false, optional = true, features = ["arithmetic"] } rsa = { version = "0.10.0-rc.6", default-features = false, optional = true } +sha2 = { version = "0.11.0-rc.2", default-features = false, optional = true } url = { version = "2.4.1", default-features = false, optional = true, features = ["serde"] } # legacy dependencies diff --git a/jose-jwk/README.md b/jose-jwk/README.md index 35a2b7d..c7748a7 100644 --- a/jose-jwk/README.md +++ b/jose-jwk/README.md @@ -7,9 +7,9 @@ ![Rust Version][rustc-image] [![Project Chat][chat-image]][chat-link] -Pure Rust implementation of the JSON Web Key ([JWK]) component of the -Javascript Object Signing and Encryption ([JOSE]) specification as described -in [RFC7517]. +Pure Rust implementation of the JSON Web Key ([JWK]) and JWK Thumbprint +components of the Javascript Object Signing and Encryption ([JOSE]) +specification as described in [RFC7517] and [RFC7638]. A JWK is a way to represent cryptographic keys in JSON, typically public keys. This format contains information about how the key needs to be used so a child @@ -17,9 +17,12 @@ node can validate what a parent node sends (e.g. with JWTs) or encrypt messages for the parent node using this key (e.g. with JWEs). This crate provides data structures to interface with this format. +A JWK Thumbprint is a hash of the required members of a JWK, and provides a +deterministic and unique identifier for the key. + ```rust -use jose_jwk::{Jwk, JwkSet, Key}; use jose_jwk::jose_jwa::{Algorithm, Signing}; +use jose_jwk::{Jwk, JwkSet, Key}; let keys = serde_json::json!({ "keys": [ @@ -57,6 +60,20 @@ assert_eq!(ec_jwk.prm.kid, Some(String::from("some-ec-kid"))); assert_eq!(rsa_jwk.prm.kid, Some(String::from("some-rsa-kid"))); assert_eq!(rsa_jwk.prm.alg, Some(Algorithm::Signing(Signing::Rs256))); + +#[cfg(feature = "thumbprint")] +{ + use jose_jwk::JwkThumbprint; + + assert_eq!( + ec_jwk.thumbprint(), + "cn-I_WNMClehiVp51i_0VpOENW1upEerA8sEam5hn-s" + ); + assert_eq!( + rsa_jwk.thumbprint(), + "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" + ); +} ``` [Documentation][docs-link] @@ -102,3 +119,4 @@ dual licensed as above, without any additional terms or conditions. [JWK]: https://jose.readthedocs.io/en/latest/#jwk [JOSE]: https://jose.readthedocs.io/ [RFC7517]: https://www.rfc-editor.org/rfc/rfc7517 +[RFC7638]: https://datatracker.ietf.org/doc/html/rfc7638 diff --git a/jose-jwk/src/lib.rs b/jose-jwk/src/lib.rs index 178c5f7..6f437bc 100644 --- a/jose-jwk/src/lib.rs +++ b/jose-jwk/src/lib.rs @@ -26,9 +26,12 @@ pub mod legacy; mod key; mod prm; +mod thumbprint; pub use key::*; pub use prm::{Class, Operations, Parameters, Thumbprint}; +#[cfg(feature = "thumbprint")] +pub use thumbprint::JwkThumbprint; pub use jose_b64; pub use jose_jwa; diff --git a/jose-jkt/src/lib.rs b/jose-jwk/src/thumbprint.rs similarity index 85% rename from jose-jkt/src/lib.rs rename to jose-jwk/src/thumbprint.rs index 7061cf9..1b91f89 100644 --- a/jose-jkt/src/lib.rs +++ b/jose-jwk/src/thumbprint.rs @@ -6,31 +6,15 @@ //! This module provides methods for computing JWK Thumbprints, which are //! cryptographic hash values computed over the required members of a JWK. -#![no_std] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] -#![doc = include_str!("../README.md")] -#![doc( - html_logo_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg", - html_favicon_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg" -)] -#![forbid(unsafe_code)] -#![warn( - clippy::panic, - clippy::panic_in_result_fn, - clippy::unwrap_used, - missing_docs, - rust_2018_idioms, - unused_lifetimes, - unused_qualifications -)] +#![cfg(feature = "thumbprint")] extern crate alloc; use alloc::string::String; +use crate::{Ec, Jwk, Key, Oct, Okp, Rsa}; use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; use jose_b64::stream::{Encoder, Update}; -use jose_jwk::{Ec, Jwk, Key, Oct, Okp, Rsa}; use sha2::{Digest, Sha256}; /// Trait for computing JWK thumbprints. @@ -117,7 +101,6 @@ impl JwkThumbprint for Key { Key::Rsa(rsa) => rsa.thumbprint_with_digest::(), Key::Oct(oct) => oct.thumbprint_with_digest::(), Key::Okp(okp) => okp.thumbprint_with_digest::(), - _ => unreachable!("non-exhaustive enum should not have other variants"), } } } diff --git a/jose-jkt/tests/thumbprint.rs b/jose-jwk/tests/thumbprint.rs similarity index 99% rename from jose-jkt/tests/thumbprint.rs rename to jose-jwk/tests/thumbprint.rs index baff434..1b9f021 100644 --- a/jose-jkt/tests/thumbprint.rs +++ b/jose-jwk/tests/thumbprint.rs @@ -3,11 +3,11 @@ //! JWK Thumbprint tests including RFC 7638 examples. +#![cfg(feature = "thumbprint")] #![allow(clippy::indexing_slicing)] // usage is always valid use std::collections::HashSet; -use jose_jkt::JwkThumbprint; use jose_jwk::*; use sha2::{Sha256, Sha384, Sha512};