Prevent algorithm-confusion forgery with typed VerifyingKey#6
Conversation
|
This is an automated review by Swival (https://swival.dev) using only open-source models. I recommend merging this after a small formatting fix. The security direction looks right to me. I also like that the examples and docs were moved to The only thing I would fix before merging is formatting. I verified that |
c52ba27 to
14e25e5
Compare
There was a problem hiding this comment.
Pull request overview
This PR mitigates an algorithm-confusion forgery in Token::verify(&[u8]) by introducing a typed verification key that pins the verification algorithm to the relying party’s key, rather than trusting the token’s alg header.
Changes:
- Add
VerifyingKeyand a newToken::verify_with_key(VerifyingKey)API that rejectsalgmismatches before performing cryptographic verification. - Deprecate
Token::verify(&[u8])and restrict it to HMAC-SHA256 only (ES256/PS256 now returnInvalidAlgorithm). - Migrate tests, docs, README, and examples to use
verify_with_key.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/header.rs | Introduces VerifyingKey enum and helpers to bind key material to an Algorithm. |
| src/token.rs | Adds verify_with_key, hardens deprecated verify to HMAC-only, and enforces algorithm/key binding. |
| src/tests.rs | Updates existing tests to use verify_with_key and adds regression coverage for alg-confusion scenarios. |
| src/lib.rs | Re-exports VerifyingKey and updates crate-level docs to use verify_with_key. |
| README.md | Updates usage docs and algorithm table to reflect typed verification keys and safer verification API. |
| examples/basic_usage.rs | Migrates verification calls to verify_with_key for HMAC usage. |
| examples/cat_validation.rs | Migrates verification calls to verify_with_key. |
| examples/cat_specific_claims.rs | Migrates verification calls to verify_with_key. |
| examples/asymmetric_signing.rs | Migrates asymmetric verification to verify_with_key. |
| examples/sample_es256_ps256_tokens.rs | Migrates asymmetric verification to verify_with_key. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let verifying_key = match alg { | ||
| Algorithm::Es256 => VerifyingKey::Es256(&public_key), | ||
| Algorithm::Ps256 => VerifyingKey::Ps256(&public_key), | ||
| Algorithm::HmacSha256 => VerifyingKey::HmacSha256(&public_key), | ||
| }; |
| let verifying_key = match alg { | ||
| Algorithm::Es256 => VerifyingKey::Es256(&public_key), | ||
| Algorithm::Ps256 => VerifyingKey::Ps256(&public_key), | ||
| Algorithm::HmacSha256 => VerifyingKey::HmacSha256(&public_key), | ||
| }; |
| #[deprecated( | ||
| since = "0.3.0", | ||
| note = "use `verify_with_key` with a typed `VerifyingKey`; `verify` no longer supports ES256/PS256 to prevent algorithm-confusion forgery" | ||
| )] |
jedisct1
left a comment
There was a problem hiding this comment.
Thanks for working through this. I reviewed the PR description, the current diff against the ES256/PS256 branch, the previous Swival comment, the Copilot review comments, and the affected verification paths in src/header.rs, src/token.rs, the examples, and the tests. I also ran rustup run stable cargo test, rustup run stable cargo fmt --check, rustup run stable cargo clippy --all-targets -- -D warnings, and both new asymmetric examples locally. This is an automated review by Swival (https://swival.dev) using only open-source models, intended to help both the author and the maintainers.
My recommendation is not to merge this quite yet, but I think the core security fix is the right shape and this should stay open.
The important implementation change looks correct to me. Token::verify_with_key in src/token.rs:191-225 checks the token's protected alg against the caller supplied VerifyingKey before running any cryptography, and the regression test in src/tests.rs:1727-1749 reproduces the public-key-as-HMAC-secret forgery and verifies that it is rejected with InvalidAlgorithm. Restricting the deprecated raw-byte verify path to HMAC in src/token.rs:163-173 also removes the unsafe asymmetric verification path from the new ES256/PS256 API surface.
The blocker I would fix before merge is in the examples. Both examples/asymmetric_signing.rs:91-95 and examples/sample_es256_ps256_tokens.rs:91-95 still include an Algorithm::HmacSha256 match arm that constructs VerifyingKey::HmacSha256(&public_key). Those functions are only called with ES256 and PS256 today, so this is not currently executed, but it documents exactly the unsafe key-confusion pattern the PR is meant to prevent: treating public key bytes as an HMAC secret. Since these are user-facing examples for the new safe API, I would make the HMAC arm explicitly unreachable or structure the example so only asymmetric algorithms can reach that code. That keeps the examples from teaching a dangerous fallback to readers who copy the pattern later.
I would also clean up the release and deprecation story before landing. src/token.rs:159-162 marks verify as deprecated since 0.3.0, while Cargo.toml:3 still declares 0.2.7. If this branch is intended to become a 0.3.0 breaking release, that should be made consistent with the versioning plan. If it is not, the since value should match the version that will actually ship. The PR description also calls the change source-compatible, but existing HMAC callers will now receive a deprecation warning, which can break downstream crates that build with #![deny(warnings)] or equivalent CI settings. That does not make the security fix wrong, but it does mean the compatibility impact should be described more carefully.
The earlier formatting concern appears to be resolved now. The test suite, formatting check, clippy, and the two asymmetric examples all pass locally for me on the latest stable toolchain. Once the example fallback and version/deprecation wording are tightened, I think this is a good security hardening change to merge into the ES256/PS256 work.
14e25e5 to
68e78a2
Compare
`Token::verify(&[u8])` dispatched the verification algorithm from the token's self-declared `alg` header and applied it to the caller's raw key bytes. With the new asymmetric algorithms this is exploitable: an attacker can forge a token with `alg = HmacSha256` and a MAC computed over the victim's *public* ES256/PS256 key (which is not secret), and a verifier calling `verify(public_key)` would accept it. Add `VerifyingKey`, a typed key enum that carries its own algorithm, and `Token::verify_with_key`, which rejects any token whose header `alg` does not match the key's algorithm before running cryptography. The algorithm is now pinned by the key the relying party supplies, not by the token. `verify(&[u8])` is deprecated and restricted to HMAC-SHA256 only, so it can no longer be used to mount the attack while remaining backward compatible for existing HMAC callers. Tests, examples, README, and crate docs are migrated to `verify_with_key`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses PR review: cargo fmt --check failed on import grouping in the asymmetric examples and the deprecated verify() call in tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-trip, mutation, and lossy-claim tests that landed in #5 (now on main) call the deprecated Token::verify. After rebasing onto main, convert those call sites to verify_with_key with a typed VerifyingKey so the suite builds clean under -D warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68e78a2 to
627663b
Compare
| /// let secret = b"my-secret-key"; | ||
| /// let key = VerifyingKey::HmacSha256(secret); | ||
| /// ``` | ||
| #[derive(Debug, Clone, Copy)] |
| /// public key. | ||
| pub fn verify(&self, key: &[u8]) -> Result<(), Error> { | ||
| /// COSE_Sign1 structure. The key must be the SPKI DER-encoded public key. | ||
| pub fn verify_with_key(&self, key: VerifyingKey) -> Result<(), Error> { |
jedisct1
left a comment
There was a problem hiding this comment.
Thanks for the update. I reviewed PR #6 against the current main branch after #5 landed, read the PR description, commit list, existing Swival review, Copilot comments, and review threads, and traced the affected paths in src/header.rs, src/token.rs, the examples, README, and the regression tests. I also validated the branch locally with rustup run stable cargo fmt -- --check, rustup run stable cargo test, rustup run stable cargo clippy --all-targets -- -D warnings, and the asymmetric_signing, sample_es256_ps256_tokens, and basic_usage examples. This is an automated review by Swival (https://swival.dev) using only open-source models, intended to help both the author and the maintainers.
My recommendation is not to merge this quite yet, but I would not close it. The core fix is valuable, and it is much better to finish this PR than to leave main with the documented asymmetric verify(public_key) pattern from #5 for longer than necessary.
The important part of the implementation looks right. Token::verify_with_key checks the protected-header algorithm against the caller supplied VerifyingKey before any cryptographic verification in src/token.rs:224-234, then dispatches to the matching primitive. The regression test in src/tests.rs:2269-2291 reproduces the HMAC-over-public-key forgery and confirms that an ES256 verifier rejects it with InvalidAlgorithm. The examples that previously had an HMAC fallback over public_key have also been fixed in examples/asymmetric_signing.rs:97-101 and examples/sample_es256_ps256_tokens.rs:97-101, and Cargo.toml:3 now matches the 0.3.0 deprecation version.
The first blocker I would fix is #[derive(Debug)] on VerifyingKey in src/header.rs:165. VerifyingKey::HmacSha256 carries the raw shared secret bytes, so a derived Debug implementation will print the secret if the value is ever logged, included in a panic, or captured by generic diagnostics. This is a security API, and the safe default should not expose key material. I agree with Copilot on this point. Please remove Debug, or provide a manual implementation that redacts the key bytes while still showing the variant name.
I would also mark VerifyingKey as #[non_exhaustive] before publishing it. Algorithm is already non-exhaustive, and its docs in src/header.rs:44-46 explicitly say future algorithm support should not break downstream matches. VerifyingKey is the public companion enum that mirrors those algorithms, so leaving it exhaustive creates the opposite compatibility story: the next supported algorithm will require adding a new VerifyingKey variant and will break downstream exhaustive matches. This is easiest to get right now, before the type is released.
One more safety point needs tightening in the docs and tests around the deprecated raw-byte API. The current text in src/token.rs:177-195 says verify now only accepts MAC tokens and returns InvalidAlgorithm for ES256 and PS256, which is true. But the actual alg-confusion exploit token declares HmacSha256, not ES256 or PS256. Because verify still routes Algorithm::HmacSha256 to verify_with_key(VerifyingKey::HmacSha256(key)) in src/token.rs:201-203, a caller who keeps doing verify(public_key_der) on attacker supplied tokens can still accept the forged HMAC token. The safety guarantee comes from migrating asymmetric verification to verify_with_key(VerifyingKey::Es256(...)) or VerifyingKey::Ps256(...), not from making the legacy raw-byte method intrinsically safe.
I do not think that means the design is wrong, since keeping HMAC compatibility is a reasonable tradeoff. I do think the deprecation docs, PR compatibility wording, and tests should avoid implying that verify itself can no longer be used in the confusion pattern. A small wording change plus a regression or comment that demonstrates the legacy method remains HMAC-only and must not be used with asymmetric public keys would make the boundary much clearer for future maintainers. If the intended guarantee is stronger than that, then the raw-byte API needs a harder break rather than just rejecting tokens whose own header says ES256 or PS256.
I do not consider Copilot's VerifyingKey<'_> lifetime comment a merge blocker. The branch passes clippy with warnings denied, and this repository does not enable elided_lifetimes_in_paths. Making the lifetime explicit in the public signature would be fine for readability, but it is not the issue I would hold this PR on.
Once the secret-leaking Debug implementation is fixed, the new public enum is made future-compatible, and the legacy verify safety wording is corrected, I think this should be mergeable and should land promptly against main.
Problem
Token::verify(&[u8])selects the verification algorithm from the token's ownalgheader and applies it to the caller-supplied key bytes. Before this branch (HMAC-only, secret key) that was safe. With ES256/PS256 it becomes a full authentication bypass:token.verify(public_key_der)— the public key is, by definition, public.alg = HmacSha256.HMAC-SHA256(key = public_key_der_bytes, mac0_input)and place it in the signature slot.verify()readsalg = HmacSha256, dispatches to the HMAC branch, runs HMAC with the public-key bytes as the secret — it matches → forged token accepted.This is the COSE analogue of the classic JWT RS256→HS256 alg-confusion / key-confusion attack. The
algfield being in the authenticated protected header does not help, because the attacker is forging a fresh token, not tampering with an existing one, and the verification key is public.Fix
Bind the algorithm to the key the relying party supplies, not to the token:
VerifyingKeyenum (HmacSha256/Es256/Ps256) that carries its key material and its algorithm.Token::verify_with_key(VerifyingKey)that rejects any token whose headeralgdoes not match the key's algorithm before running any cryptography, then verifies.Token::verify(&[u8])is#[deprecated]and now only accepts HMAC-SHA256, returningInvalidAlgorithmfor ES256/PS256. This keeps every existing HMAC caller working (all ofmainis HMAC) while removing the vulnerable asymmetric path.This mirrors how
google/cosetand post-CVE JWT libraries handle it: the application pins the algorithm; the token only gets to agree.Compatibility
Source-compatible.
verify(&[u8])'s signature is unchanged; existing HMAC usage compiles and behaves identically (just emits a deprecation note). The only behavior that changes is the not-yet-released asymmetricverify(public_key)path — which is exactly the vulnerable one.Tests
test_alg_confusion_hmac_over_public_key_is_rejected— reproduces the forgery and asserts it's rejected withInvalidAlgorithm.test_deprecated_verify_rejects_asymmetric/test_deprecated_verify_still_works_for_hmac.verify_with_key; wrong-key tests extended to cover both the crypto-mismatch and algorithm-binding rejection paths.cargo clippy --all-targetsclean; both asymmetric examples run and verify.Docs, README, and all examples migrated to
verify_with_key.🤖 Generated with Claude Code