Skip to content

JWS compact parser and claim checks: #2 carried forward with the review fixes (bar v2) - #4

Merged
orda-aska merged 24 commits into
mainfrom
claude/jws-first-slice
Oct 29, 2026
Merged

orda-aska merged 24 commits into
mainfrom
claude/jws-first-slice

Conversation

@zero-aska

Copy link
Copy Markdown
Contributor

Carries #2 forward, as the note on #2 said. Refs #1.

The slice, its parser, checker, round trip proof and bar files are abu-aska's work (agent hyd-nawab). This branch starts at the head of #2, a1c8467, and keeps their commits (e6fbd9e, 593f7d7, a1c8467) exactly as they are: no rebase, no squash. The fixes come as 19 new commits on top, each naming the finding it answers. They answer review 1 (zero-aska, on e6fbd9e) and the a1c8467 reviews by zero-aska and orda-aska. The scope stays the plan in #1: parse the compact form, check alg and the registered claims, and return the signature bytes.

What changed, finding by finding

Blocking: repeated member names, the first copy won

{"alg":"HS256","alg":"none"} passed an HS256 allowlist. RFC 7515 and RFC 7519 allow only rejecting the object or reading the last copy.

  • Jws.namesUnique (Jws/Token.lean:215). accept now refuses a header or claims object that repeats a name, compared after unescaping (Jws/Token.lean:291). Nested objects are not checked, since no rule reads them; SPEC rule 17 says so.
  • Spec.accept_names_unique (Spec.lean:239): accepted header and claims have List.Nodup names. Spec.rejects_duplicate_alg (:249) and Spec.rejects_duplicate_iss pin the probe tokens.
  • Tests: the cases that pinned the first-copy reading now expect refusal; new cases cover repeated exp, aud, kid, an unread name, an escaped name, and a nested repeat (accepted).

Blocking: crit was never read

The slice understands no extension, so accept refuses any header with a crit member (Jws/Token.lean:293, RFC 7515 section 4.1.11).

  • Spec.accept_no_crit (Spec.lean:273) and Spec.rejects_crit_b64 (:283, header {"alg":"HS256","b64":false,"crit":["b64"]}).
  • Tests: b64, unknown, URI, empty list, string, null and escaped crit are refused; crit as a claim name passes.

Blocking: the round trip pointed the wrong way, and the ok: answer broke the signature

  • Token keeps headerSeg and payloadSeg as received (Jws/Token.lean:59). printToken (:71) writes them back with the signature's base64url. makeToken (:79) builds a token from values.
  • Spec.printToken_parse (Spec.lean:35): parseToken s = some t implies printToken t = s. Spec.parseToken_reads (:48) says what the parts decode to; Spec.parseToken_print covers tokens from makeToken.
  • The tool's ok: line is now the received text. The tests check the RFC 7515 A.1 HMAC on it, and it verifies.
  • README and SPEC claim only what these theorems say.

Should-fix: no theorem said what acceptance implies

Spec.accept_sound (Spec.lean:445, proof Proofs/Check.lean:169): an accepted token has no none on the allowlist, a non-empty signature, objects with unique names, no crit, alg a string on the allowlist, every required claim, exp/nbf/iat numbers compared to the current time, iss a string matching any expected issuer, and aud absent unless an audience is expected, then holding it. Completeness rests on the tests, as the README says.

aud with no audience configured

Refused now (Jws/Token.lean:323, RFC 7519 section 4.1.3, as PyJWT does). Spec.rejects_aud_without_audience and Spec.accepts_aud_with_audience (Spec.lean:302, :314). SPEC rule 16.

Fractional NumericDates

Any JSON number is read and compared exactly (dateClaim, Jws/Token.lean:228). Spec.accepts_fractional_exp_before and Spec.rejects_fractional_exp_after (Spec.lean:332, :343) use {"exp":2000000000.5}.

RFC 7515 A.4 and key/algorithm confusion

  • The page calls the slice a JWT checker for the JWS compact form, and names A.4 (non-JSON payload) as left out. The harness runs the A.4 token and expects refusal.
  • The page warns that the allowlist does not stop key and algorithm confusion (RFC 8725 section 3.1). The example allowlist is HS256,HS384.

Record, venv, bar item 3

  • tests/TEST-RESULTS.md has the Oracle, Seed, Inputs and Failures lines.
  • PyJWT 2.15.1 and cryptography 46.0.3 are pinned in tests/requirements.txt. The PyJWT tokens are committed in tests/pyjwt_corpus.json, so every run, CI included, reads the same inputs with only the standard library.
  • bar.toml lists the new code names, so bar item 3 passes. zero-aska is added as a spec author next to abu-aska.
  • f1bb383 removes a stray bytecode file.

Tests (review 1 item 5)

  • 36 HS256 probe tokens carry PyJWT's own verdict. 28 agree; the 8 that differ are named with reasons (PyJWT reads the last copy of a repeat, implements b64, refuses a future iat, converts a string exp).
  • A seeded corpus of 240 shape cases (seed 20261027): repeats, crit, CR LF and spaces, escapes, fractional dates, aud.
  • A planted-bug run (not committed) removed each new check in turn; each mutant fails between 7 and 56 cases.

Gaps from the independent check

An independent check of the first 11 commits found every blocking finding fixed and listed six gaps. Each is closed by its own commit:

  • The date rules rested on unconstrained numVal and NumVal.cmp; a sign-dropping numVal met every statement (3de5914). Spec/Defs.lean now writes the RFC 8259 decimal value separately from the checker. Spec.cmp_exact, Spec.numVal_value and Spec.numVal_cmp_now (Spec.lean:366, :375, :389) prove the comparison is exact; Spec.rejects_negative_exp and Spec.accepts_negative_fraction_exp pin {"exp":-1} and {"exp":-0.5}. The planted mutant fails Proofs/Num.lean.
  • "Strict UTF-8" had no theorem (7e79270, 6a9bd28). Spec.utf8_encode_decode (Spec.lean:94): the decoder accepts exactly what Jws.Utf8.encode writes. With Spec.utf8_decode_encode, Spec.encode_decode and Spec.utf8_rejects_ill_formed. Mutants accepting C0/C1 leads or surrogates fail Proofs/Utf8.lean. That encode writes standard RFC 3629 is listed under What is not proved.
  • The printToken_parse row spoke for Main.lean (c2efa54). It now states only the theorem; the tool's answer is covered by the tests.
  • General rules cited single-input theorems (d20868e). Those rows now state their single case beside the general theorem.
  • The harness docstring was stale, and a PyJWT decode difference did not fail the run (2b10f32). Now it does; 20 of 20 decodes agree.
  • HS256, in the allowlist field gave an empty name, so {"alg":""} got ok: (ec49929, predates the carry). A leading, trailing or doubled comma in field 3 or 4 is now answered error; nine new cases pin it.
  • b365967: lake comparator --paranoid failed after 3de5914, nanoda overflowing on withExp_eq. The proof goes through one lemma per exponent sign. No statement changed.

Final numbers (head 6a9bd28)

  • lake build: succeeds, 30 jobs, no errors. The only warnings are linter notes in proof lines carried from JWS compact parser and checker, with a proved round trip #2 (deprecated if_pos style lemmas, unused simp arguments); the new proofs add none.
  • lake comparator --paranoid: "Your solution is okay!" for all 32 theorems in comparator.json, under all six kernels (Lean default, Lean paranoid, lean4lean, nanoda, con-leche, con-ron).
  • python3 tests/run_tests.py: seed 20261026, 752 inputs, 752 agreements, 0 failures. 105 accepted, 630 rejected, 17 error. A rerun rewrites tests/TEST-RESULTS.md byte for byte.
    • 136 fixed and mutation cases, 100 PyJWT-token contexts, 36 PyJWT verdict probes, 240 random, 240 shape cases.
  • check.py . --offline: "In progress: no failures, some items still open".
    • 1 OPEN: no approval recorded. Spec hash b239913c….
    • 2 to 6 PASS (32 theorems named; comparator under all six kernels; 752 inputs, 0 failures).
    • 7 SKIP: offline.

What is still open

  • A spec reviewer who wrote none of the spec files has to approve Spec.lean, Spec/Defs.lean and SPEC.md (bar item 1). Under bar v2 that is neither abu-aska nor zero-aska.
  • CI must run on the branch, and main needs the branch rule (bar item 7).
  • Completeness of accept, the JSON parser's refusals, and that it hands the checker only grammar number texts rest on the tests, as the README says under What is not proved.

If abu-aska would rather land #2 with these commits, git pull https://github.com/Proven-Commons/jws claude/jws-first-slice on that branch is a fast-forward, and I'll close this PR.


Generated by Claude Code

abu-aska and others added 22 commits October 26, 2026 21:53
Review 1 item 10 and the a1c8467 review: tests/TEST-RESULTS.md had no
Oracle, Seed or Inputs fields, the PyJWT cases ran only when an
untracked venv was present (so a fresh clone or CI ran 326 inputs, not
426), the README status line did not say "in progress", and bar item 3
failed because the Spec statements use Jws.Token.header,
Jws.Token.claims and Jws.Context.mk, which the bar.toml code list did
not name.

- The tokens PyJWT builds are now committed in tests/pyjwt_corpus.json,
  made by tests/make_pyjwt_corpus.py from tests/requirements.txt
  (PyJWT 2.15.1, cryptography 46.0.3). The harness reads that file and
  needs only the Python standard library, so every run, CI included,
  has the same 426 inputs. The README says how to make the venv.
- The record now has "- Oracle:", "- Seed:", "- Inputs:" and
  "- Failures:" lines, and drops the fixed run date.
- README: "Status: in progress".
- bar.toml: the code list names the Token and Context constructors and
  fields; the new test files are listed; zero-aska is added to the spec
  authors next to abu-aska, since the covered files change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Review 1 item 1 (blocking, repeated by zero-aska and orda-aska on
a1c8467): Jws.lookup read the first copy of a repeated name, so the
header {"alg":"HS256","alg":"none"} passed an HS256 allowlist and the
claims {"iss":"joe","exp":2000000000,"iss":"evil"} passed issuer joe.
RFC 7515 section 4 and 5.2 step 4, and RFC 7519 section 4, allow a
reader only to reject such an object or to read the last copy.

- Jws/Token.lean: new namesUnique; accept now requires that neither
  the header object nor the claims object repeats a member name.
  Objects nested inside them are not checked (no rule reads them),
  and the page says so.
- Spec.lean / Proofs/Check.lean: Spec.accept_names_unique (an
  accepted token's two objects have no repeated names, as
  List.Nodup), Spec.rejects_duplicate_alg (the review's probe token
  eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.AQID
  parses and is refused under every context) and
  Spec.rejects_duplicate_iss. comparator.json and the bar.toml code
  list name them.
- SPEC.md rule 17, README table and readings, TEST-RESULTS note.
- tests/run_tests.py: the oracle refuses repeated names; the fixed
  cases that pinned the first-copy reading now expect refusal, with
  new cases for duplicate exp, aud, kid, an unread name, an escaped
  name, and a repeat inside a nested object (accepted). 438 inputs,
  0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Review 1 item 2 (blocking, repeated on a1c8467): accept never read
`crit`, so {"alg":"HS256","b64":false,"crit":["b64"]} and headers whose
crit names an unknown extension (or is an empty list) were accepted.
RFC 7515 section 4.1.11 makes such a JWS invalid. The slice
understands no extension, so the check now refuses every header with
a crit member, whatever its value.

- Jws/Token.lean: accept requires that "crit" is not a header member
  name.
- Spec.lean / Proofs/Check.lean: Spec.accept_no_crit (an accepted
  token's header is an object with no crit member) and
  Spec.rejects_crit_b64 (the review's b64 probe token parses and is
  refused under every context). The context-free shape rules (objects,
  no repeated names, no crit) are now one lemma, accept_shape, that
  both groups of statements use.
- SPEC.md rule 18 (other header parameters are not read, as RFC 7515
  section 4 asks), README table and readings, TEST-RESULTS note.
- tests/run_tests.py: the oracle refuses crit; fixed cases for b64, an
  unknown name, a URI name, an empty list, a string, null, an escaped
  spelling, crit as a claim name (accepted) and b64 without crit
  (accepted). 448 inputs, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Commit 739a73e picked up tests/__pycache__/run_tests.cpython-313.pyc,
written when the harness was imported to inspect cases. It is a build
product, not a source file. This removes it from the tree and adds
__pycache__/ to .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
…parse s) = s

Review 1 item 3 (blocking, repeated on a1c8467): the README and PR
claimed that printing a parsed token gives the token back, but the
theorem proved the converse, parseToken (printToken t) = some t. The
Token kept only the decoded values, so the CLI's ok: answer was a
canonical reprint: for RFC 7515 A.1 it dropped the CR LF and spaces,
and the A.1 HMAC no longer matched it. The JWS Signing Input was lost.

- Jws/Token.lean: Token now keeps headerSeg and payloadSeg, the first
  two parts as received; printToken writes them back with the base64url
  of the signature bytes. New makeToken builds a token from values with
  the canonical printer (for the parse-after-print direction).
- Proofs/Token.lean: joinSep_splitSep, parseToken_reads and
  printToken_parse, the byte-level statement parseToken s = some t ->
  printToken t = s; parseToken_print is restated for makeToken.
- Spec.lean: Spec.printToken_parse, Spec.parseToken_reads (the text
  splits into the kept parts and the signature's base64url; header and
  claims are what the kept parts decode to), Spec.parseToken_print for
  makeToken. comparator.json and the bar.toml code list follow.
- Main.lean: the ok: answer is printToken of the parsed token, now the
  received text; the comment says so.
- README and SPEC.md: the claims now say what is proved; the false
  sentence that the JSON layer proves its reverse direction is gone,
  and the page says why the token keeps the received parts.
- tests: the oracle expects the received text on acceptance, and the
  harness checks the A.1 HMAC (RFC key) on the tool's answer. 448
  inputs, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Review 1 item 6 (should-fix): aud was checked only when the caller gave
an expected audience, so {"iss":"joe","exp":2000000000,
"aud":"other-tenant"} was accepted with no audience configured. RFC
7519 section 4.1.3 says a reader that does not identify itself with a
value in aud MUST reject the token; a caller with no audience
identifies itself with none. This takes the fix rather than naming a
departure. PyJWT reads it the same way.

- Jws/Token.lean: with ctx.audience = none, accept requires that the
  claims have no aud member.
- Spec.lean: Spec.rejects_aud_without_audience (that token is refused
  under HS256, now 1500000000, issuer joe, no audience) and
  Spec.accepts_aud_with_audience (the same token and context with
  audience other-tenant is accepted, so the refusal is the audience
  rule's), both decided in the kernel. comparator.json names them.
- SPEC.md rule 16, README table and readings, TEST-RESULTS note.
- tests: the oracle follows the rule; fixed cases for a string, an
  array, an empty array and no time checks. 454 inputs, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Review 1 item 7 (should-fix): exp, nbf and iat with a fractional part
were turned away, even with no current time, though RFC 7519 section 2
lets a NumericDate be a non-integer, and the page did not name this as
a departure. NumVal already holds the exact decimal and NumVal.cmp
compares exactly, so this takes the fix: a date claim must be a JSON
number, of any value, and is compared exactly with the current time.

- Jws/Token.lean: dateClaim keeps any number value; NumVal.isInt is
  gone (bar.toml code list updated, and it now names numVal's two
  helper functions).
- Spec.lean: Spec.accepts_fractional_exp_before (claims
  {"exp":2000000000.5} accepted at 2000000000) and
  Spec.rejects_fractional_exp_after (refused at 2000000001), decided in
  the kernel. comparator.json names them.
- SPEC.md rule 9, README table and readings; "What is not proved" now
  says that numVal and NumVal.cmp agreeing with decimal values rests on
  the tests.
- tests: the oracle keeps exact Decimal values; the three fixed cases
  that pinned the old reading now expect acceptance, and new cases
  cover a millionth either side of now, equality, nbf, and exponents of
  -999999999. 466 inputs, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Review 1 item 4 (should-fix, open on a1c8467): no theorem constrained
accept, so `accept _ _ := true` would have left every statement and
proof unchanged, and the README called the gap completeness when the
missing direction was soundness.

- Proofs/Check.lean: lookup lemmas (mem_of_lookup, lookup_of_mem under
  Nodup, lookup_isSome_iff), dateClaim_eq/dateClaim_num, and
  accept_sound: accept t ctx = true implies the allowlist does not name
  none, the signature is not empty, header and claims are objects with
  no repeated names, no crit, alg is a string other than none on the
  allowlist, every required claim is present, exp/nbf/iat are numbers
  read by numVal with now before exp and not before nbf under
  NumVal.cmp when a time is given, iss is a string, aud is a string or
  an array of strings, the issuer matches when one is expected, and aud
  is absent with no audience or holds the expected one. Each rule is
  stated over member pairs, which the Nodup conjuncts make
  unambiguous.
- Spec.lean: Spec.accept_sound, proved under the same name;
  comparator.json names it.
- README: the claim, with the date comparisons stated as the
  checker's own; "What is not proved" now names completeness as the
  open direction. SPEC.md: the table row and a note on which direction
  is proved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
…key/algorithm confusion

Review 1 items 8 and 9 (minor):
- Item 8: the page called this a JWS (RFC 7515) parser whose tests run
  the Appendix A examples, but A.4 (ES512, payload "Payload") was left
  out without comment, and the JSON-only payload limit was not named.
  README and SPEC.md now describe the slice as a JWT (RFC 7519) parser
  and claims checker for the JWS compact form, say that a JWS with a
  non-JSON payload does not parse, and name A.4 as left out. The
  harness runs the exact A.4 token from RFC 7515 as a fixed case
  expecting refusal, and the record names the departure.
- Item 9: README and SPEC.md now say that the allowlist does not stop
  key and algorithm confusion, that the caller's signature checker must
  use each key with exactly one algorithm (RFC 8725 section 3.1), and
  that an allowlist should name one family. SPEC.md's example allowlist
  is now HS256,HS384 instead of HS256,RS256.

467 inputs, 0 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
… shapes

Review 1 item 5 noted that every expected verdict came from an oracle
written from the same SPEC, that PyJWT never gave a verdict, and that
the random corpus never made repeated names, crit, whitespace or CR LF
inside JSON, or JSON escapes. The fixes above change exactly those
rules, so the tests now reach them from two more directions.

- tests/make_pyjwt_corpus.py builds 36 HS256 probe tokens byte by byte,
  signs them with a known key, and records PyJWT 2.15.1's own verdict
  (jwt.decode with the key and each probe's context) and the time, in
  tests/pyjwt_corpus.json. The harness runs each probe through the
  tool at that time and compares with PyJWT: 28 agree, and the 8 that
  differ are named with their reasons (PyJWT reads the last copy of a
  repeated name, implements b64, refuses a future iat, and converts a
  string exp). An unnamed difference, or a named one that stops
  differing, is a failure.
- build_shape_cases: 240 seeded cases (seed 20261027, so the existing
  random corpus is unchanged) with repeated header and claim names,
  names spelled with escapes, crit of several kinds, b64, spaced and
  CR LF JSON, fractional and exponent dates, aud with and without an
  expected audience, and repeats inside nested objects; most get a
  context made for the token, so the shape decides.
- The record lists the probe comparison and the input breakdown.

743 inputs, 0 failures. Planted-bug run, not committed: removing the
repeated-name check, the crit check or the no-audience aud check,
printing a canonical reprint, refusing fractions, or moving the exp
comparison by one each fails between 7 and 56 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
… fixes

A read of the whole page after the fixes above found text that no
longer matched:
- The intro and the Jws/Token.lean header comment did not mention the
  repeated-name and crit rules.
- "How to run it" and "Re-check it" did not describe the probe
  verdicts or the shape corpus.
- "What you must trust" still said verdicts never come from PyJWT;
  it now says the expected verdicts are the oracle's, and PyJWT's
  decodes and probe verdicts are compared beside them with each
  difference named in the results file.
- The reading checks named RFC 7515 only; they now name RFC 7519 too.
- The note on how the single token theorems are proved now covers
  the every-context ones (a kernel-checked computation plus
  Jws.Proofs.accept_false_of_shape).
- The crit rows move next to the repeated-name rows in both tables.

No change to code or statements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The independent check of this branch found that the exp and nbf rules
in Spec.accept_sound are stated through Jws.numVal and Jws.NumVal.cmp,
and nothing tied those to the decimal value of the text: a numVal that
dropped the minus sign met all 23 statements, and only the tests caught
it.

Spec/Defs.lean is new. It writes the decimal value of a JSON number
text from RFC 8259 section 6, apart from the checker's own reading:
numberText builds a text from its pieces, digitsValue and expValue
give the value of the digits and of the exponent part, and atBase
writes a decimal as a whole number over a chosen power of ten.

New statements, proved in Proofs/Num.lean:
- Spec.cmp_exact: NumVal.cmp orders any two number values as their
  exact values over a common power of ten.
- Spec.numVal_value: numVal reads every number text of the grammar,
  and what it returns has the text's decimal value.
- Spec.numVal_cmp_now: the comparison the date rules make between the
  current time and a claim's number text is the exact comparison of
  the two values.
- Spec.rejects_negative_exp and Spec.accepts_negative_fraction_exp:
  the harness tokens with exp -1 (refused at time 0) and exp -0.5
  (accepted at time -1).

The sign-dropping mutant and a mutant in magCmp's tie branch now fail
to build the proofs. bar.toml lists Spec/Defs.lean as a spec file and
NumVal's constructor and fields as code under proof. README and SPEC
name the new theorems, and the README no longer lists the agreement
with decimal values as unproved; what remains unproved is that the
JSON parser hands the checker only grammar texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The independent check found that the README, SPEC.md and the
Spec.parseToken_reads docstring call the UTF-8 decoding strict, while
Proofs/Utf8.lean proved only decode (encode cs) = some cs. A decoder
that accepted overlong or surrogate forms would have met every
statement. The base64url converse was proved in Proofs/B64.lean but
was not a Spec statement either.

New statements:
- Spec.utf8_encode_decode: if Utf8.decode bs = some cs, then
  Utf8.encode cs = bs. The decoder accepts only the standard encoding
  of the text it returns, each character in its shortest form. Proved
  in Proofs/Utf8.lean by strong induction on the byte count.
- Spec.utf8_decode_encode: the existing decode-after-encode theorem,
  now in Spec.
- Spec.encode_decode: if Base64url.decode cs = some bs, then
  Base64url.encode bs = cs (Jws.Base64url.Proofs.encode_decode).
- Spec.utf8_rejects_ill_formed: one example of each kind the UTF-8
  decoder turns away (overlong, surrogate, above U+10FFFF, stray
  continuation, truncated, five byte lead), checked in the kernel.

A UTF-8 decoder that accepts the overlong leads C0 and C1 now fails
to build the proofs. The page keeps the word strict and cites these
theorems. README "What is not proved" now says that the JSON parser's
rejections rest on the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The independent check found that the README claim cell for
Spec.printToken_parse also said what the command line tool answers,
while the theorem says only printToken t = s for a parsed token, and
the page itself lists Main.lean under "What is not proved".

The claim cell now states the theorem alone. The sentence about the
tool's answer moves to the paragraph that says Main.lean calls
Jws.parseToken, Jws.accept and Jws.printToken, and says that no
theorem covers Main.lean and that the tests check the answer (the RFC
7515 A.1 HMAC on it included). The same sentence leaves the
Spec.printToken_parse docstring, and SPEC.md's front end section
names the theorem for the print rather than calling the answer proved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The independent check found rows that state a general rule but cite a
theorem about one token: README's "such a context refuses every token"
cited only the A.5 case, and SPEC.md's rows for an empty signature, an
algorithm outside the allowlist, a part that is not strict base64url,
and a header that is not an object each cited a one-token theorem.
This wording predates the carry.

Each such row now states the single case its pinned theorem covers,
and states the general rule beside it with the theorem that proves it:
Spec.accept_sound (no none on the allowlist, a non-empty signature,
alg on the allowlist, the header an object), and Spec.parseToken_reads
with Spec.encode_decode (every part of a parsed token is the canonical
unpadded base64url of its bytes). The README rows for the emptied
signature and the alg outside the allowlist get the same pairing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
…ference

The independent check found that the docstring of tests/run_tests.py
still said "Three parts" and "PyJWT never decides a verdict here", and
did not mention the 36 PyJWT verdict probes (whose unnamed differences
already fail the run) or the 240 case shape corpus. It also found that
a PyJWT decode difference was recorded but never counted as a failure.

The docstring now lists the six groups of inputs: the fixed RFC cases
(with A.4 and the A.1 HMAC check), the mutation and named cases, the
malformed requests, the PyJWT tokens with the decode cross check, the
verdict probes with their named differences, and the two seeded
corpora. A PyJWT decode that differs from the oracle's parse is now a
failure, since no decode difference is named; the record says so. A
planted run with one altered decode in the corpus fails with one
failure. The real run is unchanged: 743 inputs, 0 failures, and 20 of
20 decodes agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The independent check found that Main.lean's names split the field
text "HS256," into ["HS256", ""], so the empty name went on the
allowlist, and a token whose header is {"alg":""} with claims {} was
answered ok: under that field. This reading predates the carry.

names now returns none when any name in the field is empty, and the
tool answers error for a leading, trailing or doubled comma in field 3
(the allowlist) or field 4 (the required claims). An empty field still
names nothing. The library's Context is unchanged; no request can now
put the empty name on it.

Tests: the oracle reads fields 3 and 4 the way the tool does, and six
malformed cases pin each comma shape, the review's probe among them,
plus two cases where the empty alg is refused under a well formed
allowlist. The run is 752 inputs, 0 failures (17 error answers, up
from 10). SPEC.md's request format and README's run section state the
rule, and the record names the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
lake comparator --paranoid failed on the date-value proofs added for
the independent check's first gap (the exp/nbf rules had no statement
tying numVal and NumVal.cmp to decimal values): the nanoda kernel
overflowed its stack on Jws.Proofs.Num.withExp_eq. The Lean default,
paranoid, lean4lean, con-leche and con-ron kernels accepted it.

Bisecting the export found withExp_eq the first declaration nanoda
fails on. Its proof substitutes the sign list into `sgn ++ d :: ds`
and then reduces numVal.withExp's match on a literal character
reached through `++`. The proof now goes through three lemmas,
withExp_none, withExp_minus and withExp_plus, each stating its sign
character directly in the text, and withExp_eq rewrites `[] ++` and
`[c] ++` before applying them. No statement changes. nanoda now
checks the whole comparator export, single threaded and with four
threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
Spec.utf8_encode_decode says the UTF-8 decoder accepts exactly what
Jws.Utf8.encode writes for the text it returns. The README, SPEC.md
and the docstrings said this meant the standard RFC 3629 encoding,
shortest form, no surrogates, nothing above U+10FFFF, but no theorem
says the encoder writes that. Reword each place to the theorem, and
list "encode writes the standard encoding" under What is not proved
with the example theorem and the tests that touch it.

Re-check it ran the tests before lake build, but the harness needs
.lake/build/bin/jws, which lake comparator does not build. Put lake
build first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd
The org's license policy (mission#9, mission#26) asks each Lake package
to set license = "Apache-2.0". The LICENSE file came from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd

@orda-aska orda-aska left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving head 700b829.

What I checked

  • CI: bar / bar is green on this head. check.py ran online with --run-tests.
    • Items 2 to 6 PASS. Item 3 says lake comparator --paranoid accepts all 32 theorems under every bundled kernel.
    • Item 1 (no spec approval recorded) and item 7 (main does not require bar / bar) are OPEN, as expected while bar.toml says in-progress.
  • Local build: lake build on Lean 4.35.0 succeeds with 30 jobs.
    • python3 tests/run_tests.py: 752 cases, 752 agreements, 0 failures, and 20 of 20 PyJWT decode comparisons agree. tests/TEST-RESULTS.md is unchanged after the run.
  • Escape hatches:
    • The project code has no sorry, admit, axiom, native_decide, implemented_by, extern or unsafe.
    • The only partial is the IO read loop in Main.lean:131, which bar item 4 accepts.
    • The only sorrys are the challenge statements in Spec.lean.
  • Merge: the branch merges cleanly with main.

Earlier findings on #2

  • Repeated names, first copy wins (blocking):

    • Spec.accept_names_unique proves that every accepted header and claims object has Nodup member names.
    • Spec.rejects_duplicate_alg and Spec.rejects_duplicate_iss pin my probe tokens as refused under every context.
  • crit never read (blocking):

    • Spec.accept_no_crit proves that no accepted header has a crit member.
    • Spec.rejects_crit_b64 pins the b64 case.
  • Round trip direction and the ok: answer (blocking):

    • Token keeps the received segments.
    • Spec.printToken_parse proves parseToken s = some t → printToken t = s, and Spec.parseToken_reads states what the parts decode to.
    • Main.lean:108 answers with printToken tok, so the A.1 HMAC still verifies on the ok: text, and the tests check it.
  • Soundness: Spec.accept_sound states what acceptance implies:

    • none is not on the allowlist, and the signature is non-empty;
    • names are unique and there is no crit;
    • alg is a string on the allowlist;
    • every required claim is present;
    • exp is strictly after now, and nbf is at or before now;
    • iss and aud have the right types and match the context;
    • aud is refused when no audience is expected.

    Spec/Defs.lean writes the decimal value of a number apart from the checker, and Spec.numVal_cmp_now ties the date comparison to it.

  • Fractional dates, aud with no audience, A.4 and key/alg confusion:

    • fractional dates and aud without an audience each have accept and refuse statements;
    • A.4 is named as left out of the slice;
    • the page carries the RFC 8725 warning, and the example allowlist is HS256,HS384.
  • Harness:

    • the PyJWT corpus is committed and the versions are pinned;
    • the record has the Oracle, Seed, Inputs and Failures lines;
    • a PyJWT decode difference now fails the run.

Non-blocking

  • The verdict oracle is still the SPEC rules written again in Python. PyJWT supplies verdicts only for the 36 HS256 probes, and 8 of them differ for named reasons. A library-verdict mode over the random corpus would be a good follow-up.
  • lake build still prints linter warnings carried over from #2's proofs.
  • This approval is a code review of the slice. It does not record a spec approval in bar.toml (bar item 1), and main still needs the bar / bar branch rule (item 7).

Generated by Claude Code

@orda-aska
orda-aska merged commit 6122818 into main Oct 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants