From 3d45e63e2b43dd9769f302fc708158799e2ae59f Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Mon, 22 Jun 2026 14:36:52 -0500 Subject: [PATCH 01/16] Add ES256 and PS256 signing algorithm support The Common Access Token spec supports the ES256 and PS256 signing algorithms in addition to HS256. This adds both signing and verification for those asymmetric algorithms. - Algorithm enum gains Es256 (COSE -7) and Ps256 (COSE -37) plus an is_mac() helper to distinguish COSE_Mac0 from COSE_Sign1 algorithms - Signing/verification primitives via RustCrypto (p256, rsa, sha2); ES256 uses deterministic RFC 6979, PS256 uses OsRng for the PSS salt - token sign()/verify() dispatch per algorithm; asymmetric algorithms use COSE_Sign1 with CWT tag 61 + tag 18 - Keys reuse the existing &[u8] API: PKCS#8 DER private keys for signing, SPKI DER public keys for verifying; new Error::InvalidKey for malformed keys - 13 new tests covering round-trip, wrong-key/cross-algorithm rejection, payload tampering, PSS salt randomization, and invalid-key handling - New asymmetric_signing example, README signing-algorithms table, and updated crate docs - Bump thiserror to 2 and rust-version to 1.72 (required by RustCrypto) Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 11 +- README.md | 36 +++- examples/asymmetric_signing.rs | 98 +++++++++++ src/constants.rs | 4 + src/error.rs | 4 + src/header.rs | 27 ++- src/lib.rs | 2 +- src/tests.rs | 293 +++++++++++++++++++++++++++++++++ src/token.rs | 68 ++++++-- src/utils.rs | 69 ++++++++ 10 files changed, 594 insertions(+), 18 deletions(-) create mode 100644 examples/asymmetric_signing.rs diff --git a/Cargo.toml b/Cargo.toml index af31a49..ebe495a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,20 @@ repository = "https://github.com/fastly/rust-cat" readme = "README.md" keywords = ["token", "authentication", "authorization", "cbor", "cose"] categories = ["authentication", "cryptography", "web-programming"] -rust-version = "1.60.0" +rust-version = "1.72.0" [dependencies] minicbor = { version = "2.1.3", features = ["derive", "std"] } hmac-sha256 = "1.1.12" hmac-sha512 = "1.1.7" -thiserror = "1.0.65" +thiserror = "2" ct-codecs = "1.1.6" url = "2.5.2" regex = "1.9.6" +# ES256 (ECDSA P-256 + SHA-256, COSE alg -7) +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } +# PS256 (RSASSA-PSS + SHA-256, COSE alg -37) +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "0.10" +# RNG for PSS salt generation (WASM-friendly via getrandom) +rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/README.md b/README.md index 5ec4269..17b2eb8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ This library uses the [minicbor](https://crates.io/crates/minicbor) crate for CB - Custom Claims: Support for application-specific claims with string, binary, integer, and nested map values - CAT-specific Claims: Support for CAT-specific claims like CATU (URI restrictions), CATR (token renewal), CATM (HTTP methods), and more - Key Identifiers: Support for both binary and string key identifiers (kid) -- HMAC-SHA256 Authentication: Secure token signing and verification +- Multiple Signing Algorithms: HMAC-SHA256 (HS256) MACs, plus ECDSA P-256 (ES256) and RSASSA-PSS (PS256) asymmetric signatures - Comprehensive Verification: Validate signatures, expiration times, and other claims - CAT-specific Validation: Validate URI restrictions, HTTP method constraints, and replay protection @@ -157,6 +157,40 @@ COSE_Mac0 = [ ] ``` +Asymmetric signature algorithms (ES256, PS256) instead use the COSE_Sign1 structure with CWT (tag 61) and COSE_Sign1 (tag 18) CBOR tags. The array layout is identical; only the final element is a signature rather than a MAC. + +### 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: + +| 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 | + +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. + +```rust +use common_access_token::{Algorithm, KeyId, RegisteredClaims, TokenBuilder, current_timestamp}; + +// `private_key` is PKCS#8 DER; `public_key` is SPKI DER. +let token = TokenBuilder::new() + .algorithm(Algorithm::Es256) + .protected_key_id(KeyId::string("my-ec-key")) + .registered_claims( + RegisteredClaims::new() + .with_issuer("example-issuer") + .with_expiration(current_timestamp() + 3600), + ) + .sign(private_key) + .expect("Failed to sign token"); + +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"); +``` + ### Nested Map Claims You can include complex structured data in tokens using nested maps: diff --git a/examples/asymmetric_signing.rs b/examples/asymmetric_signing.rs new file mode 100644 index 0000000..9108f49 --- /dev/null +++ b/examples/asymmetric_signing.rs @@ -0,0 +1,98 @@ +//! Example: signing and verifying tokens with the asymmetric ES256 and PS256 +//! algorithms. +//! +//! Unlike HMAC (HS256), these algorithms use a key pair: the token is signed +//! with a private key and verified with the corresponding public key, using the +//! COSE_Sign1 structure. +//! +//! Keys are interpreted as DER: +//! - signing key: PKCS#8 DER-encoded private key +//! - verifying key: SPKI DER-encoded public key +//! +//! The keys below were generated once with OpenSSL and embedded as base64 so the +//! example is self-contained: +//! +//! ES256 (P-256): +//! openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out es.pem +//! openssl pkcs8 -topk8 -nocrypt -in es.pem -outform DER # private (PKCS#8) +//! openssl pkey -in es.pem -pubout -outform DER # public (SPKI) +//! +//! PS256 (RSA-2048): +//! openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -outform DER \ +//! | openssl pkcs8 -topk8 -nocrypt -inform DER -outform DER # private (PKCS#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, +}; +use ct_codecs::{Base64, Decoder}; + +const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; +const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; + +const PS256_PRIVATE_KEY_B64: &str = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCHjA5iauwvo2sRB529iV1c+p+WuGFzk5EUGFFLYoIHxAwo/rSmZ2/D00epwb4WzOxA4c8+1QA+0rZIN35Fti9Wiunt0b1DgC0tuSglNzpEE5gjhTDcAWZOBPOCMt9pKEuuQC4eqBRxPoG5Y14dVi46/aQOQSqU5I0T3cbeLliTzjXkrvdqySFXMGpM9/I469SZRxZbDgB8wUcB2nTIuwOokjN/Vp+BpMM5QmR66J6aFNi8LqCmQv3grUI1kM1fqrC3az/YcyXcDvjinagyXsGYgW2ZpXIf2760UXv/bASAOO01sgI8zxbIDdG6Vd+7iPhr4b/v6QIj6rpuURFfns2LAgMBAAECggEAH9CdXbdYCZRzYHNnsGGqEtVWmQNdCEo2Lr/IcQfFmnoHGqYyE67Kmm2gb/VkHyjpOQ9nXAmVvakqlMfFsSoicU84uhPVNx9CO22uwRF18R2iQ5ATGEiR0TUzTLeRHbcSEGvLB3IPHkd8Hl327K7aOglntNrR2lHM1UFkWKkLLGHObPoLBSTQLjX5JkvtpUuBgnPVlfBUc5al9+CH+m/SiC4BvVWo4hiHEKCQgMIQ/Dh8UtS9Vk91FIizqKpqBXE6+PNmAnn9ZwRjZoRNBSLn0paAyiEXXdr5rV8zeYU0ktY40J9qWEFOJmTYII4pUK1U8tukrQ0w4LUm17f8zMkufQKBgQC7MGcSrbFWVjlEwA760sG6NKOZb5sL+2etIVAJyfSoGrwr8H4aQA1WFP+pmmlCWsLZj8qfTYSyocwfT/p9aY9Na7ftyks+q1QSsDF+D7frgxmITJeCSwiPa7jnOTrmReqAEOyPn8IlytHIhJbaPxzDxPf572QIAIBgsWhdygn21QKBgQC5X9agS0u2Joypz36ZIilbgbtgmSvFAE/22U0il+3GgXQbjmxPCip1UZm1cBgmLhq12bxU1xYxJpGVPWhEsmkIrOkEfNf/RYlSvVLzbuZLQxeB1g5FDrFb1EbaegrFznv/rFonyXMeRyJ7PHtDttfN5jxNTxTiV3BQ4uobgsai3wKBgFEiW6q26mSXnt7zuApzi1CgPEDnJPb+kyNxivWTOZ4baHBLHv1VwfILy/zBVtpR6J7QOmzt9pROmOEBk3sEY/6Ur/Y7dn3FWP14rRsMyRUlj82KFSl+SEmR0WU3YxYoO8oii8Z84nPrAx68iX4zWM5p82m7n0nwnbRLcQcl6Ue5AoGACjVN42viEnjS/DLx/MrVzjU5tVsZ/vJCdQyIY+RL8seENlREgKHFrso8lbJDki6tx9/isCVcEn7WO4qzKD1O7WxgNKAPYP5aTpUgcUllIzXhoIPCK2lguPbapANefoAdcfnyyQgd78fpDTJKc3MpNSx9m6BEPSalh77HN5afC68CgYBSHR2vz1GuUzHSgU+3xKqGSc+jlroetJ1dC5913Z+9eawW7QrRfmSod+JfEiJSw8eS+5/rGYjKihMtNPyqzadRvZtp0QGZrrm1k1/vqqeeH5Uq6AgH/2Djql4tUvC3gmgpHjY7RyPDv6v+u+L9C6MP0Nu5vVfQwpAmX9bsjn/Tjw=="; +const PS256_PUBLIC_KEY_B64: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAh4wOYmrsL6NrEQedvYldXPqflrhhc5ORFBhRS2KCB8QMKP60pmdvw9NHqcG+FszsQOHPPtUAPtK2SDd+RbYvVorp7dG9Q4AtLbkoJTc6RBOYI4Uw3AFmTgTzgjLfaShLrkAuHqgUcT6BuWNeHVYuOv2kDkEqlOSNE93G3i5Yk8415K73askhVzBqTPfyOOvUmUcWWw4AfMFHAdp0yLsDqJIzf1afgaTDOUJkeuiemhTYvC6gpkL94K1CNZDNX6qwt2s/2HMl3A744p2oMl7BmIFtmaVyH9u+tFF7/2wEgDjtNbICPM8WyA3RulXfu4j4a+G/7+kCI+q6blERX57NiwIDAQAB"; + +fn main() { + demo( + "ES256 (ECDSA P-256 + SHA-256)", + Algorithm::Es256, + ES256_PRIVATE_KEY_B64, + ES256_PUBLIC_KEY_B64, + "ec-key-1", + ); + + demo( + "PS256 (RSASSA-PSS + SHA-256)", + Algorithm::Ps256, + PS256_PRIVATE_KEY_B64, + PS256_PUBLIC_KEY_B64, + "rsa-key-1", + ); +} + +fn demo(name: &str, alg: Algorithm, private_key_b64: &str, public_key_b64: &str, kid: &str) { + let private_key = Base64::decode_to_vec(private_key_b64, None).expect("valid private key"); + let public_key = Base64::decode_to_vec(public_key_b64, None).expect("valid public key"); + + let now = current_timestamp(); + let token = TokenBuilder::new() + .algorithm(alg) + .protected_key_id(KeyId::string(kid)) + .registered_claims( + RegisteredClaims::new() + .with_issuer("example-issuer") + .with_subject("example-subject") + .with_audience("example-audience") + .with_expiration(now + 3600) + .with_not_before(now) + .with_issued_at(now), + ) + .custom_string(100, "custom-string-value") + .sign(&private_key) + .expect("Failed to sign token"); + + let token_bytes = token.to_bytes().expect("Failed to encode token"); + println!( + "{name}: signed token is {} bytes ({}-byte signature)", + token_bytes.len(), + token.signature.len() + ); + + // The verifier only needs the public key. + let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); + decoded + .verify(&public_key) + .expect("Failed to verify signature"); + + let options = VerificationOptions::new() + .verify_exp(true) + .verify_nbf(true) + .expected_issuer("example-issuer") + .expected_audience("example-audience"); + decoded + .verify_claims(&options) + .expect("Failed to verify claims"); + + println!("{name}: signature and claims verified successfully\n"); +} diff --git a/src/constants.rs b/src/constants.rs index f9ffedb..4ea0529 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -224,4 +224,8 @@ pub mod cose_labels { pub mod cose_algs { /// HMAC with SHA-256 (COSE algorithm identifier: 5) pub const HMAC_SHA_256: i32 = 5; + /// ECDSA using P-256 curve and SHA-256 (COSE algorithm identifier: -7) + pub const ES256: i32 = -7; + /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (COSE algorithm identifier: -37) + pub const PS256: i32 = -37; } diff --git a/src/error.rs b/src/error.rs index 33a27c2..cc4604c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,10 @@ pub enum Error { #[error("Invalid algorithm: {0}")] InvalidAlgorithm(String), + /// Invalid cryptographic key + #[error("Invalid key: {0}")] + InvalidKey(String), + /// Signature verification failed #[error("Signature verification failed. The token's signature does not match the expected signature")] SignatureVerification, diff --git a/src/header.rs b/src/header.rs index 69c1eb8..faed649 100644 --- a/src/header.rs +++ b/src/header.rs @@ -20,8 +20,13 @@ use std::collections::BTreeMap; /// This enum represents the cryptographic algorithms that can be used /// to sign and verify Common Access Tokens. /// -/// Currently, only HMAC-SHA256 is supported, but the design allows for -/// easy extension to support additional algorithms in the future. +/// The following algorithms are supported: +/// +/// - **HmacSha256**: HMAC-SHA256, a symmetric MAC algorithm (COSE_Mac0 structure). +/// - **Es256**: ECDSA using the P-256 curve and SHA-256, an asymmetric signature +/// algorithm (COSE_Sign1 structure). +/// - **Ps256**: RSASSA-PSS using SHA-256 and MGF1 with SHA-256, an asymmetric +/// signature algorithm (COSE_Sign1 structure). /// /// # Example /// @@ -31,11 +36,18 @@ use std::collections::BTreeMap; /// // Create a token with HMAC-SHA256 algorithm /// let alg = Algorithm::HmacSha256; /// assert_eq!(alg.identifier(), 5); // COSE algorithm identifier +/// +/// assert_eq!(Algorithm::Es256.identifier(), -7); +/// assert_eq!(Algorithm::Ps256.identifier(), -37); /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Algorithm { /// HMAC with SHA-256 (COSE algorithm identifier: 5) HmacSha256, + /// ECDSA using P-256 curve and SHA-256 (COSE algorithm identifier: -7) + Es256, + /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (COSE algorithm identifier: -37) + Ps256, } impl Algorithm { @@ -43,6 +55,8 @@ impl Algorithm { pub fn identifier(&self) -> i32 { match self { Algorithm::HmacSha256 => cose_algs::HMAC_SHA_256, + Algorithm::Es256 => cose_algs::ES256, + Algorithm::Ps256 => cose_algs::PS256, } } @@ -50,9 +64,18 @@ impl Algorithm { pub fn from_identifier(id: i32) -> Option { match id { cose_algs::HMAC_SHA_256 => Some(Algorithm::HmacSha256), + cose_algs::ES256 => Some(Algorithm::Es256), + cose_algs::PS256 => Some(Algorithm::Ps256), _ => None, } } + + /// Returns `true` if this algorithm is a MAC (symmetric) algorithm that uses + /// the COSE_Mac0 structure. Returns `false` for asymmetric signature + /// algorithms that use the COSE_Sign1 structure. + pub fn is_mac(&self) -> bool { + matches!(self, Algorithm::HmacSha256) + } } /// Key identifier that can be either a binary or string value. diff --git a/src/lib.rs b/src/lib.rs index a655a07..94637a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ //! //! - CBOR-encoded tokens for compact representation //! - Support for both COSE_Sign1 and COSE_Mac0 structures -//! - HMAC-SHA256 authentication +//! - HMAC-SHA256 (HS256) authentication, plus ECDSA P-256 (ES256) and RSASSA-PSS (PS256) signatures //! - Protected and unprotected headers //! - Standard registered claims (issuer, subject, audience, expiration, etc.) //! - Custom claims with string, binary, integer, and nested map values diff --git a/src/tests.rs b/src/tests.rs index 14f5c66..900a631 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1382,3 +1382,296 @@ fn test_signed_integer_in_nested_structures() { panic!("Custom claim 300 not found or has wrong type"); } } + +// --------------------------------------------------------------------------- +// Asymmetric algorithm tests (ES256 / PS256) +// +// The key material below is generated once with OpenSSL and embedded as +// base64-encoded DER so the tests are deterministic and fast (RSA key +// generation at test time would be slow). +// +// ES256 (P-256): +// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out es.pem +// openssl pkcs8 -topk8 -nocrypt -in es.pem -outform DER # private (PKCS#8) +// openssl pkey -in es.pem -pubout -outform DER # public (SPKI) +// +// PS256 (RSA-2048): +// openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -outform DER # private (PKCS#8) +// openssl pkey -inform DER -in ps_priv.der -pubout -outform DER # public (SPKI) +// --------------------------------------------------------------------------- + +/// ES256 PKCS#8 DER private key (base64-encoded). +const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; +/// ES256 SPKI DER public key (base64-encoded), matching the private key above. +const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; + +/// PS256 PKCS#8 DER private key (base64-encoded). +const PS256_PRIVATE_KEY_B64: &str = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCHjA5iauwvo2sRB529iV1c+p+WuGFzk5EUGFFLYoIHxAwo/rSmZ2/D00epwb4WzOxA4c8+1QA+0rZIN35Fti9Wiunt0b1DgC0tuSglNzpEE5gjhTDcAWZOBPOCMt9pKEuuQC4eqBRxPoG5Y14dVi46/aQOQSqU5I0T3cbeLliTzjXkrvdqySFXMGpM9/I469SZRxZbDgB8wUcB2nTIuwOokjN/Vp+BpMM5QmR66J6aFNi8LqCmQv3grUI1kM1fqrC3az/YcyXcDvjinagyXsGYgW2ZpXIf2760UXv/bASAOO01sgI8zxbIDdG6Vd+7iPhr4b/v6QIj6rpuURFfns2LAgMBAAECggEAH9CdXbdYCZRzYHNnsGGqEtVWmQNdCEo2Lr/IcQfFmnoHGqYyE67Kmm2gb/VkHyjpOQ9nXAmVvakqlMfFsSoicU84uhPVNx9CO22uwRF18R2iQ5ATGEiR0TUzTLeRHbcSEGvLB3IPHkd8Hl327K7aOglntNrR2lHM1UFkWKkLLGHObPoLBSTQLjX5JkvtpUuBgnPVlfBUc5al9+CH+m/SiC4BvVWo4hiHEKCQgMIQ/Dh8UtS9Vk91FIizqKpqBXE6+PNmAnn9ZwRjZoRNBSLn0paAyiEXXdr5rV8zeYU0ktY40J9qWEFOJmTYII4pUK1U8tukrQ0w4LUm17f8zMkufQKBgQC7MGcSrbFWVjlEwA760sG6NKOZb5sL+2etIVAJyfSoGrwr8H4aQA1WFP+pmmlCWsLZj8qfTYSyocwfT/p9aY9Na7ftyks+q1QSsDF+D7frgxmITJeCSwiPa7jnOTrmReqAEOyPn8IlytHIhJbaPxzDxPf572QIAIBgsWhdygn21QKBgQC5X9agS0u2Joypz36ZIilbgbtgmSvFAE/22U0il+3GgXQbjmxPCip1UZm1cBgmLhq12bxU1xYxJpGVPWhEsmkIrOkEfNf/RYlSvVLzbuZLQxeB1g5FDrFb1EbaegrFznv/rFonyXMeRyJ7PHtDttfN5jxNTxTiV3BQ4uobgsai3wKBgFEiW6q26mSXnt7zuApzi1CgPEDnJPb+kyNxivWTOZ4baHBLHv1VwfILy/zBVtpR6J7QOmzt9pROmOEBk3sEY/6Ur/Y7dn3FWP14rRsMyRUlj82KFSl+SEmR0WU3YxYoO8oii8Z84nPrAx68iX4zWM5p82m7n0nwnbRLcQcl6Ue5AoGACjVN42viEnjS/DLx/MrVzjU5tVsZ/vJCdQyIY+RL8seENlREgKHFrso8lbJDki6tx9/isCVcEn7WO4qzKD1O7WxgNKAPYP5aTpUgcUllIzXhoIPCK2lguPbapANefoAdcfnyyQgd78fpDTJKc3MpNSx9m6BEPSalh77HN5afC68CgYBSHR2vz1GuUzHSgU+3xKqGSc+jlroetJ1dC5913Z+9eawW7QrRfmSod+JfEiJSw8eS+5/rGYjKihMtNPyqzadRvZtp0QGZrrm1k1/vqqeeH5Uq6AgH/2Djql4tUvC3gmgpHjY7RyPDv6v+u+L9C6MP0Nu5vVfQwpAmX9bsjn/Tjw=="; +/// PS256 SPKI DER public key (base64-encoded), matching the private key above. +const PS256_PUBLIC_KEY_B64: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAh4wOYmrsL6NrEQedvYldXPqflrhhc5ORFBhRS2KCB8QMKP60pmdvw9NHqcG+FszsQOHPPtUAPtK2SDd+RbYvVorp7dG9Q4AtLbkoJTc6RBOYI4Uw3AFmTgTzgjLfaShLrkAuHqgUcT6BuWNeHVYuOv2kDkEqlOSNE93G3i5Yk8415K73askhVzBqTPfyOOvUmUcWWw4AfMFHAdp0yLsDqJIzf1afgaTDOUJkeuiemhTYvC6gpkL94K1CNZDNX6qwt2s/2HMl3A744p2oMl7BmIFtmaVyH9u+tFF7/2wEgDjtNbICPM8WyA3RulXfu4j4a+G/7+kCI+q6blERX57NiwIDAQAB"; + +fn es256_keys() -> (Vec, Vec) { + use ct_codecs::{Base64, Decoder}; + ( + Base64::decode_to_vec(ES256_PRIVATE_KEY_B64, None).expect("valid ES256 private key"), + Base64::decode_to_vec(ES256_PUBLIC_KEY_B64, None).expect("valid ES256 public key"), + ) +} + +fn ps256_keys() -> (Vec, Vec) { + use ct_codecs::{Base64, Decoder}; + ( + Base64::decode_to_vec(PS256_PRIVATE_KEY_B64, None).expect("valid PS256 private key"), + Base64::decode_to_vec(PS256_PUBLIC_KEY_B64, None).expect("valid PS256 public key"), + ) +} + +fn build_signed_token(alg: Algorithm, private_key: &[u8]) -> Token { + TokenBuilder::new() + .algorithm(alg) + .protected_key_id(KeyId::string("asym-key-1")) + .registered_claims( + RegisteredClaims::new() + .with_issuer("issuer") + .with_subject("subject") + .with_audience("audience") + .with_expiration(current_timestamp() + 3600) + .with_not_before(current_timestamp()) + .with_issued_at(current_timestamp()), + ) + .custom_string(100, "custom-string-value") + .custom_int(102, 12345) + .sign(private_key) + .expect("Failed to sign token") +} + +#[test] +fn test_es256_sign_and_verify() { + let (private_key, public_key) = es256_keys(); + + let token = build_signed_token(Algorithm::Es256, &private_key); + + // ES256 signatures are the fixed 64-byte COSE form (r || s). + assert_eq!( + token.signature.len(), + 64, + "ES256 signature should be 64 bytes" + ); + assert_eq!(token.header.algorithm(), Some(Algorithm::Es256)); + + // Round-trip through encoding. + let token_bytes = token.to_bytes().expect("Failed to encode token"); + let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); + + decoded + .verify(&public_key) + .expect("Failed to verify ES256 signature"); + + let options = VerificationOptions::new() + .verify_exp(true) + .verify_nbf(true) + .expected_issuer("issuer") + .expected_audience("audience"); + decoded + .verify_claims(&options) + .expect("Failed to verify claims"); + + assert_eq!(decoded.get_custom_string(100), Some("custom-string-value")); + assert_eq!(decoded.get_custom_int(102), Some(12345)); +} + +#[test] +fn test_ps256_sign_and_verify() { + let (private_key, public_key) = ps256_keys(); + + let token = build_signed_token(Algorithm::Ps256, &private_key); + assert_eq!(token.header.algorithm(), Some(Algorithm::Ps256)); + // RSA-2048 PSS signature is 256 bytes. + assert_eq!( + token.signature.len(), + 256, + "PS256 signature should be 256 bytes" + ); + + let token_bytes = token.to_bytes().expect("Failed to encode token"); + let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); + + decoded + .verify(&public_key) + .expect("Failed to verify PS256 signature"); + + let options = VerificationOptions::new() + .verify_exp(true) + .verify_nbf(true) + .expected_issuer("issuer") + .expected_audience("audience"); + decoded + .verify_claims(&options) + .expect("Failed to verify claims"); +} + +#[test] +fn test_es256_uses_cose_sign1_tags() { + let (private_key, _public_key) = es256_keys(); + let token = build_signed_token(Algorithm::Es256, &private_key); + let token_bytes = token.to_bytes().expect("Failed to encode token"); + + // Asymmetric algorithms must use COSE_Sign1 (tag 18) under the CWT tag (61). + assert_eq!( + token_bytes[0], 0xd8, + "First byte should be CBOR tag indicator" + ); + assert_eq!(token_bytes[1], 0x3d, "Should have tag 61 (CWT)"); + assert_eq!(token_bytes[2], 0xd2, "Should have tag 18 (COSE_Sign1)"); + assert_eq!( + token_bytes[3], 0x84, + "Should be followed by 4-element array" + ); +} + +#[test] +fn test_ps256_uses_cose_sign1_tags() { + let (private_key, _public_key) = ps256_keys(); + let token = build_signed_token(Algorithm::Ps256, &private_key); + let token_bytes = token.to_bytes().expect("Failed to encode token"); + + assert_eq!( + token_bytes[0], 0xd8, + "First byte should be CBOR tag indicator" + ); + assert_eq!(token_bytes[1], 0x3d, "Should have tag 61 (CWT)"); + assert_eq!(token_bytes[2], 0xd2, "Should have tag 18 (COSE_Sign1)"); + assert_eq!( + token_bytes[3], 0x84, + "Should be followed by 4-element array" + ); +} + +#[test] +fn test_es256_wrong_key_fails() { + let (private_key, _public_key) = es256_keys(); + // A different, freshly generated P-256 public key that does NOT match. + let wrong_public_key = ct_codecs::Base64::decode_to_vec( + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqM7q0vY3RfQ8vJpV4hQ4Z0H8K7m2xZ1k9d0c8N3vWf6m1pQ2rX5sT8uY9wA0bC1dE2fG3hI4jK5lM6nO7pPqQ==", + None, + ); + // Even if decoding the bogus key fails or it's structurally invalid, the + // point is verification must not succeed against the real signature. + let token = build_signed_token(Algorithm::Es256, &private_key); + let token_bytes = token.to_bytes().expect("Failed to encode token"); + let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); + + if let Ok(wrong_key) = wrong_public_key { + assert!( + decoded.verify(&wrong_key).is_err(), + "Verification should fail with a non-matching public key" + ); + } + + // Verifying against an unrelated but valid PS256 public key must also fail. + let (_ps_priv, ps_pub) = ps256_keys(); + assert!( + decoded.verify(&ps_pub).is_err(), + "ES256 token should not verify against an RSA public key" + ); +} + +#[test] +fn test_ps256_wrong_key_fails() { + let (private_key, _public_key) = ps256_keys(); + let token = build_signed_token(Algorithm::Ps256, &private_key); + 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. + let (_es_priv, es_pub) = es256_keys(); + assert!( + decoded.verify(&es_pub).is_err(), + "PS256 token should not verify against an EC public key" + ); +} + +#[test] +fn test_es256_tampered_payload_fails() { + let (private_key, public_key) = es256_keys(); + let token = build_signed_token(Algorithm::Es256, &private_key); + let mut token_bytes = token.to_bytes().expect("Failed to encode token"); + + // Flip a byte near the end of the payload region (before the signature). + let idx = token_bytes.len() - 70; + token_bytes[idx] ^= 0xFF; + + // Decoding may still succeed structurally, but verification must fail. + if let Ok(decoded) = Token::from_bytes(&token_bytes) { + assert!( + decoded.verify(&public_key).is_err(), + "Verification should fail for a tampered ES256 token" + ); + } +} + +#[test] +fn test_ps256_signatures_are_randomized() { + // PSS uses a random salt, so two signatures over the same input differ, + // yet both must verify. + let (private_key, public_key) = ps256_keys(); + + let token_a = build_signed_token(Algorithm::Ps256, &private_key); + let token_b = build_signed_token(Algorithm::Ps256, &private_key); + + assert_ne!( + token_a.signature, token_b.signature, + "PSS signatures should differ due to random salt" + ); + + token_a.verify(&public_key).expect("token_a should verify"); + token_b.verify(&public_key).expect("token_b should verify"); +} + +#[test] +fn test_es256_invalid_private_key_errors() { + let result = TokenBuilder::new() + .algorithm(Algorithm::Es256) + .registered_claims(RegisteredClaims::new().with_issuer("issuer")) + .sign(b"not-a-valid-der-key"); + assert!( + matches!(result, Err(crate::error::Error::InvalidKey(_))), + "Signing with an invalid ES256 key should yield InvalidKey" + ); +} + +#[test] +fn test_ps256_invalid_private_key_errors() { + let result = TokenBuilder::new() + .algorithm(Algorithm::Ps256) + .registered_claims(RegisteredClaims::new().with_issuer("issuer")) + .sign(b"not-a-valid-der-key"); + assert!( + matches!(result, Err(crate::error::Error::InvalidKey(_))), + "Signing with an invalid PS256 key should yield InvalidKey" + ); +} + +#[test] +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"); + assert!( + matches!(result, Err(crate::error::Error::InvalidKey(_))), + "Verifying with an invalid ES256 public key should yield InvalidKey" + ); +} + +#[test] +fn test_algorithm_identifier_roundtrip() { + for alg in [Algorithm::HmacSha256, Algorithm::Es256, Algorithm::Ps256] { + let id = alg.identifier(); + assert_eq!(Algorithm::from_identifier(id), Some(alg)); + } + assert_eq!(Algorithm::Es256.identifier(), -7); + assert_eq!(Algorithm::Ps256.identifier(), -37); + assert!(!Algorithm::Es256.is_mac()); + assert!(!Algorithm::Ps256.is_mac()); + assert!(Algorithm::HmacSha256.is_mac()); +} diff --git a/src/token.rs b/src/token.rs index 1619ffd..9770a30 100644 --- a/src/token.rs +++ b/src/token.rs @@ -4,7 +4,10 @@ use crate::claims::{Claims, RegisteredClaims}; use crate::constants::tprint_params; use crate::error::Error; use crate::header::{Algorithm, CborValue, Header, HeaderMap, KeyId}; -use crate::utils::{compute_hmac_sha256, current_timestamp, verify_hmac_sha256}; +use crate::utils::{ + compute_es256, compute_hmac_sha256, compute_ps256, current_timestamp, verify_es256, + verify_hmac_sha256, verify_ps256, +}; use crate::FingerprintType; use minicbor::{Decoder, Encoder}; use std::collections::BTreeMap; @@ -40,12 +43,23 @@ impl Token { let mut buf = Vec::new(); let mut enc = Encoder::new(&mut buf); - // For HMAC algorithms, use COSE_Mac0 format with CWT tag - if let Some(Algorithm::HmacSha256) = self.header.algorithm() { - // Apply CWT tag (61) - enc.tag(minicbor::data::Tag::new(61))?; - // Apply COSE_Mac0 tag (17) - enc.tag(minicbor::data::Tag::new(17))?; + // Apply the CWT tag (61) followed by the COSE structure tag. HMAC (MAC) + // algorithms use COSE_Mac0 (tag 17); asymmetric signature algorithms use + // COSE_Sign1 (tag 18). + match self.header.algorithm() { + Some(alg) if alg.is_mac() => { + // Apply CWT tag (61) + enc.tag(minicbor::data::Tag::new(61))?; + // Apply COSE_Mac0 tag (17) + enc.tag(minicbor::data::Tag::new(17))?; + } + Some(_) => { + // Apply CWT tag (61) + enc.tag(minicbor::data::Tag::new(61))?; + // Apply COSE_Sign1 tag (18) + enc.tag(minicbor::data::Tag::new(18))?; + } + None => {} } // COSE structure array with 4 items @@ -127,9 +141,14 @@ impl Token { /// Verify the token signature /// - /// This function supports both COSE_Sign1 and COSE_Mac0 structures. - /// It will first try to verify the signature using the COSE_Sign1 structure, - /// and if that fails, it will try the COSE_Mac0 structure. + /// The structure used depends on the algorithm in the protected header: + /// + /// - **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> { let alg = self.header.algorithm().ok_or_else(|| { Error::InvalidFormat("Missing algorithm in protected header".to_string()) @@ -149,6 +168,14 @@ impl Token { let mac0_input = self.mac0_input()?; verify_hmac_sha256(key, &mac0_input, &self.signature) } + Algorithm::Es256 => { + let sign1_input = self.sign1_input()?; + verify_es256(key, &sign1_input, &self.signature) + } + Algorithm::Ps256 => { + let sign1_input = self.sign1_input()?; + verify_ps256(key, &sign1_input, &self.signature) + } } } @@ -1366,6 +1393,12 @@ impl TokenBuilder { } /// Build and sign the token + /// + /// The `key` is interpreted according to the algorithm in the protected header: + /// + /// - **HmacSha256**: the raw symmetric MAC key bytes. + /// - **Es256**: a PKCS#8 DER-encoded P-256 private key. + /// - **Ps256**: a PKCS#8 DER-encoded RSA private key. pub fn sign(self, key: &[u8]) -> Result { // Ensure we have an algorithm let alg = self.header.algorithm().ok_or_else(|| { @@ -1380,14 +1413,25 @@ impl TokenBuilder { original_payload_bytes: None, }; - // Compute signature input based on algorithm - // HMAC algorithms use COSE_Mac0 structure, others use COSE_Sign1 + // Compute signature input based on algorithm. + // HMAC (MAC) algorithms use the COSE_Mac0 structure; asymmetric signature + // algorithms use the COSE_Sign1 structure. let (_signature_input, signature) = match alg { Algorithm::HmacSha256 => { let mac_input = token.mac0_input()?; let mac = compute_hmac_sha256(key, &mac_input); (mac_input, mac) } + Algorithm::Es256 => { + let sign_input = token.sign1_input()?; + let sig = compute_es256(key, &sign_input)?; + (sign_input, sig) + } + Algorithm::Ps256 => { + let sign_input = token.sign1_input()?; + let sig = compute_ps256(key, &sign_input)?; + (sign_input, sig) + } }; // Create final token with signature diff --git a/src/utils.rs b/src/utils.rs index 977aba7..51ac4b7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,6 +19,75 @@ pub fn verify_hmac_sha256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<( } } +/// Compute an ES256 (ECDSA P-256 + SHA-256) signature over `data`. +/// +/// `key` must be a PKCS#8 DER-encoded P-256 private key. The returned signature +/// is the fixed-length 64-byte COSE representation (`r || s`), as required by the +/// COSE specification (RFC 8152 §8.1). +pub fn compute_es256(key: &[u8], data: &[u8]) -> Result, Error> { + use p256::ecdsa::{signature::Signer, Signature, SigningKey}; + use p256::pkcs8::DecodePrivateKey; + + let signing_key = SigningKey::from_pkcs8_der(key) + .map_err(|e| Error::InvalidKey(format!("Invalid ES256 private key: {e}")))?; + // ECDSA signing with the RustCrypto `ecdsa` crate is deterministic (RFC 6979), + // so no RNG is required here. + let signature: Signature = signing_key.sign(data); + Ok(signature.to_bytes().to_vec()) +} + +/// Verify an ES256 (ECDSA P-256 + SHA-256) signature over `data`. +/// +/// `key` must be an SPKI DER-encoded P-256 public key. `signature` must be the +/// fixed-length 64-byte COSE representation (`r || s`). +pub fn verify_es256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Error> { + use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; + use p256::pkcs8::DecodePublicKey; + + let verifying_key = VerifyingKey::from_public_key_der(key) + .map_err(|e| Error::InvalidKey(format!("Invalid ES256 public key: {e}")))?; + let signature = Signature::from_slice(signature).map_err(|_| Error::SignatureVerification)?; + verifying_key + .verify(data, &signature) + .map_err(|_| Error::SignatureVerification) +} + +/// Compute a PS256 (RSASSA-PSS with SHA-256 and MGF1-SHA-256) signature over `data`. +/// +/// `key` must be a PKCS#8 DER-encoded RSA private key. PSS uses a random salt, so +/// an OS-provided RNG is used (WASM-friendly via `getrandom`). +pub fn compute_ps256(key: &[u8], data: &[u8]) -> Result, Error> { + use rsa::pkcs8::DecodePrivateKey; + use rsa::pss::SigningKey; + use rsa::signature::{RandomizedSigner, SignatureEncoding}; + use rsa::RsaPrivateKey; + + let private_key = RsaPrivateKey::from_pkcs8_der(key) + .map_err(|e| Error::InvalidKey(format!("Invalid PS256 private key: {e}")))?; + let signing_key = SigningKey::::new(private_key); + let mut rng = rand_core::OsRng; + let signature = signing_key.sign_with_rng(&mut rng, data); + Ok(signature.to_vec()) +} + +/// Verify a PS256 (RSASSA-PSS with SHA-256 and MGF1-SHA-256) signature over `data`. +/// +/// `key` must be an SPKI DER-encoded RSA public key. +pub fn verify_ps256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Error> { + use rsa::pkcs8::DecodePublicKey; + use rsa::pss::{Signature, VerifyingKey}; + use rsa::signature::Verifier; + use rsa::RsaPublicKey; + + let public_key = RsaPublicKey::from_public_key_der(key) + .map_err(|e| Error::InvalidKey(format!("Invalid PS256 public key: {e}")))?; + let verifying_key = VerifyingKey::::new(public_key); + let signature = Signature::try_from(signature).map_err(|_| Error::SignatureVerification)?; + verifying_key + .verify(data, &signature) + .map_err(|_| Error::SignatureVerification) +} + /// Get current timestamp in seconds since Unix epoch pub fn current_timestamp() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; From 0a4562ab512d626027d0fa24104418a71d5664dc Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Mon, 22 Jun 2026 14:46:46 -0500 Subject: [PATCH 02/16] Add example that mints sample ES256/PS256 tokens Generates ES256 and PS256 tokens with a ~10-year expiration and prints them in hex and base64url, alongside their public keys. Each token is self-verified (signature and claims) before printing. Co-Authored-By: Claude Opus 4.8 --- examples/sample_es256_ps256_tokens.rs | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 examples/sample_es256_ps256_tokens.rs diff --git a/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs new file mode 100644 index 0000000..7a7b97b --- /dev/null +++ b/examples/sample_es256_ps256_tokens.rs @@ -0,0 +1,103 @@ +//! Mint sample ES256 and PS256 tokens with a ~10-year expiration and print +//! them in hex and base64url. +//! Run with: `cargo run --example sample_es256_ps256_tokens`. +//! +//! The key pairs are the same ones used by the test suite (PKCS#8 DER private +//! keys, SPKI DER public keys, base64-encoded here so the example is +//! self-contained). The public keys are printed so the tokens can be verified +//! elsewhere. + +use common_access_token::{ + current_timestamp, Algorithm, KeyId, RegisteredClaims, Token, TokenBuilder, VerificationOptions, +}; +use ct_codecs::{Base64, Base64UrlSafeNoPadding, Decoder, Encoder, Hex}; + +const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; +const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; + +const PS256_PRIVATE_KEY_B64: &str = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCHjA5iauwvo2sRB529iV1c+p+WuGFzk5EUGFFLYoIHxAwo/rSmZ2/D00epwb4WzOxA4c8+1QA+0rZIN35Fti9Wiunt0b1DgC0tuSglNzpEE5gjhTDcAWZOBPOCMt9pKEuuQC4eqBRxPoG5Y14dVi46/aQOQSqU5I0T3cbeLliTzjXkrvdqySFXMGpM9/I469SZRxZbDgB8wUcB2nTIuwOokjN/Vp+BpMM5QmR66J6aFNi8LqCmQv3grUI1kM1fqrC3az/YcyXcDvjinagyXsGYgW2ZpXIf2760UXv/bASAOO01sgI8zxbIDdG6Vd+7iPhr4b/v6QIj6rpuURFfns2LAgMBAAECggEAH9CdXbdYCZRzYHNnsGGqEtVWmQNdCEo2Lr/IcQfFmnoHGqYyE67Kmm2gb/VkHyjpOQ9nXAmVvakqlMfFsSoicU84uhPVNx9CO22uwRF18R2iQ5ATGEiR0TUzTLeRHbcSEGvLB3IPHkd8Hl327K7aOglntNrR2lHM1UFkWKkLLGHObPoLBSTQLjX5JkvtpUuBgnPVlfBUc5al9+CH+m/SiC4BvVWo4hiHEKCQgMIQ/Dh8UtS9Vk91FIizqKpqBXE6+PNmAnn9ZwRjZoRNBSLn0paAyiEXXdr5rV8zeYU0ktY40J9qWEFOJmTYII4pUK1U8tukrQ0w4LUm17f8zMkufQKBgQC7MGcSrbFWVjlEwA760sG6NKOZb5sL+2etIVAJyfSoGrwr8H4aQA1WFP+pmmlCWsLZj8qfTYSyocwfT/p9aY9Na7ftyks+q1QSsDF+D7frgxmITJeCSwiPa7jnOTrmReqAEOyPn8IlytHIhJbaPxzDxPf572QIAIBgsWhdygn21QKBgQC5X9agS0u2Joypz36ZIilbgbtgmSvFAE/22U0il+3GgXQbjmxPCip1UZm1cBgmLhq12bxU1xYxJpGVPWhEsmkIrOkEfNf/RYlSvVLzbuZLQxeB1g5FDrFb1EbaegrFznv/rFonyXMeRyJ7PHtDttfN5jxNTxTiV3BQ4uobgsai3wKBgFEiW6q26mSXnt7zuApzi1CgPEDnJPb+kyNxivWTOZ4baHBLHv1VwfILy/zBVtpR6J7QOmzt9pROmOEBk3sEY/6Ur/Y7dn3FWP14rRsMyRUlj82KFSl+SEmR0WU3YxYoO8oii8Z84nPrAx68iX4zWM5p82m7n0nwnbRLcQcl6Ue5AoGACjVN42viEnjS/DLx/MrVzjU5tVsZ/vJCdQyIY+RL8seENlREgKHFrso8lbJDki6tx9/isCVcEn7WO4qzKD1O7WxgNKAPYP5aTpUgcUllIzXhoIPCK2lguPbapANefoAdcfnyyQgd78fpDTJKc3MpNSx9m6BEPSalh77HN5afC68CgYBSHR2vz1GuUzHSgU+3xKqGSc+jlroetJ1dC5913Z+9eawW7QrRfmSod+JfEiJSw8eS+5/rGYjKihMtNPyqzadRvZtp0QGZrrm1k1/vqqeeH5Uq6AgH/2Djql4tUvC3gmgpHjY7RyPDv6v+u+L9C6MP0Nu5vVfQwpAmX9bsjn/Tjw=="; +const PS256_PUBLIC_KEY_B64: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAh4wOYmrsL6NrEQedvYldXPqflrhhc5ORFBhRS2KCB8QMKP60pmdvw9NHqcG+FszsQOHPPtUAPtK2SDd+RbYvVorp7dG9Q4AtLbkoJTc6RBOYI4Uw3AFmTgTzgjLfaShLrkAuHqgUcT6BuWNeHVYuOv2kDkEqlOSNE93G3i5Yk8415K73askhVzBqTPfyOOvUmUcWWw4AfMFHAdp0yLsDqJIzf1afgaTDOUJkeuiemhTYvC6gpkL94K1CNZDNX6qwt2s/2HMl3A744p2oMl7BmIFtmaVyH9u+tFF7/2wEgDjtNbICPM8WyA3RulXfu4j4a+G/7+kCI+q6blERX57NiwIDAQAB"; + +// ~10 years in seconds (10 * 365.25 days). +const TEN_YEARS_SECS: u64 = 315_576_000; + +fn main() { + let now = current_timestamp(); + let exp = now + TEN_YEARS_SECS; + println!("issued_at (iat): {now}"); + println!("expiration (exp): {exp} (~10 years from now)\n"); + + mint( + "ES256 (ECDSA P-256 + SHA-256)", + Algorithm::Es256, + ES256_PRIVATE_KEY_B64, + ES256_PUBLIC_KEY_B64, + "es256-sample-key", + now, + exp, + ); + + mint( + "PS256 (RSASSA-PSS + SHA-256)", + Algorithm::Ps256, + PS256_PRIVATE_KEY_B64, + PS256_PUBLIC_KEY_B64, + "ps256-sample-key", + now, + exp, + ); +} + +#[allow(clippy::too_many_arguments)] +fn mint( + name: &str, + alg: Algorithm, + private_key_b64: &str, + public_key_b64: &str, + kid: &str, + iat: u64, + exp: u64, +) { + let private_key = Base64::decode_to_vec(private_key_b64, None).expect("valid private key"); + let public_key = Base64::decode_to_vec(public_key_b64, None).expect("valid public key"); + + let token = TokenBuilder::new() + .algorithm(alg) + .protected_key_id(KeyId::string(kid)) + .registered_claims( + RegisteredClaims::new() + .with_issuer("example-issuer") + .with_subject("example-subject") + .with_audience("example-audience") + .with_issued_at(iat) + .with_not_before(iat) + .with_expiration(exp), + ) + .sign(&private_key) + .expect("Failed to sign token"); + + let token_bytes = token.to_bytes().expect("Failed to encode token"); + let hex = Hex::encode_to_string(&token_bytes).expect("hex encode"); + let b64url = Base64UrlSafeNoPadding::encode_to_string(&token_bytes).expect("b64url encode"); + + // 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"); + decoded + .verify_claims( + &VerificationOptions::new() + .verify_exp(true) + .verify_nbf(true) + .expected_issuer("example-issuer") + .expected_audience("example-audience"), + ) + .expect("verify claims"); + + println!("=== {name} ==="); + println!("kid: {kid}"); + println!("public key (SPKI DER, base64): {public_key_b64}"); + println!("token length: {} bytes", token_bytes.len()); + println!("token (hex): {hex}"); + println!("token (b64url): {b64url}"); + println!(); +} From db41b805f7c1cdfa482a25f6fcc84bd9eca29512 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Wed, 24 Jun 2026 09:37:12 -0600 Subject: [PATCH 03/16] Address PR review: fix MSRV, harden tests, warn on demo keys The declared MSRV of 1.72 was inaccurate: the RustCrypto stack pulls zeroize 1.9 (edition 2024) and minicbor-derive 0.19.4 uses let-chains, so the crate cannot build below Rust 1.88 once deps resolve to latest. Since Cargo.lock is not committed, downstream users hit this directly. - Set rust-version to 1.88.0 (the real minimum) and add a pinned MSRV CI job so the contract can't silently rot again. - Add DO-NOT-USE-IN-PRODUCTION warnings next to the demo private keys in both examples. - Fix test_es256_wrong_key_fails: the old bogus key was invalid base64, so the wrong-key assertion never ran behind `if let Ok(...)`. Use a real non-matching P-256 SPKI key and decode with expect(). - Use checked_sub() in test_es256_tampered_payload_fails to avoid a panic-on-underflow. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 15 +++++++++++++++ Cargo.toml | 2 +- examples/asymmetric_signing.rs | 4 ++++ examples/sample_es256_ps256_tokens.rs | 5 +++++ src/tests.rs | 27 +++++++++++++++------------ 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fd45e0..769d0d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,18 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + + msrv: + # Verify the crate actually builds with the MSRV declared in Cargo.toml. + # The dependency graph (RustCrypto + minicbor-derive) requires a recent + # compiler, so this guards against the declared rust-version going stale. + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@1.88.0 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index ebe495a..f25e7c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/fastly/rust-cat" readme = "README.md" keywords = ["token", "authentication", "authorization", "cbor", "cose"] categories = ["authentication", "cryptography", "web-programming"] -rust-version = "1.72.0" +rust-version = "1.88.0" [dependencies] minicbor = { version = "2.1.3", features = ["derive", "std"] } diff --git a/examples/asymmetric_signing.rs b/examples/asymmetric_signing.rs index 9108f49..da9ef48 100644 --- a/examples/asymmetric_signing.rs +++ b/examples/asymmetric_signing.rs @@ -27,6 +27,10 @@ use common_access_token::{ }; use ct_codecs::{Base64, Decoder}; +// ⚠️ DEMO KEYS — DO NOT USE IN PRODUCTION. +// These private keys are committed to a public repository and are therefore +// publicly known. They exist only so this example is self-contained. Generate +// your own key pair (see the OpenSSL commands above) for any real use. const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; diff --git a/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs index 7a7b97b..507765f 100644 --- a/examples/sample_es256_ps256_tokens.rs +++ b/examples/sample_es256_ps256_tokens.rs @@ -12,6 +12,11 @@ use common_access_token::{ }; use ct_codecs::{Base64, Base64UrlSafeNoPadding, Decoder, Encoder, Hex}; +// ⚠️ DEMO KEYS — DO NOT USE IN PRODUCTION. +// These private keys are committed to a public repository (they are the same +// keys used by the test suite) and are therefore publicly known. They exist +// only so this example is self-contained. Generate your own key pair for any +// real use. const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; diff --git a/src/tests.rs b/src/tests.rs index 900a631..af20473 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1550,23 +1550,23 @@ fn test_ps256_uses_cose_sign1_tags() { #[test] fn test_es256_wrong_key_fails() { let (private_key, _public_key) = es256_keys(); - // A different, freshly generated P-256 public key that does NOT match. + // A different, valid P-256 public key (SPKI DER) that does NOT match the + // signing key. Decoding must succeed so the wrong-key path is always + // exercised. let wrong_public_key = ct_codecs::Base64::decode_to_vec( - "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqM7q0vY3RfQ8vJpV4hQ4Z0H8K7m2xZ1k9d0c8N3vWf6m1pQ2rX5sT8uY9wA0bC1dE2fG3hI4jK5lM6nO7pPqQ==", + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElkhvSdit+RZ8AdhbXRhGVYDI2ZNfZjZJkufNFB+xYGCR+MwpsILkSP3AVN51C5xG/JtwVcUTDekjURgBYsuDPA==", None, - ); - // Even if decoding the bogus key fails or it's structurally invalid, the - // point is verification must not succeed against the real signature. + ) + .expect("wrong public key should be valid base64"); + let token = build_signed_token(Algorithm::Es256, &private_key); let token_bytes = token.to_bytes().expect("Failed to encode token"); let decoded = Token::from_bytes(&token_bytes).expect("Failed to decode token"); - if let Ok(wrong_key) = wrong_public_key { - assert!( - decoded.verify(&wrong_key).is_err(), - "Verification should fail with a non-matching public key" - ); - } + assert!( + decoded.verify(&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. let (_ps_priv, ps_pub) = ps256_keys(); @@ -1598,7 +1598,10 @@ fn test_es256_tampered_payload_fails() { let mut token_bytes = token.to_bytes().expect("Failed to encode token"); // Flip a byte near the end of the payload region (before the signature). - let idx = token_bytes.len() - 70; + let idx = token_bytes + .len() + .checked_sub(70) + .expect("encoded token should be at least 70 bytes"); token_bytes[idx] ^= 0xFF; // Decoding may still succeed structurally, but verification must fail. From 7b8588464f79f3561127100b0829802cfc1b5768 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Wed, 24 Jun 2026 09:49:01 -0600 Subject: [PATCH 04/16] Clarify to_bytes tagging comment for the untagged (no-algorithm) case Co-Authored-By: Claude Opus 4.8 --- src/token.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/token.rs b/src/token.rs index 9770a30..10201fc 100644 --- a/src/token.rs +++ b/src/token.rs @@ -45,7 +45,9 @@ impl Token { // Apply the CWT tag (61) followed by the COSE structure tag. HMAC (MAC) // algorithms use COSE_Mac0 (tag 17); asymmetric signature algorithms use - // COSE_Sign1 (tag 18). + // COSE_Sign1 (tag 18). If the header carries no algorithm, the bare COSE + // array is emitted untagged (`from_bytes` accepts both tagged and + // untagged input). match self.header.algorithm() { Some(alg) if alg.is_mac() => { // Apply CWT tag (61) From 158bb1730bbda82d41bc88f163c35936b580c439 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Wed, 24 Jun 2026 15:04:19 -0600 Subject: [PATCH 05/16] Address review: 0.3.0 breaking release + tighten tamper test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MSRV bump and the new Algorithm/Error variants are source-breaking for downstream users, so this cannot ship as a 0.2.x patch: - Bump version to 0.3.0 (and README dep requirement to "0.3") to make the breaking release intentional. - Mark Algorithm and Error #[non_exhaustive] so future variant additions no longer break downstream exhaustive matches. Also tighten test_es256_tampered_payload_fails: it now mutates a byte inside the custom string claim (preserving CBOR/UTF-8 structure), asserts decoding still succeeds, and then asserts verification fails — so the test can no longer pass without exercising signature rejection. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 2 +- README.md | 2 +- src/error.rs | 5 +++++ src/header.rs | 5 +++++ src/tests.rs | 36 ++++++++++++++++++++++-------------- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f25e7c4..ba912de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common-access-token" -version = "0.2.7" +version = "0.3.0" edition = "2021" description = "Implementation of the Common Access Token (CAT) specification" authors = ["Frank Denis"] diff --git a/README.md b/README.md index 17b2eb8..6bb4528 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -common-access-token = "0.2" +common-access-token = "0.3" ``` ## Usage diff --git a/src/error.rs b/src/error.rs index cc4604c..637babd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,12 @@ use std::io::Error as IoError; use thiserror::Error; /// Errors that can occur when working with Common Access Tokens +/// +/// This enum is marked `#[non_exhaustive]`: new error variants can be added +/// without it being a breaking change, so downstream `match` expressions must +/// include a wildcard arm. #[derive(Error, Debug)] +#[non_exhaustive] pub enum Error { /// Error during CBOR encoding #[error("CBOR encoding error: {0}")] diff --git a/src/header.rs b/src/header.rs index faed649..9abed65 100644 --- a/src/header.rs +++ b/src/header.rs @@ -40,7 +40,12 @@ use std::collections::BTreeMap; /// assert_eq!(Algorithm::Es256.identifier(), -7); /// assert_eq!(Algorithm::Ps256.identifier(), -37); /// ``` +/// +/// This enum is marked `#[non_exhaustive]`: future algorithm support can be +/// added without it being a breaking change, so downstream `match` expressions +/// must include a wildcard arm. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum Algorithm { /// HMAC with SHA-256 (COSE algorithm identifier: 5) HmacSha256, diff --git a/src/tests.rs b/src/tests.rs index af20473..4465070 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1597,20 +1597,28 @@ fn test_es256_tampered_payload_fails() { let token = build_signed_token(Algorithm::Es256, &private_key); let mut token_bytes = token.to_bytes().expect("Failed to encode token"); - // Flip a byte near the end of the payload region (before the signature). - let idx = token_bytes - .len() - .checked_sub(70) - .expect("encoded token should be at least 70 bytes"); - token_bytes[idx] ^= 0xFF; - - // Decoding may still succeed structurally, but verification must fail. - if let Ok(decoded) = Token::from_bytes(&token_bytes) { - assert!( - decoded.verify(&public_key).is_err(), - "Verification should fail for a tampered ES256 token" - ); - } + // Mutate a byte that lives inside the encoded payload but does not alter the + // CBOR structure: the custom string claim "custom-string-value" is stored as + // a text string, so flipping a byte within its contents keeps the token + // structurally decodable while invalidating the signed payload. + let needle = b"custom-string-value"; + let start = token_bytes + .windows(needle.len()) + .position(|w| w == needle) + .expect("custom string claim should be present in the encoded token"); + // Flip a low bit in the middle of the string's contents. XOR with 0x01 keeps + // the byte in the ASCII range so the text string stays valid UTF-8 and the + // token remains structurally decodable; only the signed bytes change. + token_bytes[start + 1] ^= 0x01; + + // Decoding must still succeed (the CBOR structure is intact)... + let decoded = + 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(), + "Verification should fail for a tampered ES256 token" + ); } #[test] From 43cc2abd6a94a3a7102f9cf84929f755fda09ed1 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Wed, 24 Jun 2026 16:14:42 -0600 Subject: [PATCH 06/16] Add CHANGELOG documenting 0.3.0 and prior releases Adopts the Keep a Changelog format and backfills entries for the two prior tagged releases (0.2.6, 0.2.7) from git history. The unreleased 0.3.0 entry documents the ES256/PS256 support and flags the breaking changes: #[non_exhaustive] on Algorithm/Error and the MSRV bump to 1.88. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f07e4a4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - Unreleased + +### Added + +- **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 + verification support, producing/consuming `COSE_Sign1` structures. +- `Algorithm::Es256` and `Algorithm::Ps256` variants, plus their COSE algorithm + identifiers (`-7` and `-37`). +- `Error::InvalidKey` for malformed DER key material. +- `examples/asymmetric_signing.rs` and `examples/sample_es256_ps256_tokens.rs` + demonstrating the new asymmetric algorithms and key formats. + +### Changed + +- **BREAKING:** `Algorithm` and `Error` are now `#[non_exhaustive]`. Downstream + `match` expressions over either enum must include a wildcard arm. This is a + one-time break that allows future variants (additional algorithms or error + cases) to be added without further breakage. +- **BREAKING:** Minimum supported Rust version (MSRV) raised to `1.88.0`, + required by the asymmetric crypto dependencies. +- Tokens are tagged according to their algorithm: HMAC (MAC) algorithms use + `COSE_Mac0` (tag 17) while asymmetric signature algorithms use `COSE_Sign1` + (tag 18). `Token::from_bytes` accepts both tagged and untagged input. + +## [0.2.7] - 2025-11-05 + +### Added + +- **CATTPRINT** (TLS Fingerprint) claim support, including verification of + fingerprint type and value. +- `FingerprintType` enum representing the supported TLS fingerprint types. + +### Changed + +- TLS fingerprint values are matched case-insensitively (compared in lowercase). +- Updated dependencies. + +### Fixed + +- Corrected error messages and comments around fingerprint matching. +- Handle signed integer CBOR types when decoding claims. + +## [0.2.6] - 2025-10-30 + +### Added + +- **CATU** URI component matching for `FILENAME`, `STEM`, and `PARENT_PATH`. +- Additional tests and documentation for CATU URI components. +- GitHub CI workflow. + +### Changed + +- Updated dependencies. + +[0.3.0]: https://github.com/fastly/rust-cat/compare/0.2.7...HEAD +[0.2.7]: https://github.com/fastly/rust-cat/compare/0.2.6...0.2.7 +[0.2.6]: https://github.com/fastly/rust-cat/releases/tag/0.2.6 From 079409edad163047394416dd9f20fe4df109946d Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 09:23:17 -0600 Subject: [PATCH 07/16] =?UTF-8?q?Fix=20COSE=20protected-header=20interop;?= =?UTF-8?q?=20centralize=20algorithm=E2=86=92structure=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related correctness/hardening changes to the COSE encoding, grounded in RFC 9052 (structures) and RFC 9053 (algorithm registry): #1 Preserve and reuse the original protected-header bytes. COSE signs the exact serialized `protected` bstr (RFC 9052 §4.4: a re-encode must be byte-identical), but `sign1_input`/`mac0_input` re-encoded the decoded header map, so verifying a token whose producer used a different-but-valid CBOR encoding (map ordering, integer width) reconstructed different signed bytes and rejected it. HMAC masked this via its Sign1/Mac0 fallback; ES256/PS256 have none. `from_bytes` now stores `original_protected_bytes` and `protected_bytes()`/`to_bytes()` reuse it, mirroring the existing payload-bytes handling. Fixes the interop bug for all algorithms. #2 Add `AlgorithmClass { Mac, Signature }` and `Algorithm::class()` as the single source of truth for the algorithm→structure mapping (RFC 9053 §2/§3, RFC 9052 §8.1/§8.2). `to_bytes()` selects the CWT-nested COSE tag (Mac0=17 / Sign1=18) via an exhaustive match instead of a `Some(_)` catch-all, so a future MAC algorithm can no longer be silently mis-tagged as COSE_Sign1 — adding a variant is now a compile error until its class is declared. `is_mac()` is reimplemented in terms of `class()`. #3 Collapse the byte-identical `sign1_input`/`mac0_input` into one `cose_input(context)` helper; the context string ("Signature1"/"MAC0", RFC 9052 §4.4/§6.3) comes from `AlgorithmClass::context()`, so #2 and #3 share the same source of truth. Tests: non-canonical protected-header verify + byte-faithful re-encode round-trip (ES256); class/context mapping. fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- src/header.rs | 47 +++++++++++++++++- src/lib.rs | 2 +- src/tests.rs | 87 ++++++++++++++++++++++++++++++++- src/token.rs | 130 +++++++++++++++++++++++++++----------------------- 4 files changed, 202 insertions(+), 64 deletions(-) diff --git a/src/header.rs b/src/header.rs index 9abed65..ab3ce8b 100644 --- a/src/header.rs +++ b/src/header.rs @@ -55,6 +55,36 @@ pub enum Algorithm { Ps256, } +/// The COSE class an algorithm belongs to. +/// +/// COSE partitions algorithms into classes with distinct message structures: +/// signature algorithms (RFC 9053 §2) are "used with the COSE_Signature and +/// COSE_Sign1 structures", while MAC algorithms (RFC 9053 §3) are "used in the +/// COSE_Mac and COSE_Mac0 structures" (RFC 9052 §8.1, §8.2). This crate encodes +/// that convention: the class determines both the CBOR tag emitted on the wire +/// (COSE_Mac0 = 17, COSE_Sign1 = 18) and the context string used in the signed +/// input (RFC 9052 §4.4, §6.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlgorithmClass { + /// A MAC algorithm using the COSE_Mac0 structure. + Mac, + /// A digital signature algorithm using the COSE_Sign1 structure. + Signature, +} + +impl AlgorithmClass { + /// The context string for this class's signed/MACed input structure. + /// + /// Per RFC 9052, the `Sig_structure` for COSE_Sign1 uses `"Signature1"` + /// (§4.4) and the `MAC_structure` for COSE_Mac0 uses `"MAC0"` (§6.3). + pub fn context(&self) -> &'static str { + match self { + AlgorithmClass::Mac => "MAC0", + AlgorithmClass::Signature => "Signature1", + } + } +} + impl Algorithm { /// Get the algorithm identifier as defined in the COSE spec pub fn identifier(&self) -> i32 { @@ -75,11 +105,26 @@ impl Algorithm { } } + /// The COSE class this algorithm belongs to. + /// + /// This is the single source of truth for the algorithm → message-structure + /// mapping: MAC algorithms (RFC 9053 §3) use COSE_Mac0, signature algorithms + /// (RFC 9053 §2) use COSE_Sign1. The `match` is exhaustive (no wildcard), so + /// adding a new `Algorithm` variant is a compile error here until its class + /// is declared — preventing a new algorithm from silently defaulting to the + /// wrong structure. + pub fn class(&self) -> AlgorithmClass { + match self { + Algorithm::HmacSha256 => AlgorithmClass::Mac, + Algorithm::Es256 | Algorithm::Ps256 => AlgorithmClass::Signature, + } + } + /// Returns `true` if this algorithm is a MAC (symmetric) algorithm that uses /// the COSE_Mac0 structure. Returns `false` for asymmetric signature /// algorithms that use the COSE_Sign1 structure. pub fn is_mac(&self) -> bool { - matches!(self, Algorithm::HmacSha256) + matches!(self.class(), AlgorithmClass::Mac) } } diff --git a/src/lib.rs b/src/lib.rs index 94637a7..802af76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,7 +148,7 @@ pub use constants::{ replay_values, tprint_params, uri_components, FingerprintType, }; pub use error::Error; -pub use header::{Algorithm, CborValue, Header, HeaderMap, KeyId}; +pub use header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId}; pub use token::{Token, TokenBuilder, VerificationOptions}; pub use utils::current_timestamp; diff --git a/src/tests.rs b/src/tests.rs index 4465070..10599a8 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, CborValue, KeyId}, + header::{Algorithm, AlgorithmClass, CborValue, KeyId}, token::{Token, TokenBuilder, VerificationOptions}, utils::current_timestamp, }; @@ -1674,6 +1674,74 @@ fn test_es256_invalid_public_key_errors() { ); } +/// Regression test for the COSE protected-header interop bug. +/// +/// COSE signs the *exact* encoded `protected` bstr, not a re-encoding of the +/// decoded header map. This test mints an "external" ES256 token whose +/// protected header encodes the `alg` value (-7) in the valid-but-non-canonical +/// 2-byte form (`0x38 0x06`) rather than the 1-byte form (`0x26`) this crate's +/// encoder emits. Verification must reproduce the original bytes; if it +/// re-encodes the header map instead, the signed input differs and a valid +/// token is rejected. +#[test] +fn test_es256_verifies_noncanonical_protected_header() { + let (private_key, public_key) = es256_keys(); + + // Protected header map {1: -7} with -7 encoded as the non-canonical + // 2-byte negative int (0x38 0x06). Canonical encoding would be `a1 01 26`. + let protected: &[u8] = &[0xa1, 0x01, 0x38, 0x06]; + // Payload: an empty claims map (`a0`). Its contents are irrelevant to the + // signed-bytes question; only the protected header encoding matters here. + let payload: &[u8] = &[0xa0]; + + // Build the COSE Sig_structure exactly as sign1_input() does, but over the + // non-canonical protected bytes, then sign it. + let mut sig_structure = vec![0x84]; // array(4) + sig_structure.push(0x6a); // text(10) + sig_structure.extend_from_slice(b"Signature1"); + sig_structure.push(0x40 | protected.len() as u8); // bstr(protected.len()) + sig_structure.extend_from_slice(protected); + sig_structure.push(0x40); // bstr(0) external_aad + sig_structure.push(0x40 | payload.len() as u8); // bstr(payload.len()) + sig_structure.extend_from_slice(payload); + + let signature = crate::utils::compute_es256(&private_key, &sig_structure) + .expect("compute_es256 over non-canonical structure"); + assert_eq!(signature.len(), 64); + + // Assemble the full tagged COSE_Sign1 token with the same protected bytes. + let mut token_bytes = vec![0xd8, 0x3d, 0xd2]; // tag 61 (CWT), tag 18 (COSE_Sign1) + token_bytes.push(0x84); // array(4) + token_bytes.push(0x40 | protected.len() as u8); // protected bstr + token_bytes.extend_from_slice(protected); + token_bytes.push(0xa0); // unprotected: empty map + token_bytes.push(0x40 | payload.len() as u8); // payload bstr + token_bytes.extend_from_slice(payload); + token_bytes.push(0x58); // bstr, 1-byte length follows + token_bytes.push(64); + token_bytes.extend_from_slice(&signature); + + let decoded = Token::from_bytes(&token_bytes).expect("decode non-canonical token"); + assert_eq!(decoded.header.algorithm(), Some(Algorithm::Es256)); + decoded + .verify(&public_key) + .expect("non-canonical protected header should still verify"); + + // Re-encoding a decoded token must be byte-faithful to the producer's + // encoding (RFC 9052 §4.4), so the non-canonical protected bytes survive a + // round-trip and the token still verifies. A canonicalizing re-encode would + // turn `a1 01 38 06` back into `a1 01 26` and break the signature. + let reencoded = decoded.to_bytes().expect("re-encode non-canonical token"); + assert_eq!( + reencoded, token_bytes, + "to_bytes() must preserve the original protected header bytes" + ); + Token::from_bytes(&reencoded) + .expect("decode round-tripped token") + .verify(&public_key) + .expect("round-tripped token should still verify"); +} + #[test] fn test_algorithm_identifier_roundtrip() { for alg in [Algorithm::HmacSha256, Algorithm::Es256, Algorithm::Ps256] { @@ -1686,3 +1754,20 @@ fn test_algorithm_identifier_roundtrip() { assert!(!Algorithm::Ps256.is_mac()); assert!(Algorithm::HmacSha256.is_mac()); } + +#[test] +fn test_algorithm_class_and_context() { + // MAC algorithms map to COSE_Mac0 / "MAC0"; signature algorithms map to + // COSE_Sign1 / "Signature1" (RFC 9052 §4.4, §6.3). + assert_eq!(Algorithm::HmacSha256.class(), AlgorithmClass::Mac); + assert_eq!(Algorithm::Es256.class(), AlgorithmClass::Signature); + assert_eq!(Algorithm::Ps256.class(), AlgorithmClass::Signature); + + assert_eq!(AlgorithmClass::Mac.context(), "MAC0"); + assert_eq!(AlgorithmClass::Signature.context(), "Signature1"); + + // is_mac() is defined in terms of the class, so they must agree. + for alg in [Algorithm::HmacSha256, Algorithm::Es256, Algorithm::Ps256] { + assert_eq!(alg.is_mac(), alg.class() == AlgorithmClass::Mac); + } +} diff --git a/src/token.rs b/src/token.rs index 10201fc..fa8a409 100644 --- a/src/token.rs +++ b/src/token.rs @@ -3,7 +3,7 @@ use crate::claims::{Claims, RegisteredClaims}; use crate::constants::tprint_params; use crate::error::Error; -use crate::header::{Algorithm, CborValue, Header, HeaderMap, KeyId}; +use crate::header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId}; use crate::utils::{ compute_es256, compute_hmac_sha256, compute_ps256, current_timestamp, verify_es256, verify_hmac_sha256, verify_ps256, @@ -25,6 +25,13 @@ pub struct Token { pub signature: Vec, /// Original payload bytes (for verification) original_payload_bytes: Option>, + /// Original protected header bytes (for verification) + /// + /// COSE signs/MACs the exact encoded `protected` bstr, not a re-encoding of + /// the decoded header map. Preserving the original bytes from `from_bytes` + /// lets verification reproduce the signed input even when the producer's + /// CBOR encoding (map ordering, integer width, etc.) differs from ours. + original_protected_bytes: Option>, } impl Token { @@ -35,6 +42,7 @@ impl Token { claims, signature, original_payload_bytes: None, + original_protected_bytes: None, } } @@ -43,32 +51,28 @@ impl Token { let mut buf = Vec::new(); let mut enc = Encoder::new(&mut buf); - // Apply the CWT tag (61) followed by the COSE structure tag. HMAC (MAC) - // algorithms use COSE_Mac0 (tag 17); asymmetric signature algorithms use - // COSE_Sign1 (tag 18). If the header carries no algorithm, the bare COSE - // array is emitted untagged (`from_bytes` accepts both tagged and - // untagged input). - match self.header.algorithm() { - Some(alg) if alg.is_mac() => { - // Apply CWT tag (61) - enc.tag(minicbor::data::Tag::new(61))?; - // Apply COSE_Mac0 tag (17) - enc.tag(minicbor::data::Tag::new(17))?; - } - Some(_) => { - // Apply CWT tag (61) - enc.tag(minicbor::data::Tag::new(61))?; - // Apply COSE_Sign1 tag (18) - enc.tag(minicbor::data::Tag::new(18))?; - } - None => {} + // Apply the CWT tag (61) followed by the COSE structure tag. The tag is + // selected by the algorithm's COSE class (RFC 9052 §8.1, §8.2): MAC + // algorithms use COSE_Mac0 (tag 17); signature algorithms use COSE_Sign1 + // (tag 18). If the header carries no algorithm, the bare COSE array is + // emitted untagged (`from_bytes` accepts both tagged and untagged input). + if let Some(alg) = self.header.algorithm() { + // Apply CWT tag (61) + enc.tag(minicbor::data::Tag::new(61))?; + let cose_tag = match alg.class() { + AlgorithmClass::Mac => 17, // COSE_Mac0 + AlgorithmClass::Signature => 18, // COSE_Sign1 + }; + enc.tag(minicbor::data::Tag::new(cose_tag))?; } // COSE structure array with 4 items enc.array(4)?; - // 1. Protected header (encoded as CBOR and then as bstr) - let protected_bytes = encode_map(&self.header.protected)?; + // 1. Protected header (encoded as CBOR and then as bstr). + // Reuse the original bytes for a decoded token so the re-encoding is + // byte-faithful to the producer's encoding (see `protected_bytes`). + let protected_bytes = self.protected_bytes()?; enc.bytes(&protected_bytes)?; // 2. Unprotected header @@ -115,6 +119,7 @@ impl Token { // 1. Protected header let protected_bytes = dec.bytes()?; let protected = decode_map(protected_bytes)?; + let original_protected_bytes = protected_bytes.to_vec(); // 2. Unprotected header let unprotected = decode_map_direct(&mut dec)?; @@ -138,6 +143,7 @@ impl Token { claims, signature, original_payload_bytes: Some(claims_bytes.to_vec()), + original_protected_bytes: Some(original_protected_bytes), }) } @@ -696,15 +702,37 @@ impl Token { } } - /// Get the COSE_Sign1 signature input - fn sign1_input(&self) -> Result, Error> { - // Sig_structure = [ - // context : "Signature1", - // protected : bstr .cbor header_map, - // external_aad : bstr, - // payload : bstr .cbor claims - // ] + /// Get the encoded protected header bytes, using original bytes if available. + /// + /// COSE signs the exact `protected` bstr that appears on the wire, so a + /// decoded token must reuse the producer's original bytes rather than + /// re-encode the header map (which can differ in map ordering, integer + /// width, etc.). Newly built tokens have no original bytes and are encoded + /// from the header map. + fn protected_bytes(&self) -> Result, Error> { + if let Some(ref original) = self.original_protected_bytes { + Ok(original.clone()) + } else { + encode_map(&self.header.protected) + } + } + /// Build the COSE signed/MACed input structure for the given context. + /// + /// Per RFC 9052, the `Sig_structure` for COSE_Sign1 (§4.4) and the + /// `MAC_structure` for COSE_Mac0 (§6.3) are the same array shape; they + /// differ only in the leading context string (`"Signature1"` vs `"MAC0"`). + /// `Sig_structure` also defines a `sign_protected` field, but it is omitted + /// for COSE_Sign1, so both structures are 4-element arrays: + /// + /// ```text + /// [ context, protected : bstr .cbor header_map, external_aad : bstr, payload : bstr .cbor claims ] + /// ``` + /// + /// The protected header is the exact serialized `bstr` from the token + /// (RFC 9052 §4.4: re-encoding must be byte-identical), supplied by + /// [`Self::protected_bytes`]. `external_aad` is the empty byte string. + fn cose_input(&self, context: &str) -> Result, Error> { let mut buf = Vec::new(); let mut enc = Encoder::new(&mut buf); @@ -712,10 +740,10 @@ impl Token { enc.array(4)?; // 1. Context - enc.str("Signature1")?; + enc.str(context)?; // 2. Protected header - let protected_bytes = encode_map(&self.header.protected)?; + let protected_bytes = self.protected_bytes()?; enc.bytes(&protected_bytes)?; // 3. External AAD (empty in our case) @@ -728,36 +756,14 @@ impl Token { Ok(buf) } - /// Get the COSE_Mac0 signature input - fn mac0_input(&self) -> Result, Error> { - // Mac_structure = [ - // context : "MAC0", - // protected : bstr .cbor header_map, - // external_aad : bstr, - // payload : bstr .cbor claims - // ] - - let mut buf = Vec::new(); - let mut enc = Encoder::new(&mut buf); - - // Start array with 4 items - enc.array(4)?; - - // 1. Context - enc.str("MAC0")?; - - // 2. Protected header - let protected_bytes = encode_map(&self.header.protected)?; - enc.bytes(&protected_bytes)?; - - // 3. External AAD (empty in our case) - enc.bytes(&[])?; - - // 4. Payload - let claims_bytes = self.get_payload_bytes()?; - enc.bytes(&claims_bytes)?; + /// Get the COSE_Sign1 signature input (`Sig_structure`, RFC 9052 §4.4). + fn sign1_input(&self) -> Result, Error> { + self.cose_input(AlgorithmClass::Signature.context()) + } - Ok(buf) + /// Get the COSE_Mac0 MAC input (`MAC_structure`, RFC 9052 §6.3). + fn mac0_input(&self) -> Result, Error> { + self.cose_input(AlgorithmClass::Mac.context()) } // Convenience methods for common token operations @@ -1413,6 +1419,7 @@ impl TokenBuilder { claims: self.claims, signature: Vec::new(), original_payload_bytes: None, + original_protected_bytes: None, }; // Compute signature input based on algorithm. @@ -1442,6 +1449,7 @@ impl TokenBuilder { claims: token.claims, signature, original_payload_bytes: None, + original_protected_bytes: None, }) } } From 978ea489be65fbbde2f8c832fd2e6241b0dd367e Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 11:43:03 -0600 Subject: [PATCH 08/16] Preserve original payload bytes in to_bytes() to_bytes() rebuilt the payload from the claims map while the protected header (fixed in 079409e) and verification already reuse the preserved original bytes. For a token decoded from the wire with a non-canonical payload encoding, this emitted a different payload bstr while keeping the original signature/MAC, producing a re-encoded token that no longer verified. Use get_payload_bytes() in to_bytes() so the payload bstr is byte-faithful to the producer's encoding, mirroring the protected-header handling. Add a round-trip regression test that decodes the external COSE_Mac0 fixture, verifies it, re-encodes via to_bytes(), and verifies the result. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 32 ++++++++++++++++++++++++++++++++ src/token.rs | 9 ++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 10599a8..40c1cd7 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -597,6 +597,38 @@ fn test_mac0_token_verification_with_original_bytes() { ); } +#[test] +fn test_decoded_token_reencodes_without_breaking_signature() { + // Regression test: `to_bytes()` must preserve the exact signed bytes of a + // decoded token (both protected header and payload), otherwise re-encoding + // a token parsed from the wire would emit a different payload/protected + // bstr while keeping the original MAC/signature, producing a token that no + // longer verifies. Uses the external COSE_Mac0 fixture (tags 61 + 17). + let token_b64 = "2D3RhEOhAQWhBEd0ZXN0S2lkWOCnAWpwcmltZXZpZGVvAngkNWI4ZWQ2YjItZmNhNC00ZWQ1LTkxNWYtNThjZTFiMGYzMDRiB1gkZjI0ZmIxMDctNDA0MS00MTkxLThkMDktOWMzMzZkNWVjNzAyBRpofDGABBpoirIAGQE4ogahAXgkNGFkZGQ5ZTctZTUzMS00NzIxLTlhNjctYjJlNzQ1OTIyMmJiBaECeCYvZTA1OS83ODExLzE2NDAvNDdlMS05OTMwLTJhNDM0MWVhOGIxMBkBQ6MAAgEZA4QEdVgtUFYtQ0ROLUFjY2Vzcy1Ub2tlblggdBNqM-3RwdEOuIZ2UoF-jDq3z7DvNcjUWSISjCiugR4"; + + let token_bytes = + Base64UrlSafeNoPadding::decode_to_vec(token_b64, None).expect("Failed to decode base64"); + + let key = b"testSecret"; + + // The original decoded token verifies. + let token = Token::from_bytes(&token_bytes).expect("Failed to parse token"); + assert!( + token.verify(key).is_ok(), + "Original decoded token should verify with testSecret" + ); + + // Re-encode it, parse the re-encoded bytes, and verify again. This fails if + // `to_bytes()` re-encodes the payload (or protected header) instead of + // reusing the preserved original bytes. + 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(), + "Re-encoded token should still verify with testSecret" + ); +} + #[test] fn test_created_token_format() { // Test that tokens created by this library have the correct format diff --git a/src/token.rs b/src/token.rs index fa8a409..26a5baf 100644 --- a/src/token.rs +++ b/src/token.rs @@ -78,9 +78,12 @@ impl Token { // 2. Unprotected header encode_map_direct(&self.header.unprotected, &mut enc)?; - // 3. Payload (encoded as CBOR and then as bstr) - let claims_map = self.claims.to_map(); - let claims_bytes = encode_map(&claims_map)?; + // 3. Payload (encoded as CBOR and then as bstr). + // Reuse the original bytes for a decoded token so the re-encoding is + // byte-faithful to the producer's encoding (see `get_payload_bytes`). + // This is the exact payload covered by the signature/MAC, so emitting + // a re-encoded payload would invalidate a decoded token's signature. + let claims_bytes = self.get_payload_bytes()?; enc.bytes(&claims_bytes)?; // 4. Signature/MAC From 424d67d9a2675cac6fa0789b5403fa66c14080c1 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 11:52:49 -0600 Subject: [PATCH 09/16] Update obsoleted RFC 8152 reference to RFC 9053 Co-Authored-By: Claude Opus 4.8 --- src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.rs b/src/utils.rs index 51ac4b7..dbeab5f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -23,7 +23,7 @@ pub fn verify_hmac_sha256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<( /// /// `key` must be a PKCS#8 DER-encoded P-256 private key. The returned signature /// is the fixed-length 64-byte COSE representation (`r || s`), as required by the -/// COSE specification (RFC 8152 §8.1). +/// COSE specification (RFC 9053 §2.1). pub fn compute_es256(key: &[u8], data: &[u8]) -> Result, Error> { use p256::ecdsa::{signature::Signer, Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey; From d814b78a1a250eefde8dcaa9e566f749301670d2 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 12:03:44 -0600 Subject: [PATCH 10/16] Pin claims in PS256 randomization test so salt is the only entropy build_signed_token() called current_timestamp() per token, so token_a and token_b could be signed over different payloads (iat/nbf/exp), letting the test pass even without PSS salt randomization. Build both tokens with identical fixed claims so any signature difference comes solely from the salt. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 40c1cd7..5630e1b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -629,6 +629,60 @@ fn test_decoded_token_reencodes_without_breaking_signature() { ); } +#[test] +fn test_mutated_claims_are_reflected_in_to_bytes() { + // The `claims` field is public, so a decoded token can be mutated before + // re-encoding. `to_bytes()` must reflect the mutation rather than silently + // emit the producer's original (cached) payload bytes. Without the cache + // validation in `get_payload_bytes`, this emits a token still carrying the + // original `iss`. + let token_b64 = "2D3RhEOhAQWhBEd0ZXN0S2lkWOCnAWpwcmltZXZpZGVvAngkNWI4ZWQ2YjItZmNhNC00ZWQ1LTkxNWYtNThjZTFiMGYzMDRiB1gkZjI0ZmIxMDctNDA0MS00MTkxLThkMDktOWMzMzZkNWVjNzAyBRpofDGABBpoirIAGQE4ogahAXgkNGFkZGQ5ZTctZTUzMS00NzIxLTlhNjctYjJlNzQ1OTIyMmJiBaECeCYvZTA1OS83ODExLzE2NDAvNDdlMS05OTMwLTJhNDM0MWVhOGIxMBkBQ6MAAgEZA4QEdVgtUFYtQ0ROLUFjY2Vzcy1Ub2tlblggdBNqM-3RwdEOuIZ2UoF-jDq3z7DvNcjUWSISjCiugR4"; + let token_bytes = + Base64UrlSafeNoPadding::decode_to_vec(token_b64, None).expect("Failed to decode base64"); + + let mut token = Token::from_bytes(&token_bytes).expect("Failed to parse token"); + assert_eq!(token.claims.registered.iss, Some("primevideo".to_string())); + + // Mutate a claim, then re-encode and re-parse. + token.claims.registered.iss = Some("mutated-issuer".to_string()); + let reencoded = token.to_bytes().expect("Failed to re-encode token"); + let reparsed = Token::from_bytes(&reencoded).expect("Failed to parse re-encoded token"); + + // The mutation must be reflected on the wire. + assert_eq!( + reparsed.claims.registered.iss, + Some("mutated-issuer".to_string()), + "to_bytes() must emit the mutated claims, not the cached original 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(), + "A token whose claims were mutated after decode must not verify with the original MAC" + ); +} + +#[test] +fn test_unmutated_decoded_token_reuses_original_bytes() { + // Counterpart to the mutation test: when the claims are *not* changed, the + // cache-validation path must still reuse the producer's exact original + // bytes so the round-trip stays byte-faithful (the non-canonical-encoding + // interop guarantee). + let token_b64 = "2D3RhEOhAQWhBEd0ZXN0S2lkWOCnAWpwcmltZXZpZGVvAngkNWI4ZWQ2YjItZmNhNC00ZWQ1LTkxNWYtNThjZTFiMGYzMDRiB1gkZjI0ZmIxMDctNDA0MS00MTkxLThkMDktOWMzMzZkNWVjNzAyBRpofDGABBpoirIAGQE4ogahAXgkNGFkZGQ5ZTctZTUzMS00NzIxLTlhNjctYjJlNzQ1OTIyMmJiBaECeCYvZTA1OS83ODExLzE2NDAvNDdlMS05OTMwLTJhNDM0MWVhOGIxMBkBQ6MAAgEZA4QEdVgtUFYtQ0ROLUFjY2Vzcy1Ub2tlblggdBNqM-3RwdEOuIZ2UoF-jDq3z7DvNcjUWSISjCiugR4"; + let token_bytes = + Base64UrlSafeNoPadding::decode_to_vec(token_b64, None).expect("Failed to decode base64"); + + let token = Token::from_bytes(&token_bytes).expect("Failed to parse token"); + let reencoded = token.to_bytes().expect("Failed to re-encode token"); + + // Byte-for-byte identical to the producer's original encoding. + assert_eq!( + reencoded, token_bytes, + "Unmutated decoded token must re-encode byte-faithfully" + ); +} + #[test] fn test_created_token_format() { // Test that tokens created by this library have the correct format @@ -1659,8 +1713,32 @@ fn test_ps256_signatures_are_randomized() { // yet both must verify. let (private_key, public_key) = ps256_keys(); - let token_a = build_signed_token(Algorithm::Ps256, &private_key); - let token_b = build_signed_token(Algorithm::Ps256, &private_key); + // Build both tokens with identical, fixed claims (no calls to + // `current_timestamp()`) so the signed payload is byte-for-byte the same. + // The PSS salt is then the only source of entropy, so any difference in + // the signatures is attributable solely to salt randomization rather than + // to differing claims. + let build = || { + TokenBuilder::new() + .algorithm(Algorithm::Ps256) + .protected_key_id(KeyId::string("asym-key-1")) + .registered_claims( + RegisteredClaims::new() + .with_issuer("issuer") + .with_subject("subject") + .with_audience("audience") + .with_expiration(2_000_000_000) + .with_not_before(1_000_000_000) + .with_issued_at(1_000_000_000), + ) + .custom_string(100, "custom-string-value") + .custom_int(102, 12345) + .sign(&private_key) + .expect("Failed to sign token") + }; + + let token_a = build(); + let token_b = build(); assert_ne!( token_a.signature, token_b.signature, From bdae217a62c24bfc77c553814da709eed4705bfd Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 12:06:15 -0600 Subject: [PATCH 11/16] Assert PSS salt randomization at the full-token byte level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode both tokens with to_bytes() and assert the on-the-wire bytes share an identical prefix (tags, headers, payload, and the signature's bstr header) and differ only across the trailing signature region — proving the random PSS salt is the sole source of difference. Adds a public to_signed_payload_bytes() helper exposing the exact signed payload bstr for the premise check. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 50 +++++++++++++++++++++++++++++ src/token.rs | 89 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 5630e1b..6ad43b2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1740,11 +1740,61 @@ fn test_ps256_signatures_are_randomized() { let token_a = build(); let token_b = build(); + // Sanity check the premise: the signed payloads are identical, so the only + // thing that can differ between the two signatures is the PSS salt. + assert_eq!( + token_a + .to_signed_payload_bytes() + .expect("token_a signed payload"), + token_b + .to_signed_payload_bytes() + .expect("token_b signed payload"), + "signed payloads should be identical so the salt is the only entropy" + ); + assert_ne!( token_a.signature, token_b.signature, "PSS signatures should differ due to random salt" ); + // Confirm the randomization is observable in the full on-the-wire bytes and + // is confined to the signature. In COSE_Sign1 the signature is the final + // `bstr` element, so the two encodings must share an identical prefix (tags, + // protected/unprotected headers, payload, and the signature's bstr header) + // and differ only across the trailing signature bytes. + let bytes_a = token_a.to_bytes().expect("Failed to encode token_a"); + let bytes_b = token_b.to_bytes().expect("Failed to encode token_b"); + + // RSA-2048 PSS signatures are a fixed 256 bytes, so both encodings have the + // same length and the signature occupies the same trailing region in each. + assert_eq!( + token_a.signature.len(), + token_b.signature.len(), + "PS256 signatures should be the same fixed length" + ); + assert_eq!( + bytes_a.len(), + bytes_b.len(), + "encoded tokens should be the same length" + ); + + let split = bytes_a.len() - token_a.signature.len(); + + assert_eq!( + bytes_a[..split], + bytes_b[..split], + "everything before the signature (headers + payload) must be identical" + ); + assert_ne!( + bytes_a[split..], + bytes_b[split..], + "the trailing signature bytes must differ due to the random PSS salt" + ); + // The trailing region is exactly the signature, so the observed difference + // is the salt and nothing else. + 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"); } diff --git a/src/token.rs b/src/token.rs index 26a5baf..eb004ba 100644 --- a/src/token.rs +++ b/src/token.rs @@ -46,6 +46,20 @@ impl Token { } } + /// Return the encoded `payload` bstr that the signature or MAC is computed + /// over (the CBOR-encoded claims map). + /// + /// For a token decoded via [`Self::from_bytes`] whose `claims` are unchanged, + /// this is the producer's exact original bytes; if `claims` were mutated, it + /// is a fresh encoding of the current claims. This is the same payload that + /// [`Self::to_bytes`] emits and that signing/verification cover, so two + /// tokens with identical claims produce identical signed payload bytes + /// (useful for asserting that any signature difference comes from elsewhere, + /// e.g. PSS salt randomization). + pub fn to_signed_payload_bytes(&self) -> Result, Error> { + self.get_payload_bytes() + } + /// Encode the token to CBOR bytes pub fn to_bytes(&self) -> Result, Error> { let mut buf = Vec::new(); @@ -693,30 +707,48 @@ impl Token { // Note: signature_input method removed as we now use mac0_input for HMAC algorithms - /// Get the encoded payload bytes, using original bytes if available + /// Get the encoded payload bytes. + /// + /// For a token decoded via [`Self::from_bytes`] whose `claims` have not been + /// changed, the producer's exact original bytes are reused so the signed + /// `payload` bstr is byte-faithful (a re-encode can differ in map ordering, + /// integer width, etc. — see [`Self::protected_bytes`]). + /// + /// The `claims` field is public, so it may have been mutated after decoding. + /// The cached bytes are only reused when they still describe the *current* + /// claims; otherwise the claims are re-encoded. This keeps `to_bytes()` from + /// silently emitting a token that carries the producer's original claims + /// while the caller believes it carries their mutated ones. A re-encoded + /// payload no longer matches the original signature/MAC, so such a token + /// fails verification rather than passing with the wrong claims. fn get_payload_bytes(&self) -> Result, Error> { - if let Some(ref original) = self.original_payload_bytes { - // Use original bytes for verification - Ok(original.clone()) - } else { - // Encode claims for newly created tokens - let claims_map = self.claims.to_map(); - encode_map(&claims_map) + let claims_bytes = encode_map(&self.claims.to_map())?; + match self.original_payload_bytes { + // Reuse the producer's exact bytes only when they decode to the same + // claims the token currently holds. + Some(ref original) if payload_matches(original, &self.claims) => Ok(original.clone()), + _ => Ok(claims_bytes), } } - /// Get the encoded protected header bytes, using original bytes if available. + /// Get the encoded protected header bytes. /// /// COSE signs the exact `protected` bstr that appears on the wire, so a - /// decoded token must reuse the producer's original bytes rather than - /// re-encode the header map (which can differ in map ordering, integer - /// width, etc.). Newly built tokens have no original bytes and are encoded - /// from the header map. + /// decoded token reuses the producer's original bytes rather than re-encode + /// the header map (which can differ in map ordering, integer width, etc.). + /// Newly built tokens have no original bytes and are encoded from the header + /// map. + /// + /// As with [`Self::get_payload_bytes`], the `header` field is public; the + /// cached bytes are only reused when they still describe the current + /// protected header, so a mutated header is re-encoded rather than silently + /// replaced by the producer's original bytes. fn protected_bytes(&self) -> Result, Error> { - if let Some(ref original) = self.original_protected_bytes { - Ok(original.clone()) - } else { - encode_map(&self.header.protected) + match self.original_protected_bytes { + Some(ref original) if protected_matches(original, &self.header.protected) => { + Ok(original.clone()) + } + _ => encode_map(&self.header.protected), } } @@ -1459,6 +1491,29 @@ impl TokenBuilder { // Helper functions for CBOR encoding/decoding +/// Whether the cached original payload `bytes` still describe `claims`. +/// +/// Decodes the producer's original `payload` bstr and compares it, by logical +/// content (the claims map), against the token's current claims. A mismatch +/// means the public `claims` field was mutated after decoding, so the cached +/// bytes must not be reused. Undecodable original bytes also count as a +/// mismatch, falling back to a fresh encode. +fn payload_matches(bytes: &[u8], claims: &Claims) -> bool { + match decode_map(bytes) { + Ok(decoded) => decoded == claims.to_map(), + Err(_) => false, + } +} + +/// Whether the cached original protected-header `bytes` still describe +/// `protected`. See [`payload_matches`]; same logic for the protected header. +fn protected_matches(bytes: &[u8], protected: &HeaderMap) -> bool { + match decode_map(bytes) { + Ok(decoded) => &decoded == protected, + Err(_) => false, + } +} + fn encode_map(map: &HeaderMap) -> Result, Error> { let mut buf = Vec::new(); let mut enc = Encoder::new(&mut buf); From ac8fa56b6c2f6d4dfbb0919e1e810b008f0117ca Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 12:42:46 -0600 Subject: [PATCH 12/16] Fix verification regression for lossy-but-valid claim payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_payload_bytes() gated reuse of the producer's original signed payload bytes on a comparison between the full decoded map and the current claims projection. Because Claims is a lossy projection (e.g. an aud encoded as a CBOR array, which RFC 8392/7519 permit, cannot fit Option), an unmutated externally-produced token failed that check, got re-encoded without the dropped claim, and no longer verified — a silent break of previously-accepted tokens. Detect mutation by comparing the current claims projection against a baseline projection captured at decode time, instead of against the full original map. Both sides drop the same information, so an unmutated lossy token still matches and keeps its byte-faithful signed bytes, while a real mutation still forces a re-encode. Also drops the eager re-encode on the cache-hit path. Add regression tests for aud-as-array and cti-as-text payloads (verify + byte-faithful round-trip), plus a lossy-token mutation test. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/token.rs | 85 +++++++++++++++++--------- 2 files changed, 223 insertions(+), 28 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 6ad43b2..1462ea0 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -683,6 +683,172 @@ fn test_unmutated_decoded_token_reuses_original_bytes() { ); } +// --------------------------------------------------------------------------- +// Lossy-claim round-trip regression tests. +// +// `Claims` cannot represent every spec-valid CWT payload: a registered claim +// carried with an unexpected CBOR type is dropped by `Claims::from_map`. The +// canonical case is `aud` (key 3) encoded as a CBOR *array* of audiences, which +// RFC 8392 / RFC 7519 permit but `RegisteredClaims.aud: Option` cannot +// hold. A `cti` (key 7) encoded as text is a second, non-conformant-producer +// trigger. +// +// These tokens must still verify (their signed payload bytes are preserved +// byte-faithfully) and round-trip without the dropped claim breaking the +// signature. They regress if `to_bytes`/`verify` re-encode the lossy `Claims` +// projection instead of reusing the producer's original payload bytes. +// --------------------------------------------------------------------------- + +/// Append a CBOR byte string (`bstr`) header + contents for `data`. +fn push_bstr(buf: &mut Vec, data: &[u8]) { + let len = data.len(); + if len < 24 { + buf.push(0x40 | len as u8); + } else if len < 256 { + buf.push(0x58); + buf.push(len as u8); + } else { + buf.push(0x59); + buf.push((len >> 8) as u8); + buf.push((len & 0xff) as u8); + } + buf.extend_from_slice(data); +} + +/// Append a CBOR text string for `s` (only short strings, len < 24). +fn push_text(buf: &mut Vec, s: &str) { + let b = s.as_bytes(); + assert!(b.len() < 24, "test helper only handles short text"); + buf.push(0x60 | b.len() as u8); + buf.extend_from_slice(b); +} + +/// Hand-build a tagged COSE_Mac0 (CWT) token from raw protected/payload bstr +/// contents, MACed with HMAC-SHA256 over the `MAC_structure` using `key`. +fn build_mac0_token_bytes(protected: &[u8], payload: &[u8], key: &[u8]) -> Vec { + // MAC_structure = ["MAC0", protected : bstr, external_aad : bstr(empty), payload : bstr] + let mut mac_structure = vec![0x84]; // array(4) + push_text(&mut mac_structure, "MAC0"); + push_bstr(&mut mac_structure, protected); + push_bstr(&mut mac_structure, &[]); // external_aad + push_bstr(&mut mac_structure, payload); + + let mac = crate::utils::compute_hmac_sha256(key, &mac_structure); + + // Tagged COSE_Mac0: tag 61 (CWT), tag 17 (COSE_Mac0), array(4). + let mut token = vec![0xd8, 0x3d, 0xd1, 0x84]; + push_bstr(&mut token, protected); // 1. protected header + token.push(0xa0); // 2. unprotected: empty map + push_bstr(&mut token, payload); // 3. payload + push_bstr(&mut token, &mac); // 4. MAC + token +} + +#[test] +fn test_aud_as_array_verifies_and_roundtrips() { + // A spec-valid CWT whose `aud` (key 3) is a CBOR array of audiences — a + // shape `RegisteredClaims.aud: Option` cannot represent, so the + // claim is dropped from the decoded `Claims`. The producer's signed payload + // bytes must still be preserved so the token verifies and round-trips. + let key = b"testSecret"; + + // Protected header { 1 (alg): 5 (HS256) }. + let protected: &[u8] = &[0xa1, 0x01, 0x05]; + + // Payload { 3 (aud): ["aud-one", "aud-two"] }. + let mut payload = vec![0xa1, 0x03, 0x82]; // map(1), key 3, array(2) + push_text(&mut payload, "aud-one"); + push_text(&mut payload, "aud-two"); + + let token_bytes = build_mac0_token_bytes(protected, &payload, key); + + let token = Token::from_bytes(&token_bytes).expect("decode aud-as-array token"); + + // The lossy drop is real and documented: the typed accessor sees no `aud`. + assert_eq!( + token.claims.registered.aud, None, + "array-valued aud cannot be represented by Option and is dropped" + ); + + // Regression: an unmutated token must still verify against the original key. + assert!( + token.verify(key).is_ok(), + "unmutated aud-as-array token must verify (byte-faithful payload reuse)" + ); + + // And it must re-encode byte-for-byte (the dropped claim survives on the wire). + let reencoded = token.to_bytes().expect("re-encode aud-as-array token"); + assert_eq!( + reencoded, token_bytes, + "unmutated lossy token must re-encode byte-faithfully, preserving aud" + ); + Token::from_bytes(&reencoded) + .expect("decode round-tripped token") + .verify(key) + .expect("round-tripped aud-as-array token should still verify"); +} + +#[test] +fn test_aud_as_array_mutation_is_reflected() { + // Counterpart: mutating a claim on a lossy token must still be reflected on + // the wire (the cache must not silently re-emit the producer's bytes), and + // the mutated payload must then fail verification against the original MAC. + let key = b"testSecret"; + + let protected: &[u8] = &[0xa1, 0x01, 0x05]; + let mut payload = vec![0xa1, 0x03, 0x82]; + push_text(&mut payload, "aud-one"); + push_text(&mut payload, "aud-two"); + let token_bytes = build_mac0_token_bytes(protected, &payload, key); + + let mut token = Token::from_bytes(&token_bytes).expect("decode aud-as-array token"); + + // Set a (representable) issuer claim that was not present in the original. + token.claims.registered.iss = Some("mutated-issuer".to_string()); + + let reencoded = token.to_bytes().expect("re-encode mutated token"); + let reparsed = Token::from_bytes(&reencoded).expect("decode mutated token"); + + assert_eq!( + reparsed.claims.registered.iss, + Some("mutated-issuer".to_string()), + "mutation on a lossy token must be reflected on the wire" + ); + assert!( + reparsed.verify(key).is_err(), + "a mutated payload must not verify against the original MAC" + ); +} + +#[test] +fn test_cti_as_text_verifies_and_roundtrips() { + // A second lossy trigger: `cti` (key 7) carried as text rather than bytes. + // `RegisteredClaims::from_map` only accepts `cti` as bytes, so it is dropped, + // yet the token must still verify and round-trip byte-faithfully. + let key = b"testSecret"; + + let protected: &[u8] = &[0xa1, 0x01, 0x05]; + // Payload { 7 (cti): "cti-as-text" }. + let mut payload = vec![0xa1, 0x07]; + push_text(&mut payload, "cti-as-text"); + let token_bytes = build_mac0_token_bytes(protected, &payload, key); + + let token = Token::from_bytes(&token_bytes).expect("decode cti-as-text token"); + assert_eq!( + token.claims.registered.cti, None, + "text-valued cti cannot be represented as bytes and is dropped" + ); + assert!( + token.verify(key).is_ok(), + "unmutated cti-as-text token must verify" + ); + assert_eq!( + token.to_bytes().expect("re-encode cti-as-text token"), + token_bytes, + "unmutated lossy token must re-encode byte-faithfully" + ); +} + #[test] fn test_created_token_format() { // Test that tokens created by this library have the correct format diff --git a/src/token.rs b/src/token.rs index eb004ba..56270cb 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,6 +1,6 @@ //! Token implementation for Common Access Token -use crate::claims::{Claims, RegisteredClaims}; +use crate::claims::{Claims, ClaimsMap, RegisteredClaims}; use crate::constants::tprint_params; use crate::error::Error; use crate::header::{Algorithm, AlgorithmClass, CborValue, Header, HeaderMap, KeyId}; @@ -32,6 +32,18 @@ pub struct Token { /// lets verification reproduce the signed input even when the producer's /// CBOR encoding (map ordering, integer width, etc.) differs from ours. original_protected_bytes: Option>, + /// Baseline claims projection captured at decode time (for mutation detection) + /// + /// This is `Claims::from_map(decoded).to_map()` — the round-tripped + /// projection of the payload as it was when decoded. We reuse the producer's + /// exact `original_payload_bytes` only when the *current* claims project to + /// this same baseline; a difference means the public `claims` field was + /// mutated after decoding. Comparing projection-to-projection (rather than + /// the current projection to the full original map) makes the check robust + /// to claims the `Claims` struct cannot represent losslessly (e.g. an `aud` + /// encoded as a CBOR array): both sides drop the same information, so an + /// unmutated token still matches and keeps its byte-faithful signed bytes. + baseline_payload_projection: Option, } impl Token { @@ -43,6 +55,7 @@ impl Token { signature, original_payload_bytes: None, original_protected_bytes: None, + baseline_payload_projection: None, } } @@ -151,6 +164,10 @@ impl Token { let claims_bytes = dec.bytes()?; let claims_map = decode_map(claims_bytes)?; let claims = Claims::from_map(&claims_map); + // Baseline projection of the payload as decoded. `claims` is + // `Claims::from_map(&claims_map)`, so `claims.to_map()` is exactly the + // round-tripped projection used later to detect mutation. + let baseline_payload_projection = claims.to_map(); // 4. Signature let signature = dec.bytes()?.to_vec(); @@ -161,6 +178,7 @@ impl Token { signature, original_payload_bytes: Some(claims_bytes.to_vec()), original_protected_bytes: Some(original_protected_bytes), + baseline_payload_projection: Some(baseline_payload_projection), }) } @@ -715,19 +733,31 @@ impl Token { /// integer width, etc. — see [`Self::protected_bytes`]). /// /// The `claims` field is public, so it may have been mutated after decoding. - /// The cached bytes are only reused when they still describe the *current* - /// claims; otherwise the claims are re-encoded. This keeps `to_bytes()` from - /// silently emitting a token that carries the producer's original claims - /// while the caller believes it carries their mutated ones. A re-encoded - /// payload no longer matches the original signature/MAC, so such a token - /// fails verification rather than passing with the wrong claims. + /// The cached bytes are only reused when the current claims still project to + /// the baseline captured at decode time (see `baseline_payload_projection`); + /// otherwise the claims are re-encoded. This keeps `to_bytes()` from silently + /// emitting a token that carries the producer's original claims while the + /// caller believes it carries their mutated ones. A re-encoded payload no + /// longer matches the original signature/MAC, so such a token fails + /// verification rather than passing with the wrong claims. + /// + /// The comparison is projection-to-projection (current claims vs. the + /// decode-time projection) rather than current-claims vs. the full original + /// map, so it is robust to claims the `Claims` struct drops on decode (e.g. + /// an `aud` encoded as a CBOR array): both sides lose the same information, + /// so an unmutated token still matches and keeps its byte-faithful bytes. fn get_payload_bytes(&self) -> Result, Error> { - let claims_bytes = encode_map(&self.claims.to_map())?; - match self.original_payload_bytes { - // Reuse the producer's exact bytes only when they decode to the same - // claims the token currently holds. - Some(ref original) if payload_matches(original, &self.claims) => Ok(original.clone()), - _ => Ok(claims_bytes), + // Encoding is deferred to the fallback arm so the common + // decoded-and-unmutated path (e.g. during verify/to_bytes) avoids a + // wasted re-encode, matching the sibling `protected_bytes`. + match ( + &self.original_payload_bytes, + &self.baseline_payload_projection, + ) { + (Some(original), Some(baseline)) if self.claims.to_map() == *baseline => { + Ok(original.clone()) + } + _ => encode_map(&self.claims.to_map()), } } @@ -1455,6 +1485,7 @@ impl TokenBuilder { signature: Vec::new(), original_payload_bytes: None, original_protected_bytes: None, + baseline_payload_projection: None, }; // Compute signature input based on algorithm. @@ -1485,28 +1516,26 @@ impl TokenBuilder { signature, original_payload_bytes: None, original_protected_bytes: None, + baseline_payload_projection: None, }) } } // Helper functions for CBOR encoding/decoding -/// Whether the cached original payload `bytes` still describe `claims`. -/// -/// Decodes the producer's original `payload` bstr and compares it, by logical -/// content (the claims map), against the token's current claims. A mismatch -/// means the public `claims` field was mutated after decoding, so the cached -/// bytes must not be reused. Undecodable original bytes also count as a -/// mismatch, falling back to a fresh encode. -fn payload_matches(bytes: &[u8], claims: &Claims) -> bool { - match decode_map(bytes) { - Ok(decoded) => decoded == claims.to_map(), - Err(_) => false, - } -} - /// Whether the cached original protected-header `bytes` still describe -/// `protected`. See [`payload_matches`]; same logic for the protected header. +/// `protected`. +/// +/// Decodes the producer's original protected bstr and compares it, by logical +/// content (the header map), against the token's current protected header. A +/// mismatch means the public `header.protected` field was mutated after +/// decoding, so the cached bytes must not be reused. Undecodable original bytes +/// also count as a mismatch, falling back to a fresh encode. +/// +/// Unlike the payload, the protected header round-trips through `HeaderMap` +/// losslessly, so comparing against the decoded original map is safe here (it +/// cannot misfire on an unmutated token the way a lossy `Claims` projection +/// could — see [`Token::get_payload_bytes`]). fn protected_matches(bytes: &[u8], protected: &HeaderMap) -> bool { match decode_map(bytes) { Ok(decoded) => &decoded == protected, From f54f71039050b6500f7983f2f76f1438d51e0f3d Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 13:02:28 -0600 Subject: [PATCH 13/16] Enforce canonical low-S ECDSA signatures for ES256 ECDSA signatures are malleable: for a valid (r, s), the pair (r, n - s) verifies over the same message. Without normalization a third party could derive a second valid signature for an unchanged token, and the signature bytes could not serve as a stable token identity. - compute_es256 normalizes to low-S before encoding. - verify_es256 rejects high-S (non-canonical) signatures. - Add tests asserting emitted signatures are low-S and that a constructed high-S twin is rejected. Also record deferred code-review findings (alg-confusion, hot-path rebuild costs, array-valued aud, and assorted cleanups) in FOLLOWUPS.md. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++++ FOLLOWUPS.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/tests.rs | 62 +++++++++++++++++++++++++ src/utils.rs | 22 +++++++++ 4 files changed, 221 insertions(+) create mode 100644 FOLLOWUPS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f07e4a4..a0a7169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `COSE_Mac0` (tag 17) while asymmetric signature algorithms use `COSE_Sign1` (tag 18). `Token::from_bytes` accepts both tagged and untagged input. +### Security + +- 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 + message — so without this a third party could derive a second valid signature + for an unchanged token. Requiring low-S makes the signature bytes a stable + identity for a token. + ## [0.2.7] - 2025-11-05 ### Added diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md new file mode 100644 index 0000000..69213fe --- /dev/null +++ b/FOLLOWUPS.md @@ -0,0 +1,128 @@ +# Follow-up items + +Deferred findings from the code review of the ES256/PS256 work +(`add-es256-ps256-support`). These were intentionally left out of that change +to keep its scope focused; each is safe to defer and can be picked up +independently. + +## Security (separate PR — breaking) + +### Algorithm-confusion in `Token::verify` + +`verify(key)` selects the verification algorithm from the token's self-declared +`alg` in the protected header, with no way for the caller to pin an expected +algorithm. Now that the crate supports asymmetric key pairs, a relying party +holding only a public key can be attacked: an attacker relabels the header to +`alg = HmacSha256` and computes an HMAC using the (known) public-key bytes as +the MAC secret, and `verify` takes the HMAC path and accepts it. This is the +classic JWT `alg`-confusion attack. + +- **Fix direction:** add an expected-algorithm / key-type to `verify` or + `VerificationOptions` and reject MAC algorithms when an asymmetric key is + supplied. +- **Why deferred:** the API change (and the design decision about where the + algorithm expectation should live) is a larger breaking change deserving its + own PR. +- Location: `src/token.rs` — `Token::verify`. + +## Data model (separate PR — breaking) + +### Array-valued `aud` is not readable through the typed API + +`RegisteredClaims.aud` is `Option` and cannot represent an `aud` +(CWT key 3) encoded as a CBOR **array** of audiences, which RFC 8392 / RFC 7519 +permit. On decode, `Claims::from_map` silently drops the array-valued `aud` and +`Token::audience()` returns `None`. + +The **correctness** half is already handled: `get_payload_bytes` reuses the +producer's original signed payload bytes (via `baseline_payload_projection`), so +such tokens still **verify** and **round-trip byte-faithfully** even though the +claim is invisible to the typed API (see `test_aud_as_array_verifies_and_roundtrips` +and `test_cti_as_text_verifies_and_roundtrips` in `src/tests.rs`). The urgent +interop/availability regression — valid tokens failing verification — is +resolved. + +- **Deferred (not done):** making the array audience *readable* through the + typed API. That requires changing the type of `RegisteredClaims.aud` (and + `with_audience` / `audience()`), a breaking public API change. +- **Why deferred:** exposing the value is a nicety with a breaking cost; revisit + only when bumping a breaking version, and only if a consumer actually needs to + inspect array audiences. The same lossy-but-now-safe shape affects + non-conformant producers (e.g. `cti` as text); those are malformed and not + worth a data-model change. +- Location: `src/claims.rs` — `RegisteredClaims` / `Claims::from_map`. + +## Performance (hot path) + +### Redundant claims-map rebuild in `get_payload_bytes` + +`get_payload_bytes` calls `self.claims.to_map()` (allocating a `BTreeMap` and +deep-cloning every claim value) on every `verify()` / `to_bytes()` for a decoded +token, just to compare against `baseline_payload_projection`, then drops it. The +old code returned `Ok(original.clone())` with no map work. Consider a cheap +fingerprint (length/hash of the encoded claims) or a dirty flag instead. + +- Location: `src/token.rs` — `get_payload_bytes`. + +### Full CBOR decode in `protected_bytes` + +`protected_matches` calls `decode_map(original)` (allocates a fresh `HeaderMap`, +deep-clones every entry) on every `verify()` / `to_bytes()` just to compare and +drop it. The header was already decoded into `self.header.protected` at +`from_bytes` time. Compounding both: an HMAC `verify()` calls `cose_input` twice +(`sign1_input` then `mac0_input`) and `to_bytes()` calls them again, so the +decode + map-rebuild runs 2–4× per operation with no memoization. Compute the +bytes once per public operation and thread them down, or compare +`encode_map(&self.header.protected)` against the cached bytes. + +- Location: `src/token.rs` — `protected_bytes` / `protected_matches` / `cose_input`. + +## Maintainability / cleanup + +### `baseline_payload_projection` is a derivable cached field + +It is exactly `Claims::from_map(decode(original_payload_bytes)).to_map()` and is +kept alive for the token's lifetime (≈doubling per-token decode memory). It must +be set consistently at four construction sites. Note the asymmetry: +`protected_bytes` needs no parallel cached field — it derives its comparison on +demand. The payload path could mirror that and drop the field. + +- Location: `src/token.rs` — `Token` struct fields, `from_bytes`, `sign`. + +### Two divergent mutation-detection mechanisms + +The payload uses projection-equality (`claims.to_map() == baseline`) while the +protected header uses decode-and-compare (`decode_map(bytes) == header.protected`). +The divergence is only justified by the payload's lossy `Claims` projection, and +that rationale lives only in prose comments. Consider a single shared helper with +the match-predicate passed in, plus a code-level marker of why they must differ, +so a future "unification" doesn't reintroduce the signature-breaking regression +the caches exist to prevent. + +- Location: `src/token.rs` — `get_payload_bytes` / `protected_bytes`. + +### `to_bytes` silently emits an untagged, unverifiable token when `alg` is `None` + +The `if let Some(alg)` branch emits a bare untagged COSE array when the header +has no algorithm — which the crate's own `verify()` then rejects with +`InvalidFormat("Missing algorithm")`. This state is only reachable via a +hand-built `Token::new`, i.e. a caller bug. Returning `Err(InvalidFormat)` here +would be symmetric with `verify()` / `sign()` and surface the bug instead of +masking it. + +- Location: `src/token.rs` — `Token::to_bytes`. + +### `Algorithm::is_mac` is redundant with `class()` + +`is_mac()` is `matches!(self.class(), AlgorithmClass::Mac)` and has no non-test +caller. Consider dropping it in favor of `class() == AlgorithmClass::Mac`. + +- Location: `src/header.rs` — `Algorithm::is_mac`. + +### Test helpers re-implement CBOR encoding + +`push_bstr` / `push_text` / `build_mac0_token_bytes` hand-roll bstr/text length +prefixes (with implicit size ceilings) that `minicbor::Encoder` already produces. +Low risk today, but they can drift from what the library emits. Test-only. + +- Location: `src/tests.rs`. diff --git a/src/tests.rs b/src/tests.rs index 1462ea0..d4265da 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2000,6 +2000,68 @@ fn test_es256_invalid_public_key_errors() { ); } +#[test] +fn test_es256_signatures_are_low_s() { + // Every signature this crate emits must be in canonical low-S form so the + // signature bytes are a stable identity for a token (no malleable twin). + use p256::ecdsa::Signature; + + let (private_key, _public_key) = es256_keys(); + // Sign several distinct messages; RFC 6979 makes signing deterministic, so + // varying the input is what exercises different (r, s) pairs. + for i in 0..32u8 { + let sig_bytes = + crate::utils::compute_es256(&private_key, &[i, i.wrapping_mul(7), 0xAB]).expect("sign"); + let sig = Signature::from_slice(&sig_bytes).expect("64-byte signature"); + assert!( + sig.normalize_s().is_none(), + "compute_es256 must emit low-S signatures (iteration {i})" + ); + } +} + +#[test] +fn test_es256_high_s_signature_is_rejected() { + // ECDSA is malleable: given a valid (r, s) signature, (r, n - s) is an + // equally valid signature over the same message. A high-S twin must be + // rejected so the accepted signature is canonical and cannot be duplicated + // by a third party without the private key. + use p256::ecdsa::Signature; + + let (private_key, public_key) = es256_keys(); + let data = b"es256 malleability check"; + + let low_s_bytes = crate::utils::compute_es256(&private_key, data).expect("sign"); + // Sanity: the freshly produced signature verifies and is low-S. + crate::utils::verify_es256(&public_key, data, &low_s_bytes) + .expect("canonical low-S signature should verify"); + + // Construct the high-S twin (r, n - s) by negating the s scalar. + let low_s = Signature::from_slice(&low_s_bytes).expect("64-byte signature"); + let (r, s) = low_s.split_scalars(); + let high_s = Signature::from_scalars(r, -s).expect("non-zero scalars"); + // The twin really is the high-S form (normalize_s would flip it back). + assert!( + high_s.normalize_s().is_some(), + "negated-s signature should be high-S" + ); + let high_s_bytes = high_s.to_bytes().to_vec(); + assert_ne!( + high_s_bytes, low_s_bytes, + "the malleable twin must differ from the canonical signature" + ); + + // The high-S twin is a mathematically valid ECDSA signature, but our + // verifier must reject it as non-canonical. + assert!( + matches!( + crate::utils::verify_es256(&public_key, data, &high_s_bytes), + Err(crate::error::Error::SignatureVerification) + ), + "high-S (malleable) signature must be rejected" + ); +} + /// Regression test for the COSE protected-header interop bug. /// /// COSE signs the *exact* encoded `protected` bstr, not a re-encoding of the diff --git a/src/utils.rs b/src/utils.rs index dbeab5f..7263ae7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -24,6 +24,13 @@ pub fn verify_hmac_sha256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<( /// `key` must be a PKCS#8 DER-encoded P-256 private key. The returned signature /// is the fixed-length 64-byte COSE representation (`r || s`), as required by the /// COSE specification (RFC 9053 §2.1). +/// +/// The signature is normalized to "low-S" form. ECDSA signatures are malleable: +/// for any valid `(r, s)`, the pair `(r, n - s)` is an equally valid signature +/// over the same message. Emitting only low-S signatures yields a canonical +/// encoding (each token has one signature) and interoperates with strict +/// verifiers that reject high-S (e.g. WebCrypto and various COSE stacks). The +/// matching `verify_es256` likewise rejects high-S. pub fn compute_es256(key: &[u8], data: &[u8]) -> Result, Error> { use p256::ecdsa::{signature::Signer, Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey; @@ -33,6 +40,9 @@ pub fn compute_es256(key: &[u8], data: &[u8]) -> Result, Error> { // ECDSA signing with the RustCrypto `ecdsa` crate is deterministic (RFC 6979), // so no RNG is required here. let signature: Signature = signing_key.sign(data); + // Normalize to low-S. `normalize_s` returns `Some` only when the signature + // was high-S; otherwise the original (already low-S) signature is used. + let signature = signature.normalize_s().unwrap_or(signature); Ok(signature.to_bytes().to_vec()) } @@ -40,6 +50,12 @@ pub fn compute_es256(key: &[u8], data: &[u8]) -> Result, Error> { /// /// `key` must be an SPKI DER-encoded P-256 public key. `signature` must be the /// fixed-length 64-byte COSE representation (`r || s`). +/// +/// High-S signatures are rejected. Because ECDSA signatures are malleable +/// (`(r, s)` and `(r, n - s)` are both valid), accepting high-S would let a +/// third party derive a second valid signature for an unchanged token without +/// the private key. Requiring low-S makes the accepted signature canonical, so +/// the signature bytes are safe to use as an identity/dedup key. pub fn verify_es256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Error> { use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; use p256::pkcs8::DecodePublicKey; @@ -47,6 +63,12 @@ pub fn verify_es256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Err let verifying_key = VerifyingKey::from_public_key_der(key) .map_err(|e| Error::InvalidKey(format!("Invalid ES256 public key: {e}")))?; let signature = Signature::from_slice(signature).map_err(|_| Error::SignatureVerification)?; + // Reject high-S (non-canonical) signatures. `normalize_s` returns `Some` + // exactly when the signature is high-S, so a `Some` here means the input + // was malleable and must not be accepted. + if signature.normalize_s().is_some() { + return Err(Error::SignatureVerification); + } verifying_key .verify(data, &signature) .map_err(|_| Error::SignatureVerification) From 5b04be72797075a6385defe513d33d1b5a5f081a Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 13:27:13 -0600 Subject: [PATCH 14/16] Enforce 2048-bit RSA floor for PS256; centralize COSE tag mapping Address code-review findings on the ES256/PS256 work: - PS256 now rejects RSA keys with a modulus smaller than 2048 bits on both the signing and verification paths. Neither the DER decoders nor PSS verification impose a floor, so an undersized (e.g. 512/1024-bit) key would otherwise sign and verify normally. Add sign/verify tests with a 1024-bit key and a CHANGELOG security note. - Move the COSE structure-tag mapping (17/18) onto AlgorithmClass::cbor_tag(), beside context(), so both wire facts the class determines stay co-located; to_bytes calls it instead of inline literals. - Document why the Es256/Ps256 arms in verify/sign are kept separate rather than merged, and mark the intentionally-duplicated demo keys across the two examples and tests so future reviews don't re-flag them. - Record the remaining deferred items in FOLLOWUPS.md: tag/alg cross-check on decode (with the algorithm-confusion hardening) and the cache-hit clone (with the existing perf item). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++ FOLLOWUPS.md | 33 +++++++++++++++++++- examples/asymmetric_signing.rs | 6 ++++ examples/sample_es256_ps256_tokens.rs | 6 ++++ src/header.rs | 13 ++++++++ src/tests.rs | 45 +++++++++++++++++++++++++++ src/token.rs | 18 ++++++++--- src/utils.rs | 32 +++++++++++++++++-- 8 files changed, 149 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a7169..e35c149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 message — so without this a third party could derive a second valid signature for an unchanged token. Requiring low-S makes the signature bytes a stable identity for a token. +- PS256 rejects RSA keys whose modulus is smaller than 2048 bits, on both the + signing and verification paths. Smaller moduli (e.g. 512- or 1024-bit) are too + weak to be secure — a 512-bit modulus is factorable on commodity hardware — + and neither the DER decoders nor PSS verification impose a floor, so without + this an undersized key would sign and verify normally. ## [0.2.7] - 2025-11-05 diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index 69213fe..35650fc 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -25,6 +25,26 @@ classic JWT `alg`-confusion attack. own PR. - Location: `src/token.rs` — `Token::verify`. +### COSE structure tag and `alg` are not cross-checked on decode + +`from_bytes` decodes and discards the COSE structure tag (it skips up to two +tags as noise), so the tag and the protected-header `alg` are never compared. +`to_bytes` carefully selects the wire tag from the algorithm's class (COSE_Mac0 += 17 for MAC, COSE_Sign1 = 18 for signature), but the inverse path never +enforces that invariant: a token tagged COSE_Mac0 (17) carrying `alg = -7` +(ES256), or tagged COSE_Sign1 (18) carrying `alg = 5` (HMAC), decodes and +verifies with no complaint. The class → tag invariant the encoder writes is +write-only. + +- **Fix direction:** when a recognized COSE structure tag (17/18) is present, + reject it if it disagrees with `alg.class()`. Tolerate the untagged / unknown + CWT-tag cases as today. +- **Why deferred:** this is the same "harden what the decoder trusts" policy as + the algorithm-confusion item above (what to reject vs. tolerate should be + designed once), and it pairs naturally with that PR. Low impact on its own — + only a malformed/contradictory producer is affected. +- Location: `src/token.rs` — `Token::from_bytes`. + ## Data model (separate PR — breaking) ### Array-valued `aud` is not readable through the typed API @@ -62,7 +82,18 @@ token, just to compare against `baseline_payload_projection`, then drops it. The old code returned `Ok(original.clone())` with no map work. Consider a cheap fingerprint (length/hash of the encoded claims) or a dirty flag instead. -- Location: `src/token.rs` — `get_payload_bytes`. +Separately, on the cache-hit path both `get_payload_bytes` and `protected_bytes` +return `original.clone()` — a full heap copy of the payload / protected-header +bstr — even though every caller (`to_bytes` via `enc.bytes`, and `cose_input`) +only borrows it as `&[u8]`. For a decoded token each `verify()` / `to_bytes()` +allocates and frees a transient `Vec` (potentially KBs for CAT tokens with +CATU/CATM maps) just to produce a slice the encoder copies again. Returning +`Cow<[u8]>` (`Borrowed` for the cache hit, `Owned` for the encode arm), or +threading `&mut Encoder` into the helpers, drops the copy. This is the +return-value clone, distinct from the `to_map()` / `decode_map` comparison cost +above. + +- Location: `src/token.rs` — `get_payload_bytes` / `protected_bytes`. ### Full CBOR decode in `protected_bytes` diff --git a/examples/asymmetric_signing.rs b/examples/asymmetric_signing.rs index da9ef48..c17d7c1 100644 --- a/examples/asymmetric_signing.rs +++ b/examples/asymmetric_signing.rs @@ -31,6 +31,12 @@ use ct_codecs::{Base64, Decoder}; // These private keys are committed to a public repository and are therefore // publicly known. They exist only so this example is self-contained. Generate // your own key pair (see the OpenSSL commands above) for any real use. +// +// NOTE: these constants are intentionally duplicated across the two asymmetric +// examples and `src/tests.rs`. Each example/test is meant to stand alone (a +// reader can run a single file without chasing a shared fixture), and the keys +// are throwaway demo material, so the small duplication is preferred over a +// shared module. Not a finding — please don't flag it in review. const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; diff --git a/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs index 507765f..38acc82 100644 --- a/examples/sample_es256_ps256_tokens.rs +++ b/examples/sample_es256_ps256_tokens.rs @@ -17,6 +17,12 @@ use ct_codecs::{Base64, Base64UrlSafeNoPadding, Decoder, Encoder, Hex}; // keys used by the test suite) and are therefore publicly known. They exist // only so this example is self-contained. Generate your own key pair for any // real use. +// +// NOTE: these constants are intentionally duplicated across the two asymmetric +// examples and `src/tests.rs`. Each example/test is meant to stand alone (a +// reader can run a single file without chasing a shared fixture), and the keys +// are throwaway demo material, so the small duplication is preferred over a +// shared module. Not a finding — please don't flag it in review. const ES256_PRIVATE_KEY_B64: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg7BOlgwBOMKscTUCaG3RmlSCgUznDdxMn+9Pvoqp4pUOhRANCAARWMcvR3DnF1U15IvgcOyAxr3pJPfOHcF7ESuY+H+ya3LCH03PC1d99/XgN1ldF+wmMxVhY0w9iop10N6tNZDTg"; const ES256_PUBLIC_KEY_B64: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVjHL0dw5xdVNeSL4HDsgMa96ST3zh3BexErmPh/smtywh9NzwtXfff14DdZXRfsJjMVYWNMPYqKddDerTWQ04A=="; diff --git a/src/header.rs b/src/header.rs index ab3ce8b..b5ed1f0 100644 --- a/src/header.rs +++ b/src/header.rs @@ -83,6 +83,19 @@ impl AlgorithmClass { AlgorithmClass::Signature => "Signature1", } } + + /// The CBOR tag emitted on the wire for this class's COSE structure. + /// + /// Per RFC 9052 §8.1/§8.2, COSE_Mac0 is tag 17 and COSE_Sign1 is tag 18. + /// This lives beside [`Self::context`] so both wire-encoding facts the class + /// determines (the tag and the context string) are co-located and stay in + /// sync as classes are added. + pub fn cbor_tag(&self) -> u64 { + match self { + AlgorithmClass::Mac => 17, // COSE_Mac0 + AlgorithmClass::Signature => 18, // COSE_Sign1 + } + } } impl Algorithm { diff --git a/src/tests.rs b/src/tests.rs index d4265da..fe96376 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1650,6 +1650,11 @@ fn test_signed_integer_in_nested_structures() { // PS256 (RSA-2048): // openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -outform DER # private (PKCS#8) // openssl pkey -inform DER -in ps_priv.der -pubout -outform DER # public (SPKI) +// +// NOTE: the same demo keys are also embedded (intentionally) in +// `examples/asymmetric_signing.rs` and `examples/sample_es256_ps256_tokens.rs` +// so each example stands alone. The duplication is deliberate throwaway test +// material — please don't flag it in review. // --------------------------------------------------------------------------- /// ES256 PKCS#8 DER private key (base64-encoded). @@ -1977,6 +1982,46 @@ fn test_es256_invalid_private_key_errors() { ); } +/// A valid 1024-bit RSA key pair (PKCS#8 / SPKI DER, base64). Used only to +/// confirm the PS256 minimum-key-size floor rejects undersized keys; 1024-bit +/// RSA is below the 2048-bit minimum the crate enforces. +const PS256_1024_PRIVATE_KEY_B64: &str = "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKvHWxROqV0XWK/d7kjK0u0SZFP5mkAQlw+f+DSF2Lb0GR6nSpzPsgz72KcBnKTuIjKBjpCziE96F4kY60ads16ZdGnjpaZKk3ggWnMF0Q7njDUTedYcyZykGpJmYF/XhAsTn4yRnJBjc2mzHLeMYGHZgmwgf/AHC104uLFbShsbAgMBAAECgYEAhvsuPLTbLQVtcTSpS5XlTNkI8VvPs8vViDeh6FPMyWbiXk4CuVoThVRZGFKR7qAZSyq3BkmtMRa1a8ujBWhiSwgcelL2KSXiY60hYzlKkmBnYMwDpDfUxlfTgw2nC5ufb4HUd/W/p2NJdmGI0/5td+A9AhIpsg/7ZlHbK4QYisECQQDUuh2GTmiPl3c5Unmby3XXvnDVMNCTKtAk8mf9mNL8AIq2D9tqgX0PsVczKqzjtYVVlQD50lH95LKfTr3aoAQLAkEAzrjVqxNva8SMabIAvPXNLvk80IgnLKgLTFVbtOBHrUH3muoRKbFQvC0UwvqxkjNszpakX0eo3UTmIGQMdgQfMQJAK+udSuyHZBY2tGwV1ZfFZdzY+PtSJQBy5x3xYIecEBGgkgRmHfBMPOA1i8fk2ELTG59fCzVkXlJImuGsCyZ8jwJABXkBNxk5nunCKd4rhNUhDHhOstqX5ue//NJZri0t2Jlhe7lsoOTv1TuATDUk1FEGNWXpjhgwkUMMsJjVd55eUQJBALD636EfXmesRUVTo3dGBreONSD9kob2x7XckJFBBFAXGV2LLbEmjZTC6e9OVpPb5fEE0hMq029VrSwva5mqyuc="; +const PS256_1024_PUBLIC_KEY_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrx1sUTqldF1iv3e5IytLtEmRT+ZpAEJcPn/g0hdi29Bkep0qcz7IM+9inAZyk7iIygY6Qs4hPeheJGOtGnbNemXRp46WmSpN4IFpzBdEO54w1E3nWHMmcpBqSZmBf14QLE5+MkZyQY3Npsxy3jGBh2YJsIH/wBwtdOLixW0obGwIDAQAB"; + +#[test] +fn test_ps256_undersized_key_is_rejected_on_sign() { + use ct_codecs::{Base64, Decoder}; + let small_key = Base64::decode_to_vec(PS256_1024_PRIVATE_KEY_B64, None) + .expect("valid 1024-bit private key"); + let result = TokenBuilder::new() + .algorithm(Algorithm::Ps256) + .registered_claims(RegisteredClaims::new().with_issuer("issuer")) + .sign(&small_key); + // The key is structurally valid DER, so the only reason to reject is the + // 2048-bit minimum modulus floor. + assert!( + matches!(result, Err(crate::error::Error::InvalidKey(_))), + "Signing with a 1024-bit RSA key should be rejected as too small" + ); +} + +#[test] +fn test_ps256_undersized_key_is_rejected_on_verify() { + // A signature produced directly by the low-level helper would also be + // refused at sign time; verify must independently reject an undersized + // public key regardless of the signature bytes supplied. + use ct_codecs::{Base64, Decoder}; + let small_pub = + Base64::decode_to_vec(PS256_1024_PUBLIC_KEY_B64, None).expect("valid 1024-bit public key"); + // Signature length matches a 1024-bit modulus (128 bytes); contents are + // irrelevant because the size check fires before any PSS verification. + let result = crate::utils::verify_ps256(&small_pub, b"data", &[0u8; 128]); + assert!( + matches!(result, Err(crate::error::Error::InvalidKey(_))), + "Verifying against a 1024-bit RSA key should be rejected as too small" + ); +} + #[test] fn test_ps256_invalid_private_key_errors() { let result = TokenBuilder::new() diff --git a/src/token.rs b/src/token.rs index 56270cb..d1fad88 100644 --- a/src/token.rs +++ b/src/token.rs @@ -86,11 +86,10 @@ impl Token { if let Some(alg) = self.header.algorithm() { // Apply CWT tag (61) enc.tag(minicbor::data::Tag::new(61))?; - let cose_tag = match alg.class() { - AlgorithmClass::Mac => 17, // COSE_Mac0 - AlgorithmClass::Signature => 18, // COSE_Sign1 - }; - enc.tag(minicbor::data::Tag::new(cose_tag))?; + // The COSE structure tag is owned by the algorithm's class (see + // `AlgorithmClass::cbor_tag`), keeping it in sync with the matching + // context string used in the signed input. + enc.tag(minicbor::data::Tag::new(alg.class().cbor_tag()))?; } // COSE structure array with 4 items @@ -211,6 +210,11 @@ impl Token { let mac0_input = self.mac0_input()?; 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 => { let sign1_input = self.sign1_input()?; verify_es256(key, &sign1_input, &self.signature) @@ -1497,6 +1501,10 @@ impl TokenBuilder { let mac = compute_hmac_sha256(key, &mac_input); (mac_input, mac) } + // Es256 and Ps256 share the COSE_Sign1 input but stay as separate + // arms (not `Es256 | Ps256` with a nested match) so each maps + // directly to its own signing primitive — the one duplicated line is + // clearer than a merged arm that re-introduces a nested `match alg`. Algorithm::Es256 => { let sign_input = token.sign1_input()?; let sig = compute_es256(key, &sign_input)?; diff --git a/src/utils.rs b/src/utils.rs index 7263ae7..172c53b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -74,18 +74,36 @@ pub fn verify_es256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Err .map_err(|_| Error::SignatureVerification) } +/// Minimum accepted RSA modulus size, in bits. +/// +/// RSA moduli below 2048 bits are considered too weak to provide meaningful +/// security (NIST SP 800-57 deprecated 1024-bit RSA, and 512-bit moduli are +/// factorable on commodity hardware). Neither the `rsa` crate's DER decoders +/// nor PSS verification impose any size floor, so without this check a token +/// signed under a tiny key would be accepted and could be forged by an +/// attacker. We enforce the floor on both the signing and verification paths. +const MIN_RSA_KEY_BITS: usize = 2048; + /// Compute a PS256 (RSASSA-PSS with SHA-256 and MGF1-SHA-256) signature over `data`. /// -/// `key` must be a PKCS#8 DER-encoded RSA private key. PSS uses a random salt, so -/// an OS-provided RNG is used (WASM-friendly via `getrandom`). +/// `key` must be a PKCS#8 DER-encoded RSA private key whose modulus is at least +/// [`MIN_RSA_KEY_BITS`] bits. PSS uses a random salt, so an OS-provided RNG is +/// used (WASM-friendly via `getrandom`). pub fn compute_ps256(key: &[u8], data: &[u8]) -> Result, Error> { use rsa::pkcs8::DecodePrivateKey; use rsa::pss::SigningKey; use rsa::signature::{RandomizedSigner, SignatureEncoding}; + use rsa::traits::PublicKeyParts; use rsa::RsaPrivateKey; let private_key = RsaPrivateKey::from_pkcs8_der(key) .map_err(|e| Error::InvalidKey(format!("Invalid PS256 private key: {e}")))?; + let key_bits = private_key.n().bits(); + if key_bits < MIN_RSA_KEY_BITS { + return Err(Error::InvalidKey(format!( + "PS256 RSA key is too small: {key_bits} bits (minimum {MIN_RSA_KEY_BITS})" + ))); + } let signing_key = SigningKey::::new(private_key); let mut rng = rand_core::OsRng; let signature = signing_key.sign_with_rng(&mut rng, data); @@ -94,15 +112,23 @@ pub fn compute_ps256(key: &[u8], data: &[u8]) -> Result, Error> { /// Verify a PS256 (RSASSA-PSS with SHA-256 and MGF1-SHA-256) signature over `data`. /// -/// `key` must be an SPKI DER-encoded RSA public key. +/// `key` must be an SPKI DER-encoded RSA public key whose modulus is at least +/// [`MIN_RSA_KEY_BITS`] bits; smaller keys are rejected as insecure. pub fn verify_ps256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Error> { use rsa::pkcs8::DecodePublicKey; use rsa::pss::{Signature, VerifyingKey}; use rsa::signature::Verifier; + use rsa::traits::PublicKeyParts; use rsa::RsaPublicKey; let public_key = RsaPublicKey::from_public_key_der(key) .map_err(|e| Error::InvalidKey(format!("Invalid PS256 public key: {e}")))?; + let key_bits = public_key.n().bits(); + if key_bits < MIN_RSA_KEY_BITS { + return Err(Error::InvalidKey(format!( + "PS256 RSA key is too small: {key_bits} bits (minimum {MIN_RSA_KEY_BITS})" + ))); + } let verifying_key = VerifyingKey::::new(public_key); let signature = Signature::try_from(signature).map_err(|_| Error::SignatureVerification)?; verifying_key From af2542d31325665e69afafbcf64f6013996838c3 Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 13:33:28 -0600 Subject: [PATCH 15/16] Reject to_bytes() on a token with no algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hand-built token (via Token::new) with no algorithm in its protected header has no valid COSE structure and is rejected by verify(), but to_bytes() previously emitted a bare untagged COSE array — silently producing an unverifiable token. Return Error::InvalidFormat instead, symmetric with verify()/sign(), so the caller bug surfaces. Addresses a Copilot review comment on PR #5. Removes the corresponding deferred item from FOLLOWUPS.md and clarifies the alg-confusion item's deferral rationale (0.3.0 is already breaking; an additive mitigation is possible, so it is not blocked on a future breaking release). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ FOLLOWUPS.md | 24 +++++++++--------------- src/tests.rs | 14 ++++++++++++++ src/token.rs | 27 +++++++++++++++++---------- 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35c149..5e5cfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tokens are tagged according to their algorithm: HMAC (MAC) algorithms use `COSE_Mac0` (tag 17) while asymmetric signature algorithms use `COSE_Sign1` (tag 18). `Token::from_bytes` accepts both tagged and untagged input. +- `Token::to_bytes` now returns `Error::InvalidFormat` when the protected header + carries no algorithm, instead of emitting a bare untagged COSE array that the + crate's own `verify` would reject. This state is only reachable by manually + constructing a `Token` via `Token::new` without an algorithm. ### Security diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index 35650fc..a39c6a3 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -19,10 +19,15 @@ classic JWT `alg`-confusion attack. - **Fix direction:** add an expected-algorithm / key-type to `verify` or `VerificationOptions` and reject MAC algorithms when an asymmetric key is - supplied. -- **Why deferred:** the API change (and the design decision about where the - algorithm expectation should live) is a larger breaking change deserving its - own PR. + supplied. A minimal, additive (non-breaking) `verify_with_algorithm(key, + expected)` entrypoint could land first; threading the expectation through + `VerificationOptions` is the fuller, more ergonomic form. +- **Why deferred:** designing where the algorithm expectation should live + (a new entrypoint vs. `VerificationOptions`, and the default-strictness + question) warrants its own PR rather than riding along here. Note this is + *not* blocked on a breaking release — 0.3.0 is already breaking, and an + additive entrypoint would not break callers — so it can be picked up + independently and soon. Reviewed and consciously deferred (PR #5). - Location: `src/token.rs` — `Token::verify`. ### COSE structure tag and `alg` are not cross-checked on decode @@ -132,17 +137,6 @@ the caches exist to prevent. - Location: `src/token.rs` — `get_payload_bytes` / `protected_bytes`. -### `to_bytes` silently emits an untagged, unverifiable token when `alg` is `None` - -The `if let Some(alg)` branch emits a bare untagged COSE array when the header -has no algorithm — which the crate's own `verify()` then rejects with -`InvalidFormat("Missing algorithm")`. This state is only reachable via a -hand-built `Token::new`, i.e. a caller bug. Returning `Err(InvalidFormat)` here -would be symmetric with `verify()` / `sign()` and surface the bug instead of -masking it. - -- Location: `src/token.rs` — `Token::to_bytes`. - ### `Algorithm::is_mac` is redundant with `class()` `is_mac()` is `matches!(self.class(), AlgorithmClass::Mac)` and has no non-test diff --git a/src/tests.rs b/src/tests.rs index fe96376..7e5ff07 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2204,3 +2204,17 @@ fn test_algorithm_class_and_context() { assert_eq!(alg.is_mac(), alg.class() == AlgorithmClass::Mac); } } + +#[test] +fn test_to_bytes_without_algorithm_errors() { + // A hand-built token with no algorithm in its protected header has no valid + // COSE structure and cannot be verified, so to_bytes() must reject it rather + // than silently emit an untagged, unverifiable token (symmetric with verify + // / sign, which both require an algorithm). + use crate::header::Header; + let token = Token::new(Header::new(), crate::claims::Claims::new(), Vec::new()); + assert!( + matches!(token.to_bytes(), Err(crate::error::Error::InvalidFormat(_))), + "to_bytes() on a token with no algorithm should return InvalidFormat" + ); +} diff --git a/src/token.rs b/src/token.rs index d1fad88..4aaff8c 100644 --- a/src/token.rs +++ b/src/token.rs @@ -81,16 +81,23 @@ impl Token { // Apply the CWT tag (61) followed by the COSE structure tag. The tag is // selected by the algorithm's COSE class (RFC 9052 §8.1, §8.2): MAC // algorithms use COSE_Mac0 (tag 17); signature algorithms use COSE_Sign1 - // (tag 18). If the header carries no algorithm, the bare COSE array is - // emitted untagged (`from_bytes` accepts both tagged and untagged input). - if let Some(alg) = self.header.algorithm() { - // Apply CWT tag (61) - enc.tag(minicbor::data::Tag::new(61))?; - // The COSE structure tag is owned by the algorithm's class (see - // `AlgorithmClass::cbor_tag`), keeping it in sync with the matching - // context string used in the signed input. - enc.tag(minicbor::data::Tag::new(alg.class().cbor_tag()))?; - } + // (tag 18). + // + // A token with no algorithm in its protected header has no valid COSE + // structure and cannot be verified (`verify` rejects it with + // `InvalidFormat`), so emitting one would silently produce an + // unverifiable token. That state is only reachable by hand-building a + // `Token` via `Token::new` without an algorithm; reject it here so the + // caller bug surfaces, symmetric with `verify`/`sign`. + let alg = self.header.algorithm().ok_or_else(|| { + Error::InvalidFormat("Missing algorithm in protected header".to_string()) + })?; + // Apply CWT tag (61) + enc.tag(minicbor::data::Tag::new(61))?; + // The COSE structure tag is owned by the algorithm's class (see + // `AlgorithmClass::cbor_tag`), keeping it in sync with the matching + // context string used in the signed input. + enc.tag(minicbor::data::Tag::new(alg.class().cbor_tag()))?; // COSE structure array with 4 items enc.array(4)?; From 759948d5e7b68d85e9530c2cab14215afc21e1cf Mon Sep 17 00:00:00 2001 From: Zac Shenker Date: Thu, 25 Jun 2026 13:55:54 -0600 Subject: [PATCH 16/16] Extract RSA key-size check; drop test-only public method Address two code-review cleanups: - Extract the duplicated RSA minimum-modulus check into a shared ensure_rsa_key_bits() helper so the floor policy and error message cannot drift between compute_ps256 and verify_ps256. - Remove to_signed_payload_bytes from the public API. It was a thin wrapper added only for a test assertion; make get_payload_bytes pub(crate) so the PSS-randomization test can use it directly without committing the "signed payload bytes" concept to the semver surface. Co-Authored-By: Claude Opus 4.8 --- src/tests.rs | 8 ++------ src/token.rs | 21 ++++++--------------- src/utils.rs | 27 +++++++++++++++------------ 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 7e5ff07..82a740a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1914,12 +1914,8 @@ fn test_ps256_signatures_are_randomized() { // Sanity check the premise: the signed payloads are identical, so the only // thing that can differ between the two signatures is the PSS salt. assert_eq!( - token_a - .to_signed_payload_bytes() - .expect("token_a signed payload"), - token_b - .to_signed_payload_bytes() - .expect("token_b signed payload"), + token_a.get_payload_bytes().expect("token_a signed payload"), + token_b.get_payload_bytes().expect("token_b signed payload"), "signed payloads should be identical so the salt is the only entropy" ); diff --git a/src/token.rs b/src/token.rs index 4aaff8c..024da7b 100644 --- a/src/token.rs +++ b/src/token.rs @@ -59,20 +59,6 @@ impl Token { } } - /// Return the encoded `payload` bstr that the signature or MAC is computed - /// over (the CBOR-encoded claims map). - /// - /// For a token decoded via [`Self::from_bytes`] whose `claims` are unchanged, - /// this is the producer's exact original bytes; if `claims` were mutated, it - /// is a fresh encoding of the current claims. This is the same payload that - /// [`Self::to_bytes`] emits and that signing/verification cover, so two - /// tokens with identical claims produce identical signed payload bytes - /// (useful for asserting that any signature difference comes from elsewhere, - /// e.g. PSS salt randomization). - pub fn to_signed_payload_bytes(&self) -> Result, Error> { - self.get_payload_bytes() - } - /// Encode the token to CBOR bytes pub fn to_bytes(&self) -> Result, Error> { let mut buf = Vec::new(); @@ -757,7 +743,12 @@ impl Token { /// map, so it is robust to claims the `Claims` struct drops on decode (e.g. /// an `aud` encoded as a CBOR array): both sides lose the same information, /// so an unmutated token still matches and keeps its byte-faithful bytes. - fn get_payload_bytes(&self) -> Result, Error> { + /// + /// Crate-internal (`pub(crate)`) so tests can assert that two tokens with + /// identical claims produce identical signed payload bytes (e.g. that a PSS + /// signature difference comes solely from salt randomization), without + /// committing this to the public API surface. + pub(crate) fn get_payload_bytes(&self) -> Result, Error> { // Encoding is deferred to the fallback arm so the common // decoded-and-unmutated path (e.g. during verify/to_bytes) avoids a // wasted re-encode, matching the sibling `protected_bytes`. diff --git a/src/utils.rs b/src/utils.rs index 172c53b..5d2f985 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -84,6 +84,19 @@ pub fn verify_es256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Err /// attacker. We enforce the floor on both the signing and verification paths. const MIN_RSA_KEY_BITS: usize = 2048; +/// Reject an RSA modulus smaller than [`MIN_RSA_KEY_BITS`]. +/// +/// Shared by `compute_ps256` and `verify_ps256` so the floor policy (and its +/// error message) cannot drift between the signing and verification paths. +fn ensure_rsa_key_bits(key_bits: usize) -> Result<(), Error> { + if key_bits < MIN_RSA_KEY_BITS { + return Err(Error::InvalidKey(format!( + "PS256 RSA key is too small: {key_bits} bits (minimum {MIN_RSA_KEY_BITS})" + ))); + } + Ok(()) +} + /// Compute a PS256 (RSASSA-PSS with SHA-256 and MGF1-SHA-256) signature over `data`. /// /// `key` must be a PKCS#8 DER-encoded RSA private key whose modulus is at least @@ -98,12 +111,7 @@ pub fn compute_ps256(key: &[u8], data: &[u8]) -> Result, Error> { let private_key = RsaPrivateKey::from_pkcs8_der(key) .map_err(|e| Error::InvalidKey(format!("Invalid PS256 private key: {e}")))?; - let key_bits = private_key.n().bits(); - if key_bits < MIN_RSA_KEY_BITS { - return Err(Error::InvalidKey(format!( - "PS256 RSA key is too small: {key_bits} bits (minimum {MIN_RSA_KEY_BITS})" - ))); - } + ensure_rsa_key_bits(private_key.n().bits())?; let signing_key = SigningKey::::new(private_key); let mut rng = rand_core::OsRng; let signature = signing_key.sign_with_rng(&mut rng, data); @@ -123,12 +131,7 @@ pub fn verify_ps256(key: &[u8], data: &[u8], signature: &[u8]) -> Result<(), Err let public_key = RsaPublicKey::from_public_key_der(key) .map_err(|e| Error::InvalidKey(format!("Invalid PS256 public key: {e}")))?; - let key_bits = public_key.n().bits(); - if key_bits < MIN_RSA_KEY_BITS { - return Err(Error::InvalidKey(format!( - "PS256 RSA key is too small: {key_bits} bits (minimum {MIN_RSA_KEY_BITS})" - ))); - } + ensure_rsa_key_bits(public_key.n().bits())?; let verifying_key = VerifyingKey::::new(public_key); let signature = Signature::try_from(signature).map_err(|_| Error::SignatureVerification)?; verifying_key