8000 Validate ECDSA signature length before JWS verification by naruto-lgtm · Pull Request #53928 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

Validate ECDSA signature length before JWS verification - #53928

Merged
mposolda merged 2 commits into
keycloak:mainfrom
naruto-lgtm:ecdsa-signature-length-check
Nov 6, 2026
Merged

mposolda merged 2 commits into
keycloak:mainfrom
naruto-lgtm:ecdsa-signature-length-check

Conversation

@naruto-lgtm
@naruto-lgtm naruto-lgtm commented Oct 13, 2026 •
Copy link
Copy Markdown
Contributor

concatenatedRSToASN1DER reads only the first signLength bytes of the concatenated R||S value in every crypto provider, so an ES256/384/512 JWS signature with trailing bytes appended still verifies, even though RFC 7518 section 3.4 fixes it to exactly 2 * curve-size octets, and anyone holding a valid token can derive unlimited distinct strings that all pass verification. Reject wrong-length signatures at the one chokepoint the three ECDSA verifier contexts funnel through, so the check holds under the default, FIPS and Elytron providers alike; the regression test is a shared base in the core test-jar with a subclass per crypto module, matching how the existing crypto tests are organized.

Closes #53927.

AI tooling was used to help prepare this change.

Copilot AI balanced review requested due to automatic review settings October 13, 2026 15:11
@naruto-lgtm
naruto-lgtm requested a review from a team as a code owner October 13, 2026 15:11
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The centralized validation correctly closes the signature-malleability issue with comprehensive provider coverage.

0 open findings

What changed in this PR

Enforces RFC 7518 ECDSA signature lengths before provider-specific JWS verification.

Changes:

  • Rejects truncated or trailing-byte ECDSA signatures.
  • Adds shared ES256/384/512 regression tests across all crypto providers.
File Description
core/​.../​ECDSAAlgorithm.java Validates exact signature length.
core/​.../​ECDSASignatureVerifierContextTest.java Adds shared regression coverage.
crypto/​default/​.../​DefaultCryptoECDSASignatureVerifierContextTest.java Runs tests with the default provider.
crypto/​fips1402/​.../​FIPS1402ECDSASignatureVerifierContextTest.java Runs tests with the FIPS provider.
crypto/​elytron/​.../​ElytronCryptoECDSASignatureVerifierContextTest.java Runs tests with the Elytron provider.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@oleksii-udovychenko oleksii-udovychenko left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the signature segment also be decoded strictly, so the same token can't be rewritten at the base64url level?

With this PR, a signature with extra bytes after R || S is rejected for ES256, ES384 and ES512. A token with =anything-at-all appended to its signature segment still verifies, so one valid token still has an unlimited number of accepted string forms.

JWSInput decodes the signature with Base64Url.decode, which passes it through encodeBase64ToBase64Url. That function cuts the input at the first = and maps + and / to - and _, and Base64.getUrlDecoder() ignores non-zero trailing bits. So the decoded bytes are still the 64, 96 or 132 octets the new check expects.

How I tested, on this PR's head with a probe test in crypto/default (default provider):

  1. Generated a key per curve and signed an AccessToken with JWSBuilder and ECDSASignatureSignerContext.
  2. Rewrote only the signature segment, then checked each string with both ECDSASignatureVerifierContext and TokenVerifier.create(...).publicKey(...).verify(). "Accepted" means both passed.

The header and payload can't be rewritten this way, because the signing input is built from the raw strings. I only ran ES256/384/512; Base64Url.decode isn't specific to ECDSA, so other algorithms may behave the same.

Signature segment Expected Actual
Unchanged accepted accepted (all three)
AA appended, 2 extra bytes after R || S rejected rejected (all three, this PR)
=anything-at-all appended rejected accepted (all three)
- and _ replaced with + and / rejected accepted (all three)
== padding appended (ES256, 86 chars) rejected accepted
Last character changed to one of the other 15 values with the same top 2 bits (ES256, 4 unused bits) rejected accepted, 15 of 15

@naruto-lgtm
Copy link
Copy Markdown
Contributor Author

Agreed, that's real, and your table matches the code: encodeBase64ToBase64Url truncates at the first = and remaps +//, and the JDK URL decoder doesn't reject non-zero trailing bits, so several encodings collapse to the same 64/96/132 octets before the length check ever sees them.

I'd keep it out of this PR though. It sits in JWSInput, not in ECDSA, so it applies equally to RS*, PS*, HS* and EdDSA, and Base64Url.decode is lenient on purpose (the comment there notes callers that pass non-url base64), so tightening it globally risks breaking unrelated callers. The narrower fix is a strict decode for the signature segment only in JWSInput: reject =, + and /, reject a length that's 1 mod 4, and require encode(decode(s)) to round-trip to the input so the unused trailing bits must be zero. Header and payload don't need it since the signing input is built from the raw strings.

Happy to open a separate issue for that and follow up with a PR against it, so this one stays scoped to the R||S length check.

@mposolda
Copy link
Copy Markdown
Contributor

@naruto-lgtm Thanks!

There is failing test, which is probably related to your changes. Can you please doublecheck it? Failing test is:

[ERROR] Failures: 
[ERROR]   Base3TestSuite.testDPoPProofInvalidSignature expected: <DPoP verification failure: org.keycloak.exceptions.TokenSignatureInvalidException: Invalid token signature> but was: <DPoP verification failure: org.keycloak.common.VerificationException: Signing failed>

Closes keycloak#53927

Signed-off-by: Nayyar <nayyar@bugqore.com>
@naruto-lgtm
naruto-lgtm force-pushed the ecdsa-signature-length-check branch from f703643 to 74d81df Compare October 27, 2026 07:16
Copilot AI balanced review requested due to automatic review settings October 27, 2026 07:16
@naruto-lgtm
Copy link
Copy Markdown
Contributor Author

Good catch, that one's on me. testDPoPProofInvalidSignature sends an ES256 header with an RSA-length signature, so the new length check threw an IOException, and the ECDSA verifier contexts wrap any exception as VerificationException("Signing failed") instead of returning false. TokenVerifier then never reached TokenSignatureInvalidException.

Pushed a fix (amended into the single commit): the three ECDSA verifier contexts now return false for a wrong-length R || S before the DER conversion, so a bad length is reported as an invalid signature exactly as before, and the check in concatenatedRSToASN1DER stays as the hard stop for the conversion itself. I also tightened the regression test to assert verify() returns false rather than tolerating an exception, which would have caught this.

Verified locally by driving ServerECDSASignatureVerifierContext through TokenVerifier with a 256-byte signature under an ES256 header: before the fix it produces org.keycloak.common.VerificationException: Signing failed, after it produces org.keycloak.exceptions.TokenSignatureInvalidException: Invalid token signature. The crypto tests pass under the default, Elytron and FIPS providers and spotless is clean.

Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

Static review found no blocking issues in the focused validation changes or provider-specific regression test integration.

No open issues 🎉

Review effort: Balanced

@mposolda 10000 mposolda left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@naruto-lgtm Thanks!

@graziang graziang left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@naruto-lgtm thanks! The fix looks good to me, approving. I just left one non-blocking inline comment on the FIPS test guard, feel free to address it here.

@Before
public void before() {
// Run this test just if java is not in FIPS mode
Assume.assumeFalse("Java is in FIPS mode. Skipping the test.", Environment.isJavaInFipsMode());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIPS1402ECDSASignatureVerifierContextTest uses assumeFalse(isJavaInFipsMode()), so it's skipped in the FIPS UT job (which runs with -Dcom.redhat.fips=true). I'd drop the @Before so it runs in both jobs, like BCFIPSECDSACryptoProviderTest.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! The test should be aligned with other tests in the crypto/fips module.

@naruto-lgtm Could you please fix this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Dropped the @Before in 17c9205, so it now runs in both the regular and the FIPS UT job like BCFIPSECDSACryptoProviderTest. The default-provider subclass keeps its assumeFalse since it shouldn't run under FIPS.

10000 @lhanusov lhanusov left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The length checks look correct at 74d81df. The regression tests pass across default, Elytron, and BCFIPS providers, and running them with the pre-fix core classes reproduces the original failure. I also verified the server verifier and TokenVerifier rejection paths. No blocking findings; ready for the scoped signature-length fix.

@mposolda mposolda left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marking as "request changes" due the FIPS1402ECDSASignatureVerifierContextTest (see inline comment).

@naruto-lgtm Could you please take a look?

Closes keycloak#53927

Signed-off-by: Nayyar <nayyar@bugqore.com>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 08:19
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for the ECDSA signature-length validation change.

No open issues 🎉

Review effort: Balanced

@mposolda mposolda left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@naruto-lgtm @lhanusov @graziang Thanks for the PR and reviews!

@mposolda
mposolda merged commit ea78b16 into keycloak:main Nov 6, 2026
96 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ECDSA JWS signatures with trailing bytes are accepted as valid

6 participants

0