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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e5cfd4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# 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. +- `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 + +- 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. +- 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 + +### 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 diff --git a/Cargo.toml b/Cargo.toml index af31a49..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"] @@ -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.88.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/FOLLOWUPS.md b/FOLLOWUPS.md new file mode 100644 index 0000000..a39c6a3 --- /dev/null +++ b/FOLLOWUPS.md @@ -0,0 +1,153 @@ +# 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. 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 + +`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 + +`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. + +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` + +`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`. + +### `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/README.md b/README.md index 5ec4269..6bb4528 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 @@ -31,7 +31,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -common-access-token = "0.2" +common-access-token = "0.3" ``` ## Usage @@ -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..c17d7c1 --- /dev/null +++ b/examples/asymmetric_signing.rs @@ -0,0 +1,108 @@ +//! 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}; + +// ⚠️ 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. +// +// 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=="; + +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/examples/sample_es256_ps256_tokens.rs b/examples/sample_es256_ps256_tokens.rs new file mode 100644 index 0000000..38acc82 --- /dev/null +++ b/examples/sample_es256_ps256_tokens.rs @@ -0,0 +1,114 @@ +//! 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}; + +// ⚠️ 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. +// +// 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=="; + +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!(); +} 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..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}")] @@ -26,6 +31,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..b5ed1f0 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,66 @@ 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); /// ``` +/// +/// 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, + /// 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, +} + +/// 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", + } + } + + /// 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 { @@ -43,6 +103,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 +112,33 @@ 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, } } + + /// 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.class(), AlgorithmClass::Mac) + } } /// Key identifier that can be either a binary or string value. diff --git a/src/lib.rs b/src/lib.rs index a655a07..802af76 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 @@ -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 14f5c66..82a740a 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, }; @@ -597,6 +597,258 @@ 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_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" + ); +} + +// --------------------------------------------------------------------------- +// 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 @@ -1382,3 +1634,583 @@ 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) +// +// 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). +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, 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( + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElkhvSdit+RZ8AdhbXRhGVYDI2ZNfZjZJkufNFB+xYGCR+MwpsILkSP3AVN51C5xG/JtwVcUTDekjURgBYsuDPA==", + None, + ) + .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"); + + 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(); + 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"); + + // 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] +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(); + + // 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(); + + // 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.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" + ); + + 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"); +} + +#[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" + ); +} + +/// 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() + .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_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 +/// 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] { + 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()); +} + +#[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); + } +} + +#[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 1619ffd..024da7b 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,10 +1,13 @@ //! 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, CborValue, Header, HeaderMap, KeyId}; -use crate::utils::{compute_hmac_sha256, current_timestamp, verify_hmac_sha256}; +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, +}; use crate::FingerprintType; use minicbor::{Decoder, Encoder}; use std::collections::BTreeMap; @@ -22,6 +25,25 @@ 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>, + /// 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 { @@ -32,6 +54,8 @@ impl Token { claims, signature, original_payload_bytes: None, + original_protected_bytes: None, + baseline_payload_projection: None, } } @@ -40,27 +64,45 @@ 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. 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). + // + // 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)?; - // 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 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 @@ -99,6 +141,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)?; @@ -113,6 +156,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(); @@ -122,14 +169,21 @@ impl Token { claims, signature, original_payload_bytes: Some(claims_bytes.to_vec()), + original_protected_bytes: Some(original_protected_bytes), + baseline_payload_projection: Some(baseline_payload_projection), }) } /// 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 +203,19 @@ 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) + } + Algorithm::Ps256 => { + let sign1_input = self.sign1_input()?; + verify_ps256(key, &sign1_input, &self.signature) + } } } @@ -655,27 +722,84 @@ 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 - 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) + /// 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 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. + /// + /// 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`. + 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()), } } - /// 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. + /// + /// COSE signs the exact `protected` bstr that appears on the wire, so a + /// 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> { + match self.original_protected_bytes { + Some(ref original) if protected_matches(original, &self.header.protected) => { + Ok(original.clone()) + } + _ => 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); @@ -683,10 +807,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) @@ -699,36 +823,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 @@ -1366,6 +1468,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(|| { @@ -1378,16 +1486,33 @@ impl TokenBuilder { claims: self.claims, signature: Vec::new(), original_payload_bytes: None, + original_protected_bytes: None, + baseline_payload_projection: 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) } + // 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)?; + (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 @@ -1396,12 +1521,34 @@ impl TokenBuilder { claims: token.claims, signature, original_payload_bytes: None, + original_protected_bytes: None, + baseline_payload_projection: None, }) } } // Helper functions for CBOR encoding/decoding +/// Whether the cached original protected-header `bytes` still describe +/// `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, + Err(_) => false, + } +} + fn encode_map(map: &HeaderMap) -> Result, Error> { let mut buf = Vec::new(); let mut enc = Encoder::new(&mut buf); diff --git a/src/utils.rs b/src/utils.rs index 977aba7..5d2f985 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,6 +19,126 @@ 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 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; + + 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); + // 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()) +} + +/// 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`). +/// +/// 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; + + 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) +} + +/// 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; + +/// 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 +/// [`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}")))?; + 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); + 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 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}")))?; + 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 + .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};