Status: in progress
A parser and claims checker, in Lean, for JSON Web Tokens
(RFC 7519) in the JWS compact form (RFC 7515). The payload
must be a JSON object, a JWT claims set: RFC 7515 lets a
JWS carry any payload bytes, and a JWS whose payload is
not JSON, such as RFC 7515's own Appendix A.4 example
(payload Payload), does not parse here. The parser
reads the three base64url parts, keeps the first two
exactly as received (they are the signing input a
signature verifier needs), decodes the header and payload
as JSON, and holds the signature bytes unchanged. The
checker runs the shape and claims rules of SPEC.md: no
repeated member names in the header or the claims, no
crit in the header, the algorithm against a caller
allowlist, the required claims, and the exp, nbf,
iss, and aud claims against a context the caller
supplies. The definitions are in Jws/Base64url.lean
(the base64url codec), Jws/Utf8.lean (a strict UTF-8
codec), and Jws/Token.lean (the framing, the claims
reading, and the checker). The JSON parser, printer, value type, and
round trip proof are the json repo's, carried in unchanged
under Json/ and Proofs/RoundTrip.lean, because the
repos in this org do not import each other. The rules are
written out in SPEC.md.
The statements a reviewer approves are in Spec.lean,
with the definitions in Spec/Defs.lean that give the
decimal value of a JSON number text, and their proofs in
Proofs/SpecProofs.lean. No reviewer has approved them
yet, so this project is not called verified.
One limit stands up front: this slice does not verify a signature. A token that passes every check is well formed and its claims are consistent with the check context; it is not thereby verified, and it must never be described as a verified token on its own. Signature verification is the second slice.
The algorithm allowlist does not stop key and algorithm
confusion, such as an RSA public key used as an HMAC
secret. The caller's signature checker must use each key
with exactly one algorithm (RFC 8725 section 3.1), and an
allowlist should name algorithms of one family only (for
example HS256 alone, or RS256,RS384), not HMAC and
public key algorithms together.
lake build builds the libraries, the proofs, and the
command line tool. The tool reads one request per line:
the word check, a space, and six fields separated by
semicolons. Each field is text written as comma separated
decimal character codes: the token text, the current time
(or empty for no time comparisons), the allowed
algorithms, the required claims, the expected issuer, and
the expected audience. The allowlist and the required
claims are names joined by commas, and no name may be
empty: HS256, is a malformed field. It answers ok: and
the codes of the token text as received when the token is
accepted, none when it is not, and error when the
request itself is malformed. SPEC.md states the field
format in full.
lake build
python3 tests/run_tests.py
python3 tests/run_tests.py runs the fixed RFC 7515
Appendix A cases, the mutation cases, the malformed
request cases, a seeded random corpus, and a seeded
corpus of the shapes the rules turn on (repeated names,
crit, whitespace and CR LF, escapes, fractional dates,
aud), each against an oracle implementing the same
rules. It also runs tokens PyJWT built, and HS256 probe
tokens on which PyJWT gave its own verdict, compared
beside the oracle's with every difference named. It
writes the counts to tests/TEST-RESULTS.md and needs
only the Python standard library: the PyJWT tokens and
verdicts are committed in tests/pyjwt_corpus.json, so
every run reads the same inputs. To rebuild that file
with the pinned PyJWT and cryptography:
python3 -m venv venv
venv/bin/pip install -r tests/requirements.txt
venv/bin/python tests/make_pyjwt_corpus.py
Each claim below names the theorem behind it. The
theorems are stated in Spec.lean (the challenge file,
where they end in sorry) and proved in
Proofs/SpecProofs.lean, under the same names.
comparator.json lists all of them.
| Claim | Theorem |
|---|---|
| Printing a parsed token gives back the text that was parsed, byte for byte. | Spec.printToken_parse |
| A parsed token holds the first two parts of the text as received, the header and claims values those parts decode to (the base64url decoder, the UTF-8 decoder, then the JSON parser), and signature bytes whose base64url is the third part; the text has exactly those three parts. Each of the two decoders accepts only its encoder's output, as the rows below prove. | Spec.parseToken_reads |
Parsing the printed form of a token made with Jws.makeToken from header and claims values gives that token back, for values that are well formed in the json slice's sense. |
Spec.parseToken_print |
| Decoding the base64url encoding of any bytes gives the bytes back. | Spec.decode_encode |
| Encoding what the base64url decoder returned gives the decoded text back: the decoder accepts only the canonical, unpadded encoding of its bytes. | Spec.encode_decode |
| Decoding the UTF-8 encoding of any text gives the text back. | Spec.utf8_decode_encode |
Encoding what the UTF-8 decoder returned, with this repo's encoder Jws.Utf8.encode, gives the decoded bytes back: the decoder accepts only that encoder's output for the text it returns. That the encoder writes the standard encoding is not proved (see What is not proved). |
Spec.utf8_encode_decode |
| The UTF-8 decoder turns away the overlong forms C0 80, E0 80 80 and F0 80 80 80, the encoded surrogate ED A0 80, F4 90 80 80 (above U+10FFFF), a stray continuation byte 80, the truncated E2 82, and the five byte lead F8. | Spec.utf8_rejects_ill_formed |
The RFC 7515 A.1 token is accepted when HS256 is allowed and the issuer must be joe, with no current time supplied. |
Spec.accepts_rfc_a1 |
A hand built token with header {"alg":"HS256"}, claims {"iss":"joe","exp":2000000000}, and a 32 byte signature is accepted under HS256 allowed, current time 1500000000, and issuer joe. |
Spec.accepts_hand_built |
The RFC 7515 A.5 unsecured token (algorithm none, empty signature part) is refused when HS256 is the allowed algorithm. |
Spec.rejects_unsecured_none |
The A.5 token is refused when the allowlist itself names none. Such a context refuses every token: Spec.accept_sound proves that the context of an accepted token has no none on its allowlist. |
Spec.rejects_unsecured_none_when_allowlist_names_none, Spec.accept_sound |
The A.5 token is refused when the allowlist names none alongside HS256. |
Spec.rejects_unsecured_none_when_allowlist_mixes_none |
The A.1 token with its signature part emptied is refused, under a context that accepts the token as it stands. In general, an accepted token's signature is not empty (Spec.accept_sound). |
Spec.rejects_empty_signature, Spec.accept_sound |
The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). In general, an accepted token's alg is on the allowlist (Spec.accept_sound). |
Spec.rejects_alg_outside_allowlist, Spec.accept_sound |
The hand built token with a padding character = after its first part does not parse. In general, every part of a parsed token is the canonical, unpadded base64url encoding of its bytes (Spec.parseToken_reads with Spec.encode_decode). |
Spec.rejects_part_not_base64url, Spec.parseToken_reads, Spec.encode_decode |
The hand built token whose header part decodes to the JSON array ["HS256"] is refused. In general, an accepted token's header is a JSON object (Spec.accept_sound). |
Spec.rejects_header_not_object, Spec.accept_sound |
When the check accepts a token, under any context: the allowlist does not name none; the signature is not empty; the header and the claims are JSON objects with no repeated member name; the header has no crit member; the header's alg is a string other than none, on the allowlist; every required claim is present; exp, nbf and iat, when present, are JSON numbers, and when a current time is given it is before exp and not before nbf, as the checker's decimal comparison (Jws.NumVal.cmp on Jws.numVal) orders them, which Spec.numVal_cmp_now below proves exact; iss, when present, is a string; aud, when present, is a string or an array of strings; when an issuer is expected, iss is that string; when no audience is expected there is no aud; and when one is, aud is that string or an array holding it. |
Spec.accept_sound |
| When the check accepts a token, under any context, its header and its claims are JSON objects and neither repeats a member name. | Spec.accept_names_unique |
The token eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.AQID, whose header is {"alg":"HS256","alg":"none"}, parses and is refused under every context. |
Spec.rejects_duplicate_alg |
The token whose claims are {"iss":"joe","exp":2000000000,"iss":"evil"} (header {"alg":"HS256"}, signature bytes 01 02 03) parses and is refused under every context. |
Spec.rejects_duplicate_iss |
When the check accepts a token, under any context, its header is a JSON object with no crit member. |
Spec.accept_no_crit |
The token eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.AQID, whose header is {"alg":"HS256","b64":false,"crit":["b64"]}, parses and is refused under every context. |
Spec.rejects_crit_b64 |
The token eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID, whose claims are {"exp":2000000000.5}, parses and is accepted at the current time 2000000000 with HS256 allowed: a fractional expiry is compared exactly. |
Spec.accepts_fractional_exp_before |
| The same token is refused at the current time 2000000001, after it expired. | Spec.rejects_fractional_exp_after |
Jws.NumVal.cmp, the comparison the date rules use, compares exact decimal values: for any power of ten 10^m with m at most both exponents, it orders two number values as their values written as whole numbers over 10^m (Spec.atBase, in Spec/Defs.lean). |
Spec.cmp_exact |
Jws.numVal reads every number text of the JSON grammar (an optional minus sign, integer digits, optional fraction digits, an optional exponent part) and gives the text's decimal value: the sign, the integer and fraction digits read as one whole number (Spec.digitsValue), and ten to the exponent (Spec.expValue) less the count of fraction digits. |
Spec.numVal_value |
The comparison the exp and nbf rules make between the current time and a claim's number text of the JSON grammar is the exact comparison of the whole number with the text's decimal value. |
Spec.numVal_cmp_now |
The hand built token with claims {"exp":-1} (header {"alg":"HS256"}, the 32 byte signature) is refused at the current time 0 with HS256 allowed. |
Spec.rejects_negative_exp |
The same header and signature with claims {"exp":-0.5} are accepted at the current time -1. |
Spec.accepts_negative_fraction_exp |
The token eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.AQID, whose claims are {"iss":"joe","exp":2000000000,"aud":"other-tenant"}, parses and is refused when HS256 is allowed, the current time is 1500000000, the issuer must be joe, and no audience is expected. |
Spec.rejects_aud_without_audience |
The same token is accepted under the same context when the expected audience is other-tenant. |
Spec.accepts_aud_with_audience |
The token theorems rest on the proofs in
Proofs/Token.lean. The print of a parsed token is the
received text because the token keeps the first two parts
as received and the strict base64url decoder accepts a
signature part only when it is the canonical encoding of
its bytes (Jws.Base64url.Proofs.encode_decode).
Jws.Proofs.parseToken_print is a chain of the three
round trips under it, the JSON text round trip
(Json.Proofs.parse_print, carried in with the JSON
code), the UTF-8 round trip (Spec.utf8_decode_encode),
and the base64url round trip (Spec.decode_encode). The
base64url and UTF-8 layers prove both directions, so each
decoder is exactly the inverse of its encoder; the
converse directions (Spec.encode_decode,
Spec.utf8_encode_decode) are proved in
Proofs/B64.lean and Proofs/Utf8.lean. The theorems about acceptance are
proved in Proofs/Check.lean, and the theorems about the
value of a number in Proofs/Num.lean. The single token theorems
are named fixed cases in the test harness, run through
the same Jws.parseToken and Jws.accept the command
line tool calls. Those with one context are checked in
the kernel with decide +kernel; those that hold under
every context combine a kernel-checked computation on the
token with Jws.Proofs.accept_false_of_shape, which says
that a token failing the context-free shape rules is
refused under every context.
Every theorem above checks against the standard three
axioms only (propext, Classical.choice, Quot.sound):
lake comparator --paranoid allows no other.
The command line tool in Main.lean calls
Jws.parseToken, Jws.accept, and Jws.printToken
directly, the same functions the theorems are about. It
answers an accepted token with Jws.printToken of the
parsed token, which Spec.printToken_parse proves is the
token text it parsed, so the answer's signature still
matches its signing input. That the tool does this is not
a theorem: no statement covers Main.lean (see below),
and the tests check it, including the RFC 7515 A.1 HMAC
on the tool's answer.
- Repeated names inside objects nested in the header or the claims: the check does not look for them (see below), and no theorem covers them.
- That the checker accepts every token
SPEC.mdsays it should, beyond the single inputs the theorems above pin.Spec.accept_soundproves the other direction, that an accepted token meets every rule; that a token meeting every rule is accepted (completeness) rests on the tests: the RFC examples, the mutation cases, and the differential corpus against the harness oracle. - Signature verification, as stated above: nothing about keys, signing inputs, or any algorithm. A caller that reads claims from this slice without verifying the signature has not checked authenticity.
- That the JSON parser turns away every text outside the
RFC 8259 grammar, and lone surrogate escapes. The json
slice proves only that the parser reads back what its
printer writes (
Json.Proofs.parse_print); its rejections rest on the json slice's tests and on the malformed JSON cases here. - That
Jws.Utf8.encodewrites the standard UTF-8 encoding (RFC 3629), each character in its shortest form.Spec.utf8_encode_decodesays the decoder accepts only this encoder's output, so whether the decoder accepts exactly standard UTF-8 rests on the encoder, and no theorem compares it with RFC 3629.Spec.utf8_rejects_ill_formedpins one overlong form of each length, an encoded surrogate, a value above U+10FFFF, a stray continuation byte, a truncated sequence and a five byte lead. The tests send Python's UTF-8 foré(a two byte form) in the fixed cases, the seeded corpus and the PyJWT tokens, and an overlong lead (C0 AF) that must be turned away; one, three and four byte forms beyond ASCII are not exercised. - That the JSON printer gives back the text it parsed.
It does not (it writes a canonical form, without the
whitespace a sender may have used), and the json
slice proves only the parse-after-print direction.
The token therefore keeps its received parts, and
Spec.parseToken_printis stated for tokens made from values withJws.makeToken. - That a token value built by hand, not by
parseTokenormakeToken, has parts and values that match: theTokenstructure does not enforce it, and the theorems cover only those two ways of making one. - That the JSON parser hands the checker only number
texts of the JSON grammar.
Spec.numVal_valueandSpec.numVal_cmp_nowcover every text of the grammar; the json slice's parser checks the grammar before it builds a number, but no theorem here says so. The tests cover it, comparing the date rules with Python'sDecimalon fractions, exponent spellings, and exponents of 999999999 and -999999999. - Reading requests and printing answers in
Main.lean: line splitting, the field format, the decimal code reading. The tests cover them, malformed requests included. - Speed. The code works on lists, which suits proofs, not large tokens.
Six readings worth naming, all stated in SPEC.md and
pinned by tests:
- An empty signature part is well formed base64url, but
the checker turns it away: RFC 7515 never produces an
empty signature for a signing algorithm, and the
algorithm name
noneis turned away outright, together with any context whose allowlist namesnone, so the none case never reaches acceptance. The theoremSpec.rejects_empty_signaturepins the emptied signature on the RFC's own A.1 token. - A header or claims object that repeats a member name
is turned away (
SPEC.mdrule 17), whichever copy would have passed: RFC 7515 section 4 and RFC 7519 section 4 allow a reader only that or reading the last copy.Spec.accept_names_uniqueproves it for every accepted token. Objects nested inside the header or the claims are not checked for repeated names, because no rule reads a member of a nested object. - A header with a
critmember is turned away whatevercritholds (SPEC.mdrule 18): this slice understands no extension, and RFC 7515 section 4.1.11 makes a token invalid whencritnames one the reader does not understand.Spec.accept_no_critproves it for every accepted token. - With no expected audience, a token that carries
audis turned away (SPEC.mdrule 16): RFC 7519 section 4.1.3 asks a reader that does not identify itself with a value inaudto reject the token. PyJWT reads it the same way; some other libraries accept such a token. exp,nbf, andiatmay be fractional (RFC 7519 section 2 lets a NumericDate be a non-integer), and are compared exactly with the current time, a whole number of seconds (SPEC.mdrule 9).- The payload must be JSON (a JWT claims set), so a JWS
with an arbitrary payload, which RFC 7515 allows, is
turned away at parse. RFC 7515 Appendix A.4 (ES512,
payload
Payload) is such a token; the tests run it as a fixed case expecting refusal, and it is left out of the cases expected to pass.
- The Lean kernel (Lean 4.35.0, pinned in
lean-toolchain), which checks every proof. - The Lean compiler, with the C compiler and linker shipped with it, which turns the proved definitions into the program that runs.
- The Lean runtime, which runs that program.
- The hardware the program runs on.
- Every dependency in
lake-manifest.json: the manifest lists no packages, so there are no library dependencies to trust beyond the Lean toolchain itself. - Lean's core library, including the compiled code it puts in place of some definitions the proofs reason about.
- The operating system the program runs on.
- That
SPEC.mdstates the reading of RFC 7515 and RFC 7519 you intend. The proofs are about the parser and checker in this repo following the statements exactly; the fidelity of the spec to the RFC is a reading, pinned inSPEC.mdwhere the RFC leaves a choice open. - The oracle in
tests/run_tests.py, written fromSPEC.md, and PyJWT and cryptography (pinned intests/requirements.txt), which built the tokens and gave the probe verdicts intests/pyjwt_corpus.json. Both are evidence, not proof. The expected verdicts are the oracle's; PyJWT's decodes and its verdicts on the probe tokens are compared beside them, and the results file names each difference and the versions used. - The line handling in
Main.lean(splitting a request line, reading the six fields and their decimal codes, and the partial IO loop that reads lines until the input ends). No statement depends on any of it, and the tests drive it. - There are no exceptions to item 4 of the bar: the project uses none of the features it forbids.
lake build: the libraries, the proofs, and the tool build clean. This also builds.lake/build/bin/jws, which the test harness runs.lake comparator --paranoid: builds the challenge fileSpec.leanand the proofs in a sandbox and checks that each theorem named incomparator.jsonproves exactly the statement in the challenge file, uses no axiom but the three standard ones, and is accepted by Lean's kernel and every other kernel shipped with Lean.python3 tests/run_tests.py: the fixed RFC cases, the mutation cases, the malformed request cases, the two seeded corpora, and the PyJWT tokens and probe verdicts, with counts and any failures written totests/TEST-RESULTS.md. The harness reads the PyJWT tokens and verdicts fromtests/pyjwt_corpus.jsonand needs no library beyond Python's own.- Read
SPEC.mdagainst RFC 7515 and RFC 7519 and check the pinned readings are the ones you want.