feat: add io.jwt.verify_* builtins (13 JWS signature verifiers, OPA 1.17)#115
Merged
Conversation
….17)
Adds io.jwt.verify_{hs,rs,ps,es}{256,384,512} and io.jwt.verify_eddsa —
verifying a compact JWS signature against a key and returning a boolean,
mirroring OPA's builtinJWTVerify including its three-way outcome:
- undefined: not three segments, a non-base64url signature, or (asymmetric)
no usable public key (an unsupported/private key, a malformed/incomplete
key, or a JWK Set in which any key is unbuildable);
- false: well-formed but the signature does not verify (wrong key, wrong key
type, wrong-length ECDSA signature; the header alg is ignored; an empty
HMAC secret verifies normally and so is false; an empty JWK Set has no key
and so is false);
- true: the signature verifies.
Like OPA, verification uses only the signing-input bytes, the decoded
signature, and the key — the header/payload are not parsed, so a token whose
segments are not JSON objects still verifies.
Key handling matches OPA's getKeyFromCertOrJWK, derived from an exhaustive
key-format differential against `opa eval`: the PEM string is dispatched by
its single block's type (mirroring Go's pem.Decode), so only a CERTIFICATE or
a PKIX/SPKI "PUBLIC KEY" block is accepted — PKCS#1 "RSA PUBLIC KEY", bare
DER, a private-key block, trailing bytes after the block, a second block, and
a smuggled header are all undefined. The parsed key must be one Go's
x509.ParsePKIXPublicKey accepts: RSA, EC on a NIST curve (P-224/256/384/521 —
secp256k1/brainpool are undefined), or Ed25519 (not RSASSA-PSS/DSA/DH/PQC). A
JWK / JWK Set is the fallback; a Set verifies if any key does but is undefined
if any key is unbuildable, and a private JWK ("d" member) is rejected. HMAC
uses OpenSSL.secure_compare; ECDSA converts the fixed-width r‖s JWS signature
(es512 is P-521 / 66-byte halves) to DER.
The algorithms differ only by data in VERIFY_ALGORITHMS; the base64url
signature decode is the shared Base64Url.strict_decode. Totality holds for
attacker-controlled tokens and key material.
80 goldens captured from `opa eval` 1.17 across the algorithm × key-format ×
taxonomy matrix; ECDSA round-trip and malformed-key/encoding fuzzing confirm
the DER encoding and crash-safety.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR extends the io.jwt.* builtin family by adding OPA 1.17-compatible compact JWS signature verification builtins (io.jwt.verify_*), including asymmetric key handling via PEM (cert/SPKI) and JWK/JWK Set inputs, plus a golden-fixture driven spec suite to validate OPA parity.
Changes:
- Added 13
io.jwt.verify_*builtins and supporting verification/key-parsing implementation (HMAC/RSA-PSS/ECDSA/EdDSA). - Added JWK/JWK Set parsing for building OpenSSL public keys used by verification.
- Added OPA-derived golden fixtures and RSpec coverage for verification outcomes and registration/type behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| lib/ruby/rego/builtins/jwt/verify.rb | Implements io.jwt.verify_* builtins, signature verification logic, and PEM public key extraction. |
| lib/ruby/rego/builtins/jwt/jwk.rb | Adds JWK/JWK Set parsing to produce OpenSSL public keys for verification. |
| lib/ruby/rego/builtins/jwt.rb | Wires verification into the JWT builtin module and registers the new builtins. |
| spec/ruby/rego/builtins/jwt_verify_spec.rb | Adds RSpec coverage that asserts verification behavior against OPA-derived goldens. |
| spec/fixtures/jwt_verify/goldens.json | Adds OPA-captured golden cases covering algorithm/key-format/taxonomy matrix. |
| sig/ruby/rego.rbs | Updates RBI/RBS signatures for the new JWT verification API surface and helpers. |
| sig/openssl_ext.rbs | Adds missing OpenSSL RBS typing for OpenSSL::PKey.new_raw_public_key used by OKP (Ed25519) JWK handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…terial Mirrors the PEM path's guard: a key string that is not ASCII-compatible and valid in its own encoding (UTF-16/32, invalid UTF-8) is not a usable JWK, so map it to nil (undefined) before JSON.parse. Behavior-preserving — such keys already mapped to undefined via JSON::ParserError on the current json — but it no longer relies on JSON.parse's exact exception taxonomy to keep the registry's totality guarantee for attacker-controlled key material (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the 13
io.jwt.verify_*builtins —verify_{hs,rs,ps,es}{256,384,512}andverify_eddsa— verifying a compact JWS signature against a key and returning a boolean, mirroring OPA 1.17'sbuiltinJWTVerifyincluding its three-way outcome (undefined / false / true). The next member of the plannedio.jwt.*family afterio.jwt.decode(#112).The algorithms differ only by data in
VERIFY_ALGORITHMS; the signature decode reusesBase64Url.strict_decode. HMAC usesOpenSSL.secure_compare; ECDSA converts the fixed-width r‖s JWS signature (es512 = P-521 / 66-byte halves) to DER. Like OPA, verification uses only the signing-input bytes + signature + key — the header/payload are not parsed.Outcome taxonomy (byte-exact to OPA)
Key handling (the hard part)
Matches OPA's
getKeyFromCertOrJWK, derived from an exhaustive key-format differential againstopa evalrather than sampled. The PEM string is dispatched by its single block's type via a linear scan (Go'spem.Decodesemantics): only aCERTIFICATEor a PKIX/SPKIPUBLIC KEYblock whose trailing content is[ \t]*(\r\n|\n)?is accepted. The parsed key must be onex509.ParsePKIXPublicKeyaccepts: RSA, EC on a NIST curve (P-224/256/384/521 — secp256k1/brainpool rejected), or Ed25519. So PKCS#1RSA PUBLIC KEY, bare DER, private-key blocks, RSASSA-PSS/DSA/PQC keys, trailing data, a second block, and a smuggled header are all undefined. JWK / JWK Set is the fallback (a Set verifies if any key does, undefined if any key is unbuildable; a privatedJWK is rejected).Totality / security
Holds for attacker-controlled tokens and key material: malformed/private/unsupported keys → undefined, verification-time errors → false. The PEM scan is linear (no ReDoS) and encoding-guarded (
ascii_compatible? && valid_encoding?), so no input raises out of the registry boundary.Review
Converged through 5 adversarial panel rounds (security, wire-format/OPA cross-validation, architecture, general, simplifier). The panel drove the key-parsing through several rewrites — switching to enumerate-the-grammar-first was what converged it. Findings fixed: private/PKCS#1/DER/RSA-PSS/PQC/non-NIST-curve key acceptance, cert-path bypass, header-smuggle, multi-block/trailing-data, a ReDoS (O(n²) backreference regex → linear), and an invalid-UTF-8 totality bug. A final-round CRLF "finding" was traced to
opa evalflakiness (disproven by a 15× stable differential; the golden generator now requires 4-consecutive-stable captures).86 goldens captured from
opa eval1.17 across the algorithm × key-format × taxonomy matrix. All gates green (rspec 1654/0, rubocop, reek, steep). Gem-stricter residuals (DSA/X25519 keys, EC/OKP private JWKs → undefined where OPA returns false) are documented as safe-direction.🤖 Generated with Claude Code