From 8ff44172530fe32d1d69bab43c5fdb4a63114b59 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Mon, 22 Jun 2026 16:13:35 -0500 Subject: [PATCH 1/6] Prevent algorithm-confusion forgery with typed VerifyingKey `Token::verify(&[u8])` dispatched the verification algorithm from the token's self-declared `alg` header and applied it to the caller's raw key bytes. With the new asymmetric algorithms this is exploitable: an attacker can forge a token with `alg = HmacSha256` and a MAC computed over the victim's *public* ES256/PS256 key (which is not secret), and a verifier calling `verify(public_key)` would accept it. Add `VerifyingKey`, a typed key enum that carries its own algorithm, and `Token::verify_with_key`, which rejects any token whose header `alg` does not match the key's algorithm before running cryptography. The algorithm is now pinned by the key the relying party supplies, not by the token. `verify(&[u8])` is deprecated and restricted to HMAC-SHA256 only, so it can no longer be used to mount the attack while remaining backward compatible for existing HMAC callers. Tests, examples, README, and crate docs are migrated to `verify_with_key`. Co-Authored-By: Claude Opus 4.8 --- README.md | 30 ++++-- examples/asymmetric_signing.rs | 12 ++- examples/basic_usage.rs | 6 +- examples/cat_specific_claims.rs | 4 +- examples/cat_validation.rs | 4 +- examples/sample_es256_ps256_tokens.rs | 10 +- src/header.rs | 49 +++++++++ src/lib.rs | 10 +- src/tests.rs | 149 ++++++++++++++++++++++---- src/token.rs | 70 +++++++++--- 10 files changed, 283 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index fc5d487..7222138 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,15 @@ let token_bytes = token.to_bytes().expect("Failed to encode token"); ### Verifying a token ```rust -use common_access_token::{Token, VerificationOptions}; +use common_access_token::{Token, VerificationOptions, VerifyingKey}; // Decode the token let token = Token::from_bytes(&token_bytes).expect("Failed to decode token"); // Verify the signature -token.verify(key).expect("Failed to verify signature"); +token + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify signature"); // Verify the claims let options = VerificationOptions::new() @@ -161,18 +163,20 @@ Asymmetric signature algorithms (ES256, PS256) instead use the COSE_Sign1 struct ### Signing Algorithms -The library supports three algorithms, selected via `Algorithm` on the builder. The bytes passed to `sign()` and `verify()` are interpreted according to the algorithm: +The library supports three algorithms, selected via `Algorithm` on the builder. The `sign()` key bytes and the [`VerifyingKey`] passed to `verify_with_key()` are interpreted according to the algorithm: -| Algorithm | COSE id | Structure | `sign()` key | `verify()` key | -| ------------------- | ------- | ---------- | -------------------------------- | ------------------------------- | -| `Algorithm::HmacSha256` | 5 | COSE_Mac0 | raw symmetric key bytes | raw symmetric key bytes | -| `Algorithm::Es256` | -7 | COSE_Sign1 | PKCS#8 DER P-256 private key | SPKI DER P-256 public key | -| `Algorithm::Ps256` | -37 | COSE_Sign1 | PKCS#8 DER RSA private key | SPKI DER RSA public key | +| Algorithm | COSE id | Structure | `sign()` key | `verify_with_key()` key | +| ------------------- | ------- | ---------- | -------------------------------- | ------------------------------------ | +| `Algorithm::HmacSha256` | 5 | COSE_Mac0 | raw symmetric key bytes | `VerifyingKey::HmacSha256` (raw bytes) | +| `Algorithm::Es256` | -7 | COSE_Sign1 | PKCS#8 DER P-256 private key | `VerifyingKey::Es256` (SPKI DER public key) | +| `Algorithm::Ps256` | -37 | COSE_Sign1 | PKCS#8 DER RSA private key | `VerifyingKey::Ps256` (SPKI DER public key) | ES256 is ECDSA over the NIST P-256 curve with SHA-256; signatures are the fixed 64-byte COSE `r || s` form. PS256 is RSASSA-PSS with SHA-256 and MGF1-SHA-256. PSS uses a random salt, so each signature over the same input differs while all remain valid. +> **Verify with a typed `VerifyingKey`.** `verify_with_key()` binds the algorithm to the key you provide and rejects any token whose header `alg` does not match, which prevents algorithm-confusion forgery (an attacker crafting an `HmacSha256` token whose MAC is computed over your public verification key). The older `verify(&[u8])` is deprecated and only accepts HMAC-SHA256. + ```rust -use common_access_token::{Algorithm, KeyId, RegisteredClaims, TokenBuilder, current_timestamp}; +use common_access_token::{Algorithm, KeyId, RegisteredClaims, TokenBuilder, VerifyingKey, current_timestamp}; // `private_key` is PKCS#8 DER; `public_key` is SPKI DER. let token = TokenBuilder::new() @@ -188,7 +192,9 @@ let token = TokenBuilder::new() let token_bytes = token.to_bytes().expect("Failed to encode token"); let decoded = common_access_token::Token::from_bytes(&token_bytes).expect("decode"); -decoded.verify(public_key).expect("Failed to verify ES256 signature"); +decoded + .verify_with_key(VerifyingKey::Es256(public_key)) + .expect("Failed to verify ES256 signature"); ``` ### Nested Map Claims @@ -256,7 +262,9 @@ This library creates HMAC tokens using the COSE_Mac0 structure with proper CBOR ```rust // This will work with both COSE_Sign1 and COSE_Mac0 tokens -token.verify(key).expect("Failed to verify signature"); +token + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify signature"); ``` ## CAT-specific Claims diff --git a/examples/asymmetric_signing.rs b/examples/asymmetric_signing.rs index c17d7c1..1203d99 100644 --- a/examples/asymmetric_signing.rs +++ b/examples/asymmetric_signing.rs @@ -24,6 +24,7 @@ use common_access_token::{ current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, + VerifyingKey, }; use ct_codecs::{Base64, Decoder}; @@ -89,10 +90,17 @@ fn demo(name: &str, alg: Algorithm, private_key_b64: &str, public_key_b64: &str, token.signature.len() ); - // The verifier only needs the public key. + // The verifier only needs the public key. The key is wrapped in a typed + // `VerifyingKey` that names its algorithm, so the token's self-declared + // `alg` header cannot steer verification to a different primitive. let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); + let verifying_key = match alg { + Algorithm::Es256 => VerifyingKey::Es256(&public_key), + Algorithm::Ps256 => VerifyingKey::Ps256(&public_key), + other => panic!("unsupported algorithm for this example: {other:?}"), + }; decoded - .verify(&public_key) + .verify_with_key(verifying_key) .expect("Failed to verify signature"); let options = VerificationOptions::new() diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 83c92a2..18f9b2e 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,6 +1,6 @@ use common_access_token::{ current_timestamp, Algorithm, CborValue, KeyId, RegisteredClaims, TokenBuilder, - VerificationOptions, + VerificationOptions, VerifyingKey, }; use std::collections::BTreeMap; @@ -132,7 +132,7 @@ fn verify_token(token_bytes: &[u8], key: &[u8], expected_token_type: &str) { }; // Verify the signature - if let Err(err) = token.verify(key) { + if let Err(err) = token.verify_with_key(VerifyingKey::HmacSha256(key)) { println!( "Failed to verify {} token signature: {}", expected_token_type, err @@ -195,7 +195,7 @@ fn verify_nested_map_token(token_bytes: &[u8], key: &[u8]) { }; // Verify the signature - if let Err(err) = token.verify(key) { + if let Err(err) = token.verify_with_key(VerifyingKey::HmacSha256(key)) { println!("Failed to verify nested map token signature: {}", err); return; } diff --git a/examples/cat_specific_claims.rs b/examples/cat_specific_claims.rs index 0ad5938..e032fb0 100644 --- a/examples/cat_specific_claims.rs +++ b/examples/cat_specific_claims.rs @@ -1,6 +1,6 @@ use common_access_token::{ cat_keys, catm, catr, catreplay, catu, current_timestamp, uri_components, Algorithm, KeyId, - RegisteredClaims, TokenBuilder, VerificationOptions, + RegisteredClaims, TokenBuilder, VerificationOptions, VerifyingKey, }; use std::collections::BTreeMap; @@ -21,7 +21,7 @@ fn main() { // Verify the signature decoded_token - .verify(key) + .verify_with_key(VerifyingKey::HmacSha256(key)) .expect("Failed to verify signature"); // Verify the claims diff --git a/examples/cat_validation.rs b/examples/cat_validation.rs index 48a27d9..5e01352 100644 --- a/examples/cat_validation.rs +++ b/examples/cat_validation.rs @@ -1,6 +1,6 @@ use common_access_token::{ cat_keys, catm, catr, catreplay, catu, current_timestamp, uri_components, Algorithm, KeyId, - RegisteredClaims, TokenBuilder, VerificationOptions, + RegisteredClaims, TokenBuilder, VerificationOptions, VerifyingKey, }; use std::collections::BTreeMap; @@ -25,7 +25,7 @@ fn main() { // Verify signature decoded_token - .verify(key) + .verify_with_key(VerifyingKey::HmacSha256(key)) .expect("Failed to verify signature"); // Demonstrate different CAT-specific claim validations diff --git a/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs index 38acc82..83d69e6 100644 --- a/examples/sample_es256_ps256_tokens.rs +++ b/examples/sample_es256_ps256_tokens.rs @@ -9,6 +9,7 @@ use common_access_token::{ current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, + VerifyingKey, }; use ct_codecs::{Base64, Base64UrlSafeNoPadding, Decoder, Encoder, Hex}; @@ -93,7 +94,14 @@ fn mint( // Sanity check: the token verifies against its public key. let decoded = Token::from_bytes(&token_bytes).expect("decode"); - decoded.verify(&public_key).expect("verify signature"); + let verifying_key = match alg { + Algorithm::Es256 => VerifyingKey::Es256(&public_key), + Algorithm::Ps256 => VerifyingKey::Ps256(&public_key), + other => panic!("unsupported algorithm for this example: {other:?}"), + }; + decoded + .verify_with_key(verifying_key) + .expect("verify signature"); decoded .verify_claims( &VerificationOptions::new() diff --git a/src/header.rs b/src/header.rs index b5ed1f0..3ff5281 100644 --- a/src/header.rs +++ b/src/header.rs @@ -141,6 +141,55 @@ impl Algorithm { } } +/// A typed verification key, used by [`crate::token::Token::verify_with_key`]. +/// +/// Each variant carries the key material *and* the algorithm it is valid for. +/// Because the key's type is the algorithm, the token's self-declared `alg` +/// header cannot steer verification to a different cryptographic primitive: +/// `verify_with_key` rejects any token whose header algorithm does not match +/// the key's algorithm before running any cryptography. +/// +/// This prevents algorithm-confusion forgery. For example, an attacker who +/// crafts a token with `alg = HmacSha256` and a MAC computed over the (public) +/// ES256 verification key cannot have it accepted by a verifier that supplies a +/// [`VerifyingKey::Es256`] key, because the algorithms do not match. +/// +/// # Example +/// +/// ``` +/// use common_access_token::VerifyingKey; +/// +/// let secret = b"my-secret-key"; +/// let key = VerifyingKey::HmacSha256(secret); +/// ``` +#[derive(Debug, Clone, Copy)] +pub enum VerifyingKey<'a> { + /// HMAC-SHA256 shared secret, as raw key bytes (COSE_Mac0, alg 5). + HmacSha256(&'a [u8]), + /// ES256 public key, as SPKI DER (COSE_Sign1, alg -7). + Es256(&'a [u8]), + /// PS256 public key, as SPKI DER (COSE_Sign1, alg -37). + Ps256(&'a [u8]), +} + +impl VerifyingKey<'_> { + /// The algorithm this key is valid for. + pub fn algorithm(&self) -> Algorithm { + match self { + VerifyingKey::HmacSha256(_) => Algorithm::HmacSha256, + VerifyingKey::Es256(_) => Algorithm::Es256, + VerifyingKey::Ps256(_) => Algorithm::Ps256, + } + } + + /// The raw key material. + pub fn key_bytes(&self) -> &[u8] { + match self { + VerifyingKey::HmacSha256(k) | VerifyingKey::Es256(k) | VerifyingKey::Ps256(k) => k, + } + } +} + /// Key identifier that can be either a binary or string value. /// /// The key identifier (kid) is used to indicate which key was used to secure the token. diff --git a/src/lib.rs b/src/lib.rs index 802af76..1d250d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ //! ## Basic Example //! //! ```rust -//! use common_access_token::{Algorithm, KeyId, RegisteredClaims, TokenBuilder, VerificationOptions}; +//! use common_access_token::{Algorithm, KeyId, RegisteredClaims, TokenBuilder, VerificationOptions, VerifyingKey}; //! use common_access_token::current_timestamp; //! //! // Create a key for signing and verification @@ -52,7 +52,7 @@ //! .expect("Failed to decode token"); //! //! // Verify the signature -//! decoded_token.verify(key).expect("Failed to verify signature"); +//! decoded_token.verify_with_key(VerifyingKey::HmacSha256(key)).expect("Failed to verify signature"); //! //! // Verify the claims //! let options = VerificationOptions::new() @@ -66,7 +66,7 @@ //! //! ```rust //! use common_access_token::{ -//! Algorithm, KeyId, RegisteredClaims, TokenBuilder, VerificationOptions, +//! Algorithm, KeyId, RegisteredClaims, TokenBuilder, VerificationOptions, VerifyingKey, //! cat_keys, catm, catr, catreplay, catu, uri_components, current_timestamp, //! cattprint, FingerprintType //! }; @@ -113,7 +113,7 @@ //! .expect("Failed to decode token"); //! //! // Verify signature -//! decoded_token.verify(key).expect("Failed to verify signature"); +//! decoded_token.verify_with_key(VerifyingKey::HmacSha256(key)).expect("Failed to verify signature"); //! //! // Verify standard claims and CAT-specific claims //! let options = VerificationOptions::new() @@ -148,7 +148,7 @@ pub use constants::{ replay_values, tprint_params, uri_components, FingerprintType, }; pub use error::Error; -pub use header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId}; +pub use header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId, VerifyingKey}; pub use token::{Token, TokenBuilder, VerificationOptions}; pub use utils::current_timestamp; diff --git a/src/tests.rs b/src/tests.rs index 82a740a..64a3212 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -4,7 +4,7 @@ use crate::{ cat_keys, catm, catr, catreplay, cattprint, catu, claims::RegisteredClaims, constants::{uri_components, FingerprintType}, - header::{Algorithm, AlgorithmClass, CborValue, KeyId}, + header::{Algorithm, AlgorithmClass, CborValue, KeyId, VerifyingKey}, token::{Token, TokenBuilder, VerificationOptions}, utils::current_timestamp, }; @@ -44,7 +44,7 @@ fn test_token_creation_and_verification() { // Verify signature decoded_token - .verify(key) + .verify_with_key(VerifyingKey::HmacSha256(key)) .expect("Failed to verify signature"); // Verify claims @@ -181,7 +181,7 @@ fn test_invalid_signature() { .expect("Failed to sign token"); // Verify with wrong key should fail - let result = token.verify(wrong_key); + let result = token.verify_with_key(VerifyingKey::HmacSha256(wrong_key)); assert!(result.is_err()); match result { Err(crate::Error::SignatureVerification) => {} // Expected @@ -263,7 +263,9 @@ fn test_specific_token_verification() { let token = Token::from_bytes(&token_bytes).expect("Failed to decode token"); // Verify the signature - token.verify(&key).expect("Failed to verify signature"); + token + .verify_with_key(VerifyingKey::HmacSha256(&key)) + .expect("Failed to verify signature"); // Check registered claims assert_eq!(token.claims.registered.iss, Some("cdnname".to_string())); @@ -562,14 +564,16 @@ fn test_mac0_token_verification_with_original_bytes() { // Verify the token with the correct key let key = b"testSecret"; assert!( - token.verify(key).is_ok(), + token.verify_with_key(VerifyingKey::HmacSha256(key)).is_ok(), "Token verification should succeed with testSecret" ); // Verify the token fails with wrong key let wrong_key = b"wrongKey"; assert!( - token.verify(wrong_key).is_err(), + token + .verify_with_key(VerifyingKey::HmacSha256(wrong_key)) + .is_err(), "Token verification should fail with wrong key" ); @@ -883,7 +887,9 @@ fn test_created_token_format() { // Verify the token can be decoded and verified let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - decoded.verify(key).expect("Failed to verify token"); + decoded + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify token"); } #[test] @@ -917,7 +923,9 @@ fn test_create_verify_token_with_catu_filename() { // Verify the token can be decoded and verified let decoded_token = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - decoded_token.verify(key).expect("Failed to verify token"); + decoded_token + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify token"); // Verify including checking catu with a valid URI let options = VerificationOptions::new() @@ -965,7 +973,9 @@ fn test_create_verify_token_with_catu_parentpath() { // Verify the token can be decoded and verified let decoded_token = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - decoded_token.verify(key).expect("Failed to verify token"); + decoded_token + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify token"); // Verify including checking catu with a valid URI let options = VerificationOptions::new() @@ -1010,7 +1020,9 @@ fn test_create_verify_token_with_catu_stem() { // Verify the token can be decoded and verified let decoded_token = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - decoded_token.verify(key).expect("Failed to verify token"); + decoded_token + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("Failed to verify token"); // Verify including checking catu with a valid URI let options = VerificationOptions::new() @@ -1555,7 +1567,7 @@ fn test_signed_integer_cbor_types() { // Verify signature decoded_token - .verify(key) + .verify_with_key(VerifyingKey::HmacSha256(key)) .expect("Failed to verify signature"); // Check that negative integer claims were preserved correctly @@ -1721,7 +1733,7 @@ fn test_es256_sign_and_verify() { let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); decoded - .verify(&public_key) + .verify_with_key(VerifyingKey::Es256(&public_key)) .expect("Failed to verify ES256 signature"); let options = VerificationOptions::new() @@ -1754,7 +1766,7 @@ fn test_ps256_sign_and_verify() { let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); decoded - .verify(&public_key) + .verify_with_key(VerifyingKey::Ps256(&public_key)) .expect("Failed to verify PS256 signature"); let options = VerificationOptions::new() @@ -1821,16 +1833,29 @@ fn test_es256_wrong_key_fails() { let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); assert!( - decoded.verify(&wrong_public_key).is_err(), + decoded + .verify_with_key(VerifyingKey::Es256(&wrong_public_key)) + .is_err(), "Verification should fail with a non-matching public key" ); // Verifying against an unrelated but valid PS256 public key must also fail. + // Presenting it as an ES256 key (matching the token's declared algorithm) + // fails the cryptographic check; presenting it as a PS256 key fails the + // algorithm-binding check. Both must be rejected. let (_ps_priv, ps_pub) = ps256_keys(); assert!( - decoded.verify(&ps_pub).is_err(), + decoded + .verify_with_key(VerifyingKey::Es256(&ps_pub)) + .is_err(), "ES256 token should not verify against an RSA public key" ); + assert!( + decoded + .verify_with_key(VerifyingKey::Ps256(&ps_pub)) + .is_err(), + "ES256 token must be rejected when the key is for a different algorithm" + ); } #[test] @@ -1840,12 +1865,22 @@ fn test_ps256_wrong_key_fails() { let token_bytes = token.to_bytes().expect("Failed to encode token"); let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - // Verify against the ES256 (non-matching) public key. + // Verify against the ES256 (non-matching) public key. Presented as a PS256 + // key it fails the cryptographic check; presented as an ES256 key it fails + // the algorithm-binding check. Both must be rejected. let (_es_priv, es_pub) = es256_keys(); assert!( - decoded.verify(&es_pub).is_err(), + decoded + .verify_with_key(VerifyingKey::Ps256(&es_pub)) + .is_err(), "PS256 token should not verify against an EC public key" ); + assert!( + decoded + .verify_with_key(VerifyingKey::Es256(&es_pub)) + .is_err(), + "PS256 token must be rejected when the key is for a different algorithm" + ); } #[test] @@ -1873,7 +1908,9 @@ fn test_es256_tampered_payload_fails() { Token::from_bytes(&token_bytes).expect("tampered token should still decode structurally"); // ...but signature verification must reject the tampered payload. assert!( - decoded.verify(&public_key).is_err(), + decoded + .verify_with_key(VerifyingKey::Es256(&public_key)) + .is_err(), "Verification should fail for a tampered ES256 token" ); } @@ -1962,8 +1999,12 @@ fn test_ps256_signatures_are_randomized() { assert_eq!(&bytes_a[split..], token_a.signature.as_slice()); assert_eq!(&bytes_b[split..], token_b.signature.as_slice()); - token_a.verify(&public_key).expect("token_a should verify"); - token_b.verify(&public_key).expect("token_b should verify"); + token_a + .verify_with_key(VerifyingKey::Ps256(&public_key)) + .expect("token_a should verify"); + token_b + .verify_with_key(VerifyingKey::Ps256(&public_key)) + .expect("token_b should verify"); } #[test] @@ -2034,7 +2075,7 @@ fn test_ps256_invalid_private_key_errors() { fn test_es256_invalid_public_key_errors() { let (private_key, _public_key) = es256_keys(); let token = build_signed_token(Algorithm::Es256, &private_key); - let result = token.verify(b"not-a-valid-der-key"); + let result = token.verify_with_key(VerifyingKey::Es256(b"not-a-valid-der-key")); assert!( matches!(result, Err(crate::error::Error::InvalidKey(_))), "Verifying with an invalid ES256 public key should yield InvalidKey" @@ -2214,3 +2255,69 @@ fn test_to_bytes_without_algorithm_errors() { "to_bytes() on a token with no algorithm should return InvalidFormat" ); } + +/// The algorithm-confusion forgery this fix prevents: an attacker forges a token +/// with `alg = HmacSha256` and a MAC computed over the *public* ES256 key (which +/// is not secret). A verifier holding only that public key must reject it. +#[test] +fn test_alg_confusion_hmac_over_public_key_is_rejected() { + let (_es_priv, es_pub) = es256_keys(); + + // Attacker mints an HMAC token using the victim's public key bytes as the + // "secret" — something they can do because the public key is public. + let forged = TokenBuilder::new() + .algorithm(Algorithm::HmacSha256) + .protected_key_id(KeyId::string("asym-key-1")) + .registered_claims(RegisteredClaims::new().with_issuer("attacker")) + .sign(&es_pub) + .expect("forged token signs"); + + let token_bytes = forged.to_bytes().expect("encode"); + let decoded = Token::from_bytes(&token_bytes).expect("decode"); + + // The victim verifies with its ES256 public key. The algorithm binding on + // VerifyingKey means the HmacSha256 header no longer steers verification to + // HMAC, so the forgery is rejected before any crypto runs. + let result = decoded.verify_with_key(VerifyingKey::Es256(&es_pub)); + assert!( + matches!(result, Err(crate::error::Error::InvalidAlgorithm(_))), + "alg-confusion forgery must be rejected with InvalidAlgorithm, got {result:?}" + ); +} + +/// The deprecated `verify` refuses asymmetric algorithms outright, so it can no +/// longer be used to mount the confusion attack. +#[test] +#[allow(deprecated)] +fn test_deprecated_verify_rejects_asymmetric() { + let (private_key, public_key) = es256_keys(); + let token = build_signed_token(Algorithm::Es256, &private_key); + let token_bytes = token.to_bytes().expect("encode"); + let decoded = Token::from_bytes(&token_bytes).expect("decode"); + + let result = decoded.verify(&public_key); + assert!( + matches!(result, Err(crate::error::Error::InvalidAlgorithm(_))), + "deprecated verify must reject ES256, got {result:?}" + ); +} + +/// The deprecated `verify` still works for HMAC tokens (backward compatibility). +#[test] +#[allow(deprecated)] +fn test_deprecated_verify_still_works_for_hmac() { + let key = b"my-secret-hmac-key"; + let token = TokenBuilder::new() + .algorithm(Algorithm::HmacSha256) + .registered_claims(RegisteredClaims::new().with_issuer("issuer")) + .sign(key) + .expect("sign"); + let token_bytes = token.to_bytes().expect("encode"); + let decoded = Token::from_bytes(&token_bytes).expect("decode"); + + decoded.verify(key).expect("HMAC verify should still succeed"); + // And verify_with_key works the same way. + decoded + .verify_with_key(VerifyingKey::HmacSha256(key)) + .expect("HMAC verify_with_key should succeed"); +} diff --git a/src/token.rs b/src/token.rs index 024da7b..e58234a 100644 --- a/src/token.rs +++ b/src/token.rs @@ -3,7 +3,7 @@ use crate::claims::{Claims, ClaimsMap, RegisteredClaims}; use crate::constants::tprint_params; use crate::error::Error; -use crate::header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId}; +use crate::header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId, VerifyingKey}; use crate::utils::{ compute_es256, compute_hmac_sha256, compute_ps256, current_timestamp, verify_es256, verify_hmac_sha256, verify_ps256, @@ -174,23 +174,67 @@ impl Token { }) } - /// Verify the token signature + /// Verify the token signature using a raw key, dispatching on the token's + /// own `alg` header. /// - /// The structure used depends on the algorithm in the protected header: + /// # Deprecated + /// + /// This method picks the verification algorithm from the *token's* + /// self-declared `alg` header and applies it to the caller-supplied bytes. + /// For asymmetric algorithms that is unsafe: an attacker can craft a token + /// with `alg = HmacSha256` and a MAC computed over your (public) ES256/PS256 + /// verification key, and this method would accept it — an algorithm-confusion + /// forgery. To avoid that, this method now only accepts MAC (HMAC-SHA256) + /// tokens and returns [`Error::InvalidAlgorithm`] for ES256/PS256. + /// + /// Use [`Token::verify_with_key`] with a typed [`VerifyingKey`] instead; it + /// binds the algorithm to the key and supports all algorithms safely. + #[deprecated( + since = "0.3.0", + note = "use `verify_with_key` with a typed `VerifyingKey`; `verify` no longer supports ES256/PS256 to prevent algorithm-confusion forgery" + )] + pub fn verify(&self, key: &[u8]) -> Result<(), Error> { + let alg = self.header.algorithm().ok_or_else(|| { + Error::InvalidFormat("Missing algorithm in protected header".to_string()) + })?; + + match alg { + Algorithm::HmacSha256 => self.verify_with_key(VerifyingKey::HmacSha256(key)), + Algorithm::Es256 | Algorithm::Ps256 => Err(Error::InvalidAlgorithm(format!( + "`verify` only supports HMAC-SHA256; use `verify_with_key` for {alg:?}" + ))), + } + } + + /// Verify the token signature with a typed verification key. + /// + /// The token's header `alg` must match the key's algorithm + /// ([`VerifyingKey::algorithm`]); otherwise this returns + /// [`Error::InvalidAlgorithm`] *before* any cryptography runs. Binding the + /// algorithm to the key (rather than trusting the token's self-declared + /// `alg`) is what prevents algorithm-confusion forgery. + /// + /// The COSE structure used depends on the algorithm: /// /// - **HmacSha256** uses the COSE_Mac0 structure. For backward compatibility /// with tokens produced by other implementations, both the COSE_Sign1 and /// COSE_Mac0 inputs are tried. /// - **Es256** and **Ps256** are asymmetric signature algorithms and use the - /// COSE_Sign1 structure. For these, `key` must be the SPKI DER-encoded - /// public key. - pub fn verify(&self, key: &[u8]) -> Result<(), Error> { + /// COSE_Sign1 structure. The key must be the SPKI DER-encoded public key. + pub fn verify_with_key(&self, key: VerifyingKey) -> Result<(), Error> { let alg = self.header.algorithm().ok_or_else(|| { Error::InvalidFormat("Missing algorithm in protected header".to_string()) })?; - match alg { - Algorithm::HmacSha256 => { + if alg != key.algorithm() { + return Err(Error::InvalidAlgorithm(format!( + "token declares {alg:?} but verification key is for {:?}", + key.algorithm() + ))); + } + + match key { + VerifyingKey::HmacSha256(key) => { // Try with COSE_Sign1 structure first let sign1_input = self.sign1_input()?; let sign1_result = verify_hmac_sha256(key, &sign1_input, &self.signature); @@ -204,15 +248,13 @@ impl Token { verify_hmac_sha256(key, &mac0_input, &self.signature) } // Es256 and Ps256 share the same COSE_Sign1 input but are kept as - // separate arms (rather than merged into `Es256 | Ps256` with a - // nested match) so each maps directly to its own verify primitive. - // The duplication is one line; a merged arm would re-introduce a - // nested `match alg` that is harder to read for no real savings. - Algorithm::Es256 => { + // separate arms (rather than merged) so each maps directly to its + // own verify primitive; the duplication is one line. + VerifyingKey::Es256(key) => { let sign1_input = self.sign1_input()?; verify_es256(key, &sign1_input, &self.signature) } - Algorithm::Ps256 => { + VerifyingKey::Ps256(key) => { let sign1_input = self.sign1_input()?; verify_ps256(key, &sign1_input, &self.signature) } From 71c09bde1218e9afdd723b26a0f7010317e3dfc6 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Wed, 24 Jun 2026 09:46:23 -0600 Subject: [PATCH 2/6] Apply cargo fmt to examples and tests Addresses PR review: cargo fmt --check failed on import grouping in the asymmetric examples and the deprecated verify() call in tests. Co-Authored-By: Claude Opus 4.8 --- examples/asymmetric_signing.rs | 4 ++-- examples/sample_es256_ps256_tokens.rs | 4 ++-- src/tests.rs | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/asymmetric_signing.rs b/examples/asymmetric_signing.rs index 1203d99..40e773a 100644 --- a/examples/asymmetric_signing.rs +++ b/examples/asymmetric_signing.rs @@ -23,8 +23,8 @@ //! openssl pkey -inform DER -in priv.der -pubout -outform DER # public (SPKI) use common_access_token::{ - current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, - VerifyingKey, + current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, + VerificationOptions, VerifyingKey, }; use ct_codecs::{Base64, Decoder}; diff --git a/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs index 83d69e6..5e2f06d 100644 --- a/examples/sample_es256_ps256_tokens.rs +++ b/examples/sample_es256_ps256_tokens.rs @@ -8,8 +8,8 @@ //! elsewhere. use common_access_token::{ - current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, - VerifyingKey, + current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, + VerificationOptions, VerifyingKey, }; use ct_codecs::{Base64, Base64UrlSafeNoPadding, Decoder, Encoder, Hex}; diff --git a/src/tests.rs b/src/tests.rs index 64a3212..1ff2594 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2315,7 +2315,9 @@ fn test_deprecated_verify_still_works_for_hmac() { let token_bytes = token.to_bytes().expect("encode"); let decoded = Token::from_bytes(&token_bytes).expect("decode"); - decoded.verify(key).expect("HMAC verify should still succeed"); + decoded + .verify(key) + .expect("HMAC verify should still succeed"); // And verify_with_key works the same way. decoded .verify_with_key(VerifyingKey::HmacSha256(key)) From ba3ee487a07d92ef64d0df6bc69cff8f9b3de8a0 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 14:09:29 -0600 Subject: [PATCH 3/6] Migrate tests merged from #5 to verify_with_key The round-trip, mutation, and lossy-claim tests that landed in #5 (now on main) call the deprecated Token::verify. After rebasing onto main, convert those call sites to verify_with_key with a typed VerifyingKey so the suite builds clean under -D warnings. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 1ff2594..69cbd12 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -618,7 +618,7 @@ fn test_decoded_token_reencodes_without_breaking_signature() { // The original decoded token verifies. let token = Token::from_bytes(&token_bytes).expect("Failed to parse token"); assert!( - token.verify(key).is_ok(), + token.verify_with_key(VerifyingKey::HmacSha256(key)).is_ok(), "Original decoded token should verify with testSecret" ); @@ -628,7 +628,9 @@ fn test_decoded_token_reencodes_without_breaking_signature() { let reencoded = token.to_bytes().expect("Failed to re-encode token"); let reparsed = Token::from_bytes(&reencoded).expect("Failed to parse re-encoded token"); assert!( - reparsed.verify(key).is_ok(), + reparsed + .verify_with_key(VerifyingKey::HmacSha256(key)) + .is_ok(), "Re-encoded token should still verify with testSecret" ); } @@ -662,7 +664,9 @@ fn test_mutated_claims_are_reflected_in_to_bytes() { // And because the payload changed, the original MAC no longer matches: // the token must fail verification rather than pass with the wrong claims. assert!( - reparsed.verify(b"testSecret").is_err(), + reparsed + .verify_with_key(VerifyingKey::HmacSha256(b"testSecret")) + .is_err(), "A token whose claims were mutated after decode must not verify with the original MAC" ); } @@ -776,7 +780,7 @@ fn test_aud_as_array_verifies_and_roundtrips() { // Regression: an unmutated token must still verify against the original key. assert!( - token.verify(key).is_ok(), + token.verify_with_key(VerifyingKey::HmacSha256(key)).is_ok(), "unmutated aud-as-array token must verify (byte-faithful payload reuse)" ); @@ -788,7 +792,7 @@ fn test_aud_as_array_verifies_and_roundtrips() { ); Token::from_bytes(&reencoded) .expect("decode round-tripped token") - .verify(key) + .verify_with_key(VerifyingKey::HmacSha256(key)) .expect("round-tripped aud-as-array token should still verify"); } @@ -819,7 +823,9 @@ fn test_aud_as_array_mutation_is_reflected() { "mutation on a lossy token must be reflected on the wire" ); assert!( - reparsed.verify(key).is_err(), + reparsed + .verify_with_key(VerifyingKey::HmacSha256(key)) + .is_err(), "a mutated payload must not verify against the original MAC" ); } @@ -843,7 +849,7 @@ fn test_cti_as_text_verifies_and_roundtrips() { "text-valued cti cannot be represented as bytes and is dropped" ); assert!( - token.verify(key).is_ok(), + token.verify_with_key(VerifyingKey::HmacSha256(key)).is_ok(), "unmutated cti-as-text token must verify" ); assert_eq!( @@ -2194,7 +2200,7 @@ fn test_es256_verifies_noncanonical_protected_header() { let decoded = Token::from_bytes(&token_bytes).expect("decode non-canonical token"); assert_eq!(decoded.header.algorithm(), Some(Algorithm::Es256)); decoded - .verify(&public_key) + .verify_with_key(VerifyingKey::Es256(&public_key)) .expect("non-canonical protected header should still verify"); // Re-encoding a decoded token must be byte-faithful to the producer's @@ -2208,7 +2214,7 @@ fn test_es256_verifies_noncanonical_protected_header() { ); Token::from_bytes(&reencoded) .expect("decode round-tripped token") - .verify(&public_key) + .verify_with_key(VerifyingKey::Es256(&public_key)) .expect("round-tripped token should still verify"); } From b2e81ae27b8733d83354445a0215bf3cec92c9e6 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Sun, 11 Oct 2026 13:47:29 -0600 Subject: [PATCH 4/6] Address review: redact VerifyingKey Debug, mark non_exhaustive, clarify verify - Replace derived Debug on VerifyingKey with a manual impl that never prints key material (HMAC secrets would otherwise leak into logs). - Mark VerifyingKey #[non_exhaustive] to match Algorithm, so adding algorithms later isn't a breaking change. - Correct the deprecated `verify` docs: rejecting ES256/PS256 headers does not stop the confusion attack, since the forgery declares HmacSha256. Add a test pinning that boundary, plus a Debug redaction test. - Spell out the lifetime in `verify_with_key(VerifyingKey<'_>)`. - Migrate benches/cat.rs off the deprecated `verify` (it fed public keys to it, and failed clippy -D warnings). Co-Authored-By: Claude Opus 5.5 --- benches/cat.rs | 22 ++++++++++++++++++---- src/header.rs | 22 +++++++++++++++++++++- src/tests.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++-- src/token.rs | 16 +++++++++++----- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/benches/cat.rs b/benches/cat.rs index 99d207e..e82ef86 100644 --- a/benches/cat.rs +++ b/benches/cat.rs @@ -19,7 +19,8 @@ use std::fs; use std::path::Path; use common_access_token::{ - current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, + current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, + VerificationOptions, VerifyingKey, }; use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use ct_codecs::{Base64, Decoder}; @@ -43,10 +44,22 @@ struct AlgCase { algorithm: Algorithm, /// Key passed to `sign` (HMAC secret, or PKCS#8 DER private key). signing_key: Vec, - /// Key passed to `verify` (same HMAC secret, or SPKI DER public key). + /// Key passed to `verify_with_key` (same HMAC secret, or SPKI DER public key). verifying_key: Vec, } +impl AlgCase { + /// The verifying key typed to this case's algorithm. + fn typed_verifying_key(&self) -> VerifyingKey<'_> { + match self.algorithm { + Algorithm::HmacSha256 => VerifyingKey::HmacSha256(&self.verifying_key), + Algorithm::Es256 => VerifyingKey::Es256(&self.verifying_key), + Algorithm::Ps256 => VerifyingKey::Ps256(&self.verifying_key), + other => unreachable!("no benchmark case for {other:?}"), + } + } +} + /// Build the set of algorithms to benchmark. fn alg_cases() -> Vec { let es_priv = Base64::decode_to_vec(ES256_PRIVATE_KEY_B64, None).expect("valid ES256 private"); @@ -121,10 +134,11 @@ fn bench_verify(c: &mut Criterion) { let decoded = Token::from_bytes(&bytes).expect("decode"); // Sanity check before timing so a broken setup fails loudly. - decoded.verify(&case.verifying_key).expect("verify"); + let key = case.typed_verifying_key(); + decoded.verify_with_key(key).expect("verify"); group.bench_function(case.id, |b| { - b.iter(|| decoded.verify(&case.verifying_key).expect("verify")); + b.iter(|| decoded.verify_with_key(key).expect("verify")); }); } group.finish(); diff --git a/src/header.rs b/src/header.rs index 3ff5281..64efef3 100644 --- a/src/header.rs +++ b/src/header.rs @@ -14,6 +14,7 @@ use crate::constants::cose_algs; use std::collections::BTreeMap; +use std::fmt; /// Supported algorithms for token signing and verification. /// @@ -162,7 +163,11 @@ impl Algorithm { /// let secret = b"my-secret-key"; /// let key = VerifyingKey::HmacSha256(secret); /// ``` -#[derive(Debug, Clone, Copy)] +/// +/// The [`Debug`] implementation never prints key material, so a +/// `VerifyingKey` holding an HMAC secret is safe to log. +#[derive(Clone, Copy)] +#[non_exhaustive] pub enum VerifyingKey<'a> { /// HMAC-SHA256 shared secret, as raw key bytes (COSE_Mac0, alg 5). HmacSha256(&'a [u8]), @@ -190,6 +195,21 @@ impl VerifyingKey<'_> { } } +// Manual impl so the HMAC shared secret (and, for consistency, public key +// bytes) never ends up in logs, panics, or other diagnostics. +impl fmt::Debug for VerifyingKey<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + VerifyingKey::HmacSha256(_) => "HmacSha256", + VerifyingKey::Es256(_) => "Es256", + VerifyingKey::Ps256(_) => "Ps256", + }; + f.debug_tuple(name) + .field(&format_args!("")) + .finish() + } +} + /// Key identifier that can be either a binary or string value. /// /// The key identifier (kid) is used to indicate which key was used to secure the token. diff --git a/src/tests.rs b/src/tests.rs index 69cbd12..dede42e 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2291,8 +2291,9 @@ fn test_alg_confusion_hmac_over_public_key_is_rejected() { ); } -/// The deprecated `verify` refuses asymmetric algorithms outright, so it can no -/// longer be used to mount the confusion attack. +/// The deprecated `verify` refuses tokens that declare an asymmetric algorithm. +/// Note this alone does not stop the confusion attack — see +/// `test_deprecated_verify_with_public_key_still_accepts_hmac_forgery`. #[test] #[allow(deprecated)] fn test_deprecated_verify_rejects_asymmetric() { @@ -2308,6 +2309,48 @@ fn test_deprecated_verify_rejects_asymmetric() { ); } +/// Documents the safety boundary of the deprecated `verify`: it is HMAC-only and +/// dispatches on the token's own `alg`, so a caller who (wrongly) passes an +/// asymmetric public key can still be fooled by an `HmacSha256` forgery. The +/// protection comes from `verify_with_key` with a typed asymmetric key, not from +/// `verify` itself. If this test starts failing, `verify` has been hardened +/// further and its docs should be updated to match. +#[test] +#[allow(deprecated)] +fn test_deprecated_verify_with_public_key_still_accepts_hmac_forgery() { + let (_es_priv, es_pub) = es256_keys(); + + let forged = TokenBuilder::new() + .algorithm(Algorithm::HmacSha256) + .registered_claims(RegisteredClaims::new().with_issuer("attacker")) + .sign(&es_pub) + .expect("forged token signs"); + let token_bytes = forged.to_bytes().expect("encode"); + let decoded = Token::from_bytes(&token_bytes).expect("decode"); + + assert!( + decoded.verify(&es_pub).is_ok(), + "legacy verify treats key bytes as an HMAC secret; never pass it a public key" + ); + assert!(matches!( + decoded.verify_with_key(VerifyingKey::Es256(&es_pub)), + Err(crate::error::Error::InvalidAlgorithm(_)) + )); +} + +/// `VerifyingKey`'s Debug output must never include key material. +#[test] +fn test_verifying_key_debug_redacts_key_bytes() { + let secret = b"super-secret-hmac-key"; + let rendered = format!("{:?}", VerifyingKey::HmacSha256(secret)); + assert_eq!(rendered, "HmacSha256()"); + assert!(!rendered.contains("115")); // b's' as a decimal byte + assert_eq!( + format!("{:?}", VerifyingKey::Es256(&[1, 2, 3])), + "Es256()" + ); +} + /// The deprecated `verify` still works for HMAC tokens (backward compatibility). #[test] #[allow(deprecated)] diff --git a/src/token.rs b/src/token.rs index e58234a..058d732 100644 --- a/src/token.rs +++ b/src/token.rs @@ -184,14 +184,20 @@ impl Token { /// For asymmetric algorithms that is unsafe: an attacker can craft a token /// with `alg = HmacSha256` and a MAC computed over your (public) ES256/PS256 /// verification key, and this method would accept it — an algorithm-confusion - /// forgery. To avoid that, this method now only accepts MAC (HMAC-SHA256) - /// tokens and returns [`Error::InvalidAlgorithm`] for ES256/PS256. + /// forgery. + /// + /// This method now only accepts MAC (HMAC-SHA256) tokens and returns + /// [`Error::InvalidAlgorithm`] for ES256/PS256. That does **not** make it + /// safe to call with a public key: the forged token above declares + /// `alg = HmacSha256`, so it is still routed to HMAC verification and will + /// be accepted. Only ever pass an HMAC *secret* to this method. /// /// Use [`Token::verify_with_key`] with a typed [`VerifyingKey`] instead; it - /// binds the algorithm to the key and supports all algorithms safely. + /// binds the algorithm to the key, which is what actually prevents the + /// forgery, and supports all algorithms. #[deprecated( since = "0.3.0", - note = "use `verify_with_key` with a typed `VerifyingKey`; `verify` no longer supports ES256/PS256 to prevent algorithm-confusion forgery" + note = "use `verify_with_key` with a typed `VerifyingKey`; `verify` trusts the token's `alg` header and is only safe with an HMAC secret, never a public key" )] pub fn verify(&self, key: &[u8]) -> Result<(), Error> { let alg = self.header.algorithm().ok_or_else(|| { @@ -221,7 +227,7 @@ impl Token { /// COSE_Mac0 inputs are tried. /// - **Es256** and **Ps256** are asymmetric signature algorithms and use the /// COSE_Sign1 structure. The key must be the SPKI DER-encoded public key. - pub fn verify_with_key(&self, key: VerifyingKey) -> Result<(), Error> { + pub fn verify_with_key(&self, key: VerifyingKey<'_>) -> Result<(), Error> { let alg = self.header.algorithm().ok_or_else(|| { Error::InvalidFormat("Missing algorithm in protected header".to_string()) })?; From 862420a4d8bd3e2efa23ba5fa9c2cbd41aac8b45 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Sun, 11 Oct 2026 13:50:57 -0600 Subject: [PATCH 5/6] Set verify deprecation version to 0.4.0 0.3.0 was never published; main moved to 0.4.0, which is the first release that will include this change. Co-Authored-By: Claude Opus 5.5 --- src/token.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/token.rs b/src/token.rs index 058d732..21ebadf 100644 --- a/src/token.rs +++ b/src/token.rs @@ -196,7 +196,7 @@ impl Token { /// binds the algorithm to the key, which is what actually prevents the /// forgery, and supports all algorithms. #[deprecated( - since = "0.3.0", + since = "0.4.0", note = "use `verify_with_key` with a typed `VerifyingKey`; `verify` trusts the token's `alg` header and is only safe with an HMAC secret, never a public key" )] pub fn verify(&self, key: &[u8]) -> Result<(), Error> { From cd41982a88e6301c90ff5f382b592ec236453ff9 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Mon, 19 Oct 2026 22:01:35 -0600 Subject: [PATCH 6/6] Add CHANGELOG entries for VerifyingKey and the verify deprecation Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5cfd4..2e61cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `VerifyingKey`, a typed verification key that carries its algorithm + (`HmacSha256`, `Es256`, `Ps256`), and `Token::verify_with_key`, which rejects + a token whose header `alg` doesn't match the key's algorithm before running + any cryptography. `VerifyingKey`'s `Debug` output redacts the key bytes. - **ES256** (ECDSA using the P-256 curve and SHA-256) signing and verification support, producing/consuming `COSE_Sign1` structures. - **PS256** (RSASSA-PSS using SHA-256 and MGF1 with SHA-256) signing and @@ -37,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **Algorithm confusion:** `Token::verify(&[u8])` picked the algorithm from the + token's own `alg` header. With ES256/PS256 support, an attacker could forge a + token declaring HMAC and MAC it with the verifier's public key bytes, and + `verify` accepted it. Use `verify_with_key` with a typed `VerifyingKey`. + `verify` is **deprecated**, only accepts HMAC-SHA256, and returns + `Error::InvalidAlgorithm` for ES256/PS256; it is still unsafe to call with a + public key. - ES256 signatures are now produced in canonical "low-S" form, and `verify` rejects high-S (non-canonical) ECDSA signatures. ECDSA signatures are malleable — for a valid `(r, s)`, the pair `(r, n - s)` verifies over the same