From e6fbd9e3bf36d777efe4a8634135ce5257b9bdc0 Mon Sep 17 00:00:00 2001 From: shah Date: Mon, 26 Oct 2026 21:53:48 -0500 Subject: [PATCH 01/23] Add JWS compact parser and claims checker, with proved round trip --- .gitignore | 1 + Json.lean | 2 + Json/Parse.lean | 264 ++++++++++ Json/Value.lean | 124 +++++ Jws.lean | 3 + Jws/Base64url.lean | 79 +++ Jws/Token.lean | 290 +++++++++++ Jws/Utf8.lean | 79 +++ Main.lean | 127 +++++ Proofs.lean | 4 + Proofs/B64.lean | 211 ++++++++ Proofs/RoundTrip.lean | 1104 +++++++++++++++++++++++++++++++++++++++++ Proofs/Token.lean | 114 +++++ Proofs/Utf8.lean | 135 +++++ README.md | 78 ++- SPEC.md | 178 +++++++ lake-manifest.json | 6 + lakefile.toml | 16 + lean-toolchain | 1 + tests/TEST-RESULTS.md | 47 ++ tests/run_tests.py | 918 ++++++++++++++++++++++++++++++++++ 21 files changed, 3756 insertions(+), 25 deletions(-) create mode 100644 Json.lean create mode 100644 Json/Parse.lean create mode 100644 Json/Value.lean create mode 100644 Jws.lean create mode 100644 Jws/Base64url.lean create mode 100644 Jws/Token.lean create mode 100644 Jws/Utf8.lean create mode 100644 Main.lean create mode 100644 Proofs.lean create mode 100644 Proofs/B64.lean create mode 100644 Proofs/RoundTrip.lean create mode 100644 Proofs/Token.lean create mode 100644 Proofs/Utf8.lean create mode 100644 SPEC.md create mode 100644 lake-manifest.json create mode 100644 lakefile.toml create mode 100644 lean-toolchain create mode 100644 tests/TEST-RESULTS.md create mode 100644 tests/run_tests.py diff --git a/.gitignore b/.gitignore index 01f8cdb..da49d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .lake/ +venv/ diff --git a/Json.lean b/Json.lean new file mode 100644 index 0000000..c060478 --- /dev/null +++ b/Json.lean @@ -0,0 +1,2 @@ +import Json.Value +import Json.Parse diff --git a/Json/Parse.lean b/Json/Parse.lean new file mode 100644 index 0000000..d01ce14 --- /dev/null +++ b/Json/Parse.lean @@ -0,0 +1,264 @@ +/- + The JSON parser. It reads a list of characters and returns a value + and the characters that follow it, or `none` when the text up to + that point sits outside the grammar of RFC 8259. + + Rules the parser follows: + + - Whitespace is space, tab, line feed, and carriage return, and + nothing else. It may sit before a value, after a value, and + around the marks inside an array or an object. + - A control character inside a string must be escaped. The escapes + are the ones RFC 8259 lists, plus `\uXXXX`. + - A `\uXXXX` escape in the high surrogate range must be followed + by a second `\uXXXX` escape in the low surrogate range, and the + pair is read as one character. A lone surrogate escape is + rejected, because Lean `Char` cannot hold a surrogate half. + - A number is read by the grammar of RFC 8259 and held as its + source text. + + `parseValue`, `parseEls`, and `parseObjEls` take a fuel count. + Each step into an array or an object spends one unit, so the + functions are total on hostile input: a text that nests deeper + than its own length is rejected, never looped on. The top level + `parse` passes one more than the length of the text, which is + always enough for a text the grammar accepts. +-/ + +import Json.Value + +namespace Json + +/-- Whitespace in JSON: space, tab, line feed, carriage return. -/ +def isWs (c : Char) : Bool := + c == ' ' || c == '\t' || c == '\n' || c == '\r' + +/-- Drop the whitespace at the head of the text. -/ +def skipWs : List Char → List Char + | [] => [] + | c :: cs => if isWs c then skipWs cs else c :: cs + +/-- A digit, as a test. -/ +def isDigit (c : Char) : Bool := + decide ('0' ≤ c ∧ c ≤ '9') + +/-- The value of one hex digit, in either case. -/ +def hexVal (c : Char) : Option Nat := + if '0' ≤ c ∧ c ≤ '9' then some (c.toNat - 48) + else if 'a' ≤ c ∧ c ≤ 'f' then some (c.toNat - 87) + else if 'A' ≤ c ∧ c ≤ 'F' then some (c.toNat - 55) + else none + +/-- The character a one letter escape stands for. -/ +def simpleEscape (c : Char) : Option Char := + if c = '"' then some '"' + else if c = '\\' then some '\\' + else if c = '/' then some '/' + else if c = 'b' then some (Char.ofNat 8) + else if c = 'f' then some (Char.ofNat 12) + else if c = 'n' then some (Char.ofNat 10) + else if c = 'r' then some (Char.ofNat 13) + else if c = 't' then some (Char.ofNat 9) + else none + +/-- Read a string body, up to the closing quote. The opening quote is + already consumed. Returns the characters read and the text after + the closing quote. -/ +def parseStrBody : List Char → Option (List Char × List Char) + | [] => none + | c :: rest => + if c = '"' then some ([], rest) + else if c = '\\' then + match rest with + | [] => none + | e :: rest1 => + if e = 'u' then + match rest1 with + | a :: b :: cc :: d :: rest2 => + match hexVal a, hexVal b, hexVal cc, hexVal d with + | some w, some x, some y, some z => + let n := w * 4096 + x * 256 + y * 16 + z + if n < 0xD800 then + (parseStrBody rest2).map fun (cs, r) => (Char.ofNat n :: cs, r) + else if n < 0xDC00 then + match rest2 with + | '\\' :: 'u' :: a2 :: b2 :: c2 :: d2 :: rest3 => + match hexVal a2, hexVal b2, hexVal c2, hexVal d2 with + | some w2, some x2, some y2, some z2 => + let m := w2 * 4096 + x2 * 256 + y2 * 16 + z2 + if 0xDC00 ≤ m ∧ m < 0xE000 then + (parseStrBody rest3).map fun (cs, r) => + (Char.ofNat (0x10000 + (n - 0xD800) * 0x400 + (m - 0xDC00)) :: cs, r) + else none + | _, _, _, _ => none + | _ => none + else if n < 0xE000 then none + else + (parseStrBody rest2).map fun (cs, r) => (Char.ofNat n :: cs, r) + | _, _, _, _ => none + | _ => none + else + match simpleEscape e with + | some ch => (parseStrBody rest1).map fun (cs, r) => (ch :: cs, r) + | none => none + else if c.toNat < 0x20 then none + else (parseStrBody rest).map fun (cs, r) => (c :: cs, r) + +/-- The longest run of digits at the head of the text, and the text + after it. -/ +def takeDigits : List Char → List Char × List Char + | [] => ([], []) + | c :: rest => + if isDigit c then + let (ds, r) := takeDigits rest + (c :: ds, r) + else ([], c :: rest) + +/-- The integer part of a number: a single zero, or a non-zero digit + and the digits after it. -/ +def parseIntPart (cs : List Char) : Option (List Char × List Char) := + match cs with + | [] => none + | c :: rest => + if c = '0' then some (['0'], rest) + else if '1' ≤ c ∧ c ≤ '9' then + let (ds, r) := takeDigits rest + some (c :: ds, r) + else none + +/-- The fraction part of a number when one is there: a point and one + or more digits. When the next character is not a point, the part + is empty. -/ +def parseFracOpt (cs : List Char) : Option (List Char × List Char) := + if cs.head? = some '.' then + match takeDigits cs.tail with + | (d :: ds, r) => some ('.' :: d :: ds, r) + | _ => none + else some ([], cs) + +/-- The exponent part of a number when one is there: `e` or `E`, an + optional sign, and one or more digits. When the next character is + not one of the two letters, the part is empty. -/ +def parseExpOpt (cs : List Char) : Option (List Char × List Char) := + match cs with + | e :: rest => + if e = 'e' ∨ e = 'E' then + let (sgn, rest1) : List Char × List Char := + if rest.head? = some '+' then (['+'], rest.tail) + else if rest.head? = some '-' then (['-'], rest.tail) + else ([], rest) + match takeDigits rest1 with + | (d :: ds, r) => some (e :: (sgn ++ d :: ds), r) + | _ => none + else some ([], cs) + | [] => some ([], []) + +/-- Read a number at the head of the text. Returns the number text + and the text after it. The grammar is the one in RFC 8259: an + optional minus, an integer part, a fraction part, an exponent + part. -/ +def parseNumber (cs : List Char) : Option (List Char × List Char) := + let (sgn, rest) : List Char × List Char := + if cs.head? = some '-' then (['-'], cs.tail) else ([], cs) + match parseIntPart rest with + | none => none + | some (i, r1) => + match parseFracOpt r1 with + | none => none + | some (f, r2) => + match parseExpOpt r2 with + | none => none + | some (e, r3) => some (sgn ++ i ++ f ++ e, r3) + +mutual +/-- Read one value at the head of the text, after any whitespace. + The fuel count bounds how deep arrays and objects may nest. -/ +def parseValue : Nat → List Char → Option (Json × List Char) + | 0, _ => none + | fuel + 1, cs => + match skipWs cs with + | [] => none + | c :: rest => + if c = '"' then + (parseStrBody rest).map fun (s, r) => (Json.str s, r) + else if c = '[' then + (parseEls fuel rest).map fun (vs, r) => (Json.arr vs, r) + else if c = '{' then + (parseObjEls fuel rest).map fun (kvs, r) => (Json.obj kvs, r) + else if c = 't' then + match rest with + | 'r' :: 'u' :: 'e' :: r => some (Json.bool true, r) + | _ => none + else if c = 'f' then + match rest with + | 'a' :: 'l' :: 's' :: 'e' :: r => some (Json.bool false, r) + | _ => none + else if c = 'n' then + match rest with + | 'u' :: 'l' :: 'l' :: r => some (Json.null, r) + | _ => none + else if c = '-' ∨ isDigit c then + (parseNumber (c :: rest)).map fun (n, r) => (Json.num n, r) + else none + +/-- Read the inside of an array, after the opening bracket: values + split by commas, then the closing bracket. -/ +def parseEls : Nat → List Char → Option (List Json × List Char) + | 0, _ => none + | fuel + 1, cs => + match skipWs cs with + | [] => none + | c :: rest => + if c = ']' then some ([], rest) + else + match parseValue fuel (c :: rest) with + | none => none + | some (v, r) => + match skipWs r with + | ']' :: r2 => some ([v], r2) + | ',' :: r2 => + match skipWs r2 with + | ']' :: _ => none + | [] => none + | _ => + (parseEls fuel r2).map fun (vs, r3) => (v :: vs, r3) + | _ => none + +/-- Read the inside of an object, after the opening brace: key and + value pairs split by commas, then the closing brace. -/ +def parseObjEls : Nat → List Char → Option (List (List Char × Json) × List Char) + | 0, _ => none + | fuel + 1, cs => + match skipWs cs with + | [] => none + | c :: rest => + if c = '}' then some ([], rest) + else if c = '"' then + match parseStrBody rest with + | none => none + | some (k, r1) => + match skipWs r1 with + | ':' :: r2 => + match parseValue fuel r2 with + | none => none + | some (v, r3) => + match skipWs r3 with + | '}' :: r4 => some ([(k, v)], r4) + | ',' :: r4 => + match skipWs r4 with + | '"' :: _ => + (parseObjEls fuel r4).map fun (kvs, r5) => ((k, v) :: kvs, r5) + | _ => none + | _ => none + | _ => none + else none +end + +/-- Parse a whole text: one value, with whitespace allowed around it + and nothing after it. -/ +def parse (cs : List Char) : Option Json := + match parseValue (cs.length + 1) cs with + | some (v, rest) => if skipWs rest = [] then some v else none + | none => none + +end Json diff --git a/Json/Value.lean b/Json/Value.lean new file mode 100644 index 0000000..01711c0 --- /dev/null +++ b/Json/Value.lean @@ -0,0 +1,124 @@ +/- + The JSON value type, the canonical printer, and the well formed + predicate that the round trip proof in `Proofs` uses. + + A number is held as its source text. The printer writes that text + back word for word, and no claim is made about the numeric value. + The parser in `Json.Parse` checks the text against the number + grammar of RFC 8259 before it builds a `num` value. + + A string is held as a list of characters. Lean `Char` is a Unicode + scalar value, so a string here can never hold a lone surrogate half. + + An object is held as a list of key and value pairs, in the order + written. Duplicate keys are kept as written. +-/ + +namespace Json + +/-- A JSON value. Numbers and strings and object keys are held as + lists of characters. -/ +inductive Json where + | null + | bool (b : Bool) + | num (cs : List Char) + | str (cs : List Char) + | arr (vs : List Json) + | obj (kvs : List (List Char × Json)) + +/-- The sixteen hex digits, lowercase. -/ +def hexDigits : List Char := + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'] + +/-- The hex digit for a value below 16. -/ +def hexDigit (n : Nat) : Char := + hexDigits.getD n '0' + +/-- The printed form of one character inside a string. The quote and + the backslash take a backslash before them. The five control + characters that have a short form in RFC 8259 use it. Any other + character below U+0020 is written as `\u00XX`. Every other + character is written as it is. -/ +def escape (c : Char) : List Char := + if c = '"' then ['\\', '"'] + else if c = '\\' then ['\\', '\\'] + else if c.toNat = 8 then ['\\', 'b'] + else if c.toNat = 9 then ['\\', 't'] + else if c.toNat = 10 then ['\\', 'n'] + else if c.toNat = 12 then ['\\', 'f'] + else if c.toNat = 13 then ['\\', 'r'] + else if c.toNat < 32 then + ['\\', 'u', '0', '0', hexDigit (c.toNat / 16), hexDigit (c.toNat % 16)] + else [c] + +/-- The printed form of a whole string body, without the quotes. -/ +def escapeAll (cs : List Char) : List Char := + cs.flatMap escape + +/-- A string in its printed form, with quotes. -/ +def printStr (cs : List Char) : List Char := + '"' :: (escapeAll cs ++ ['"']) + +/-- Join printed parts with a comma between each two. -/ +def join : List (List Char) → List Char + | [] => [] + | [x] => x + | x :: y :: rest => x ++ ',' :: join (y :: rest) + +mutual +/-- The canonical printed form of a value: no spaces anywhere, + object pairs in the order held, numbers as their source text. -/ +def print : Json → List Char + | .null => ['n', 'u', 'l', 'l'] + | .bool true => ['t', 'r', 'u', 'e'] + | .bool false => ['f', 'a', 'l', 's', 'e'] + | .num cs => cs + | .str cs => printStr cs + | .arr vs => '[' :: join (vs.map print) ++ [']'] + | .obj kvs => '{' :: join (kvs.map printPair) ++ ['}'] + +/-- One object pair in its printed form. -/ +def printPair : List Char × Json → List Char + | (k, v) => printStr k ++ ':' :: print v +end + +/-- A digit, as a proposition. -/ +def IsDigit (c : Char) : Prop := + '0' ≤ c ∧ c ≤ '9' + +/-- The fraction part of a number text, as RFC 8259 allows it: either + absent, or a point and one or more digits. -/ +def FracOk (f : List Char) : Prop := + f = [] ∨ ∃ d ds, f = '.' :: d :: ds ∧ IsDigit d ∧ ∀ x ∈ ds, IsDigit x + +/-- The exponent part of a number text: either absent, or `e` or `E`, + an optional sign, and one or more digits. -/ +def ExpOk (e : List Char) : Prop := + e = [] ∨ ∃ (ec : Char) (sgn : List Char) (d : Char) (ds : List Char), + e = ec :: (sgn ++ d :: ds) ∧ (ec = 'e' ∨ ec = 'E') ∧ + (sgn = [] ∨ sgn = ['-'] ∨ sgn = ['+']) ∧ + IsDigit d ∧ ∀ x ∈ ds, IsDigit x + +/-- A number text that follows the grammar of RFC 8259: an optional + minus, then either a single zero or a non-zero digit and more + digits, then a fraction part and an exponent part. Stated as a + split of the text into its parts. -/ +def WFNum (cs : List Char) : Prop := + ∃ (neg : Bool) (hd : Char) (intDs frac exp : List Char), + cs = (if neg then ['-'] else []) ++ (hd :: intDs) ++ frac ++ exp ∧ + ((hd = '0' ∧ intDs = []) ∨ ('1' ≤ hd ∧ hd ≤ '9')) ∧ + (∀ d ∈ intDs, IsDigit d) ∧ FracOk frac ∧ ExpOk exp + +/-- A value whose number texts all follow the grammar. Strings, + object keys, and the shape of arrays and objects need no further + condition: every such value prints in a form the parser reads + back. -/ +inductive WF : Json → Prop + | null : WF .null + | bool (b : Bool) : WF (.bool b) + | num {cs : List Char} : WFNum cs → WF (.num cs) + | str (cs : List Char) : WF (.str cs) + | arr {vs : List Json} : (∀ v ∈ vs, WF v) → WF (.arr vs) + | obj {kvs : List (List Char × Json)} : (∀ kv ∈ kvs, WF kv.2) → WF (.obj kvs) + +end Json diff --git a/Jws.lean b/Jws.lean new file mode 100644 index 0000000..671a5fd --- /dev/null +++ b/Jws.lean @@ -0,0 +1,3 @@ +import Jws.Base64url +import Jws.Utf8 +import Jws.Token diff --git a/Jws/Base64url.lean b/Jws/Base64url.lean new file mode 100644 index 0000000..e6f5226 --- /dev/null +++ b/Jws/Base64url.lean @@ -0,0 +1,79 @@ +/- + Base64url, as RFC 4648 section 5 defines it and RFC 7515 uses it: + the URL and filename safe alphabet, with no padding. + + The shape follows the Base64 code in the codec repo: three bytes + at a time into four characters on the way out, four characters at + a time into three bytes on the way back. The differences are the + last two alphabet characters (`-` and `_`) and the missing + padding: a final byte is carried by two characters, two final + bytes by three, and no `=` is ever written or read. + + `decode` is strict. It turns away: any character outside the + alphabet (`=` among them, since padding is not part of the + compact form), a length that is 1 mod 4, and a last character + whose unused bits are not zero. +-/ + +namespace Jws.Base64url + +/-- The character for a value below 64: the alphabet in order, + capitals (values 0 to 25), small letters (26 to 51), digits + (52 to 61), then `-` (62) and `_` (63). -/ +def char (n : Nat) : Char := + if n < 26 then Char.ofNat (n + 65) + else if n < 52 then Char.ofNat (n + 71) + else if n < 62 then Char.ofNat (n - 4) + else if n = 62 then '-' + else '_' + +/-- Encode bytes, three at a time into four characters, with a + short last group and no padding. -/ +def encode : List UInt8 → List Char + | a :: b :: c :: rest => + let n := a.toNat * 65536 + b.toNat * 256 + c.toNat + char (n / 262144) :: char (n / 4096 % 64) :: char (n / 64 % 64) :: char (n % 64) :: + encode rest + | [a, b] => + let n := a.toNat * 65536 + b.toNat * 256 + [char (n / 262144), char (n / 4096 % 64), char (n / 64 % 64)] + | [a] => + let n := a.toNat * 65536 + [char (n / 262144), char (n / 4096 % 64)] + | [] => [] + +/-- The value of one character of the alphabet. -/ +def value (c : Char) : Option Nat := + if 'A' ≤ c ∧ c ≤ 'Z' then some (c.toNat - 65) + else if 'a' ≤ c ∧ c ≤ 'z' then some (c.toNat - 71) + else if '0' ≤ c ∧ c ≤ '9' then some (c.toNat + 4) + else if c = '-' then some 62 + else if c = '_' then some 63 + else none + +/-- Decode, four characters at a time into three bytes. A short + last group of two or three characters gives one or two bytes, + and its unused bits must be zero. -/ +def decode : List Char → Option (List UInt8) + | [] => some [] + | [c0, c1] => + match value c0, value c1 with + | some v0, some v1 => + if v1 % 16 = 0 then some [(v0 * 4 + v1 / 16).toUInt8] else none + | _, _ => none + | [c0, c1, c2] => + match value c0, value c1, value c2 with + | some v0, some v1, some v2 => + if v2 % 4 = 0 then + some [(v0 * 4 + v1 / 16).toUInt8, (v1 % 16 * 16 + v2 / 4).toUInt8] + else none + | _, _, _ => none + | c0 :: c1 :: c2 :: c3 :: rest => + match value c0, value c1, value c2, value c3, decode rest with + | some v0, some v1, some v2, some v3, some tail => + some ((v0 * 4 + v1 / 16).toUInt8 :: (v1 % 16 * 16 + v2 / 4).toUInt8 :: + (v2 % 4 * 64 + v3).toUInt8 :: tail) + | _, _, _, _, _ => none + | _ => none + +end Jws.Base64url diff --git a/Jws/Token.lean b/Jws/Token.lean new file mode 100644 index 0000000..9a222cc --- /dev/null +++ b/Jws/Token.lean @@ -0,0 +1,290 @@ +import Json +import Jws.Base64url +import Jws.Utf8 + +/- + The JWS compact form (RFC 7515 section 5.2): three base64url + parts joined by dots. Part one is the header, a JSON object. + Part two is the payload, the claims set, also a JSON object. + Part three is the signature bytes. + + This slice parses the compact form and runs the plan's checks: + the algorithm against a caller allowlist, the required claims, + and the exp, nbf, iss, and aud claims against a context the + caller supplies. It does not verify the signature; a token that + passes these checks is not thereby verified. Signature + verification belongs to the second slice. + + The JSON code is the json repo's, carried in unchanged (the + repos in this org do not import each other): `Json.parse` reads + a text, `Json.print` writes a value in the canonical form, and + the round trip between them is proved there for well formed + values. +-/ + +namespace Jws + +open Json + +/-- Split a text at every copy of a separator character. The + parts never contain the separator. Splitting also serves the + comma separated name lists of the command line front end. -/ +def splitSep (sep : Char) : List Char → List (List Char) + | [] => [[]] + | c :: rest => + if c = sep then [] :: splitSep sep rest + else + match splitSep sep rest with + | p :: ps => (c :: p) :: ps + | [] => [[c]] + +/-- Join parts with a separator character between them. -/ +def joinSep (sep : Char) : List (List Char) → List Char + | [] => [] + | [p] => p + | p :: q :: ps => p ++ sep :: joinSep sep (q :: ps) + +/-- A parsed token: the header value, the claims value, and the + signature bytes, returned unchanged. -/ +structure Token where + header : Json + claims : Json + sig : List UInt8 + +/-- Print a token: each JSON part in the canonical printer's + form, UTF-8 bytes, base64url without padding, the parts + joined by dots, the signature last. -/ +def printToken (t : Token) : List Char := + joinSep '.' [Base64url.encode (Utf8.encode (Json.print t.header)), + Base64url.encode (Utf8.encode (Json.print t.claims)), + Base64url.encode t.sig] + +/-- Parse the compact form. Framing first: exactly three parts. + Each part must decode as strict base64url, the first two must + then decode as UTF-8 and parse as JSON. Object shape and the + claim rules are the checker's work, not the parser's. -/ +def parseToken (cs : List Char) : Option Token := + match splitSep '.' cs with + | [h, p, s] => + match Base64url.decode h, Base64url.decode p, Base64url.decode s with + | some hb, some pb, some sb => + match Utf8.decode hb, Utf8.decode pb with + | some hc, some pc => + match Json.parse hc, Json.parse pc with + | some hv, some pv => some ⟨hv, pv, sb⟩ + | _, _ => none + | _, _ => none + | _, _, _ => none + | _ => none + +/-- An exact decimal number value, read from a JSON number text: + the value is (if neg then -1 else 1) times sig times ten to + the exp. The form is normalized: zero is (false, 0, 0), and + any other sig does not end in zero. -/ +structure NumVal where + neg : Bool + sig : Nat + exp : Int +deriving DecidableEq + +/-- The value of a run of digit characters. -/ +def digitsVal (ds : List Char) : Nat := + ds.foldl (fun a c => a * 10 + (c.toNat - 48)) 0 + +/-- Divide factors of ten out of a significand, raising the + exponent to match. The fuel is the significand itself, which + is always more steps than stripping can take. -/ +def strip (neg : Bool) : Nat → Nat → Int → NumVal + | 0, s, e => ⟨neg, s, e⟩ + | fuel + 1, s, e => + if s % 10 = 0 ∧ s ≠ 0 then strip neg fuel (s / 10) (e + 1) + else ⟨neg, s, e⟩ + +/-- Build a normalized number value. -/ +def mkNumVal (neg : Bool) (sig : Nat) (exp : Int) : NumVal := + if sig = 0 then ⟨false, 0, 0⟩ else strip neg sig sig exp + +/-- Read a JSON number text as an exact decimal value. The text + is one the JSON parser accepted, so its shape is the number + grammar; anything else is turned away by returning none. -/ +def numVal (cs : List Char) : Option NumVal := + let (neg, r0) := match cs with + | '-' :: r => (true, r) + | _ => (false, cs) + let (ids, r1) := r0.span (·.isDigit) + if ids.isEmpty then none + else + match r1 with + | '.' :: r => + let (fds, r2) := r.span (·.isDigit) + if fds.isEmpty then none + else finish neg ids fds r2 + | _ => finish neg ids [] r1 +where + finish (neg : Bool) (ids fds : List Char) (r : List Char) : Option NumVal := + match r with + | [] => some (mkNumVal neg (digitsVal (ids ++ fds)) (-(fds.length : Int))) + | 'e' :: t => withExp neg ids fds t + | 'E' :: t => withExp neg ids fds t + | _ => none + withExp (neg : Bool) (ids fds : List Char) (r : List Char) : Option NumVal := + let (sgn, r1) := match r with + | '-' :: t => ((-1 : Int), t) + | '+' :: t => ((1 : Int), t) + | _ => ((1 : Int), r) + let (eds, r2) := r1.span (·.isDigit) + if eds.isEmpty ∨ ¬ r2.isEmpty then none + else + some (mkNumVal neg (digitsVal (ids ++ fds)) + (sgn * (digitsVal eds : Int) - (fds.length : Int))) + +/-- Whether a number value is a whole number of seconds. -/ +def NumVal.isInt (v : NumVal) : Bool := + v.sig = 0 || 0 ≤ v.exp + +/-- A whole number as a number value. -/ +def NumVal.ofInt (n : Int) : NumVal := + mkNumVal (n < 0) n.natAbs 0 + +/-- The count of digits a number is written with. -/ +def numDigits (n : Nat) : Nat := + if n < 10 then 1 else 1 + numDigits (n / 10) +termination_by n +decreasing_by omega + +/-- Compare the magnitudes of two nonzero number values. The + decimal point position decides first. On a tie the + significands are aligned by padding the shorter one with + zeros, and the padding counts are digit counts, so a wild + exponent never turns into a wild power of ten. -/ +def magCmp (a b : NumVal) : Ordering := + let da := (numDigits a.sig : Int) + a.exp + let db := (numDigits b.sig : Int) + b.exp + if da < db then .lt + else if da > db then .gt + else + let dsa := numDigits a.sig + let dsb := numDigits b.sig + if dsb ≤ dsa then compare a.sig (b.sig * 10 ^ (dsa - dsb)) + else compare (a.sig * 10 ^ (dsb - dsa)) b.sig + +/-- Compare two number values exactly. -/ +def NumVal.cmp (a b : NumVal) : Ordering := + let sa : Int := if a.sig = 0 then 0 else if a.neg then -1 else 1 + let sb : Int := if b.sig = 0 then 0 else if b.neg then -1 else 1 + if sa < sb then .lt + else if sa > sb then .gt + else if sa = 0 then .eq + else if sa = 1 then magCmp a b + else magCmp b a + +/-- Look a name up in the members of a JSON object. When a name + appears more than once, the first copy is the one found, as + in the JSON code. Anything that is not an object has no + members. -/ +def lookup (name : List Char) (j : Json) : Option Json := + match j with + | .obj kvs => kvs.lookup name + | _ => none + +/-- The state of one date claim (exp, nbf, iat) in a claims set: + absent, present but not a whole number of seconds (a + non-number, or a number with a fraction), or present with a + whole number value. -/ +inductive ClaimDate where + | absent + | bad + | ok (v : NumVal) + +def dateClaim (name : List Char) (claims : Json) : ClaimDate := + match lookup name claims with + | none => .absent + | some (.num cs) => + match numVal cs with + | some v => if v.isInt then .ok v else .bad + | none => .bad + | some _ => .bad + +/-- The check context a caller supplies: the algorithms allowed, + the current time in seconds (none means the time comparisons + are skipped; the type rules still hold), the claims that must + be present, the expected issuer if any, the expected audience + if any. -/ +structure Context where + allow : List (List Char) + now : Option Int + required : List (List Char) + issuer : Option (List Char) + audience : Option (List Char) + +/-- When iss is present it must be a string. -/ +def issShapeOk (claims : Json) : Bool := + match lookup "iss".toList claims with + | none => true + | some (.str _) => true + | some _ => false + +/-- When aud is present it must be a string or an array of + strings. -/ +def audShapeOk (claims : Json) : Bool := + match lookup "aud".toList claims with + | none => true + | some (.str _) => true + | some (.arr vs) => vs.all fun v => match v with + | .str _ => true + | _ => false + | some _ => false + +/-- The audience check itself: aud is the expected string, or an + array holding it. A missing aud fails. -/ +def audOk (claims : Json) (a : List Char) : Bool := + match lookup "aud".toList claims with + | some (.str s) => s == a + | some (.arr vs) => vs.any fun v => match v with + | .str s => s == a + | _ => false + | _ => false + +/-- Run the checks. Every rule must hold; SPEC.md states them one + by one. This checks the token's shape and claims only. It + does not verify the signature, and a true answer here is not + a verified token. -/ +def accept (t : Token) (ctx : Context) : Bool := + if ctx.allow.contains "none".toList then false + else + match t.header, t.claims with + | .obj _, .obj _ => + match lookup "alg".toList t.header with + | some (.str alg) => + alg != "none".toList && + ctx.allow.contains alg && + ctx.required.all (fun n => (lookup n t.claims).isSome) && + (match dateClaim "exp".toList t.claims with + | .bad => false + | .absent => true + | .ok v => match ctx.now with + | none => true + | some n => NumVal.cmp (NumVal.ofInt n) v == .lt) && + (match dateClaim "nbf".toList t.claims with + | .bad => false + | .absent => true + | .ok v => match ctx.now with + | none => true + | some n => NumVal.cmp (NumVal.ofInt n) v != .lt) && + (match dateClaim "iat".toList t.claims with + | .bad => false + | _ => true) && + issShapeOk t.claims && + audShapeOk t.claims && + (match ctx.issuer with + | none => true + | some i => match lookup "iss".toList t.claims with + | some (.str s) => s == i + | _ => false) && + (match ctx.audience with + | none => true + | some a => audOk t.claims a) + | _ => false + | _, _ => false + +end Jws diff --git a/Jws/Utf8.lean b/Jws/Utf8.lean new file mode 100644 index 0000000..06223a4 --- /dev/null +++ b/Jws/Utf8.lean @@ -0,0 +1,79 @@ +/- + UTF-8, by hand: the byte form of JSON text is UTF-8 (RFC 7515 + says BASE64URL(UTF8(text))), and the JSON values this project + carries hold their strings as lists of characters, so the token + code needs both directions between characters and bytes. + + The encoder writes the standard one to four bytes per character. + The decoder is strict. It turns away stray continuation bytes, + overlong forms (a character written with more bytes than it + needs, including any lead byte below C2), lead bytes above F4, + encodings of the surrogate values D800 to DFFF, values above + 10FFFF, and truncated sequences. +-/ + +namespace Jws.Utf8 + +/-- The bytes of one character. -/ +def encodeChar (c : Char) : List UInt8 := + let n := c.toNat + if n < 0x80 then [n.toUInt8] + else if n < 0x800 then + [(0xC0 + n / 64).toUInt8, (0x80 + n % 64).toUInt8] + else if n < 0x10000 then + [(0xE0 + n / 4096).toUInt8, (0x80 + (n / 64) % 64).toUInt8, (0x80 + n % 64).toUInt8] + else + [(0xF0 + n / 262144).toUInt8, (0x80 + (n / 4096) % 64).toUInt8, + (0x80 + (n / 64) % 64).toUInt8, (0x80 + n % 64).toUInt8] + +/-- The bytes of a text. -/ +def encode (cs : List Char) : List UInt8 := + cs.flatMap encodeChar + +/-- The payload bits of a continuation byte (80 to BF). -/ +def contVal (b : UInt8) : Option Nat := + if 0x80 ≤ b.toNat ∧ b.toNat < 0xC0 then some (b.toNat - 0x80) else none + +/-- Strict decoding. The side conditions on the three and four + byte forms are the overlong, surrogate, and range rules: after + E0 the first continuation value is at least 32, after ED it is + below 32, after F0 it is at least 16, after F4 it is below 16. -/ +def decode : List UInt8 → Option (List Char) + | [] => some [] + | b :: rest => + if b.toNat < 0x80 then + (decode rest).map fun cs => Char.ofNat b.toNat :: cs + else if b.toNat < 0xC2 then none + else if b.toNat < 0xE0 then + match rest with + | b1 :: rest1 => + match contVal b1 with + | some v1 => (decode rest1).map fun cs => + Char.ofNat ((b.toNat - 0xC0) * 64 + v1) :: cs + | none => none + | [] => none + else if b.toNat < 0xF0 then + match rest with + | b1 :: b2 :: rest1 => + match contVal b1, contVal b2 with + | some v1, some v2 => + if (b.toNat = 0xE0 → 0x20 ≤ v1) ∧ (b.toNat = 0xED → v1 < 0x20) then + (decode rest1).map fun cs => + Char.ofNat ((b.toNat - 0xE0) * 4096 + v1 * 64 + v2) :: cs + else none + | _, _ => none + | _ => none + else if b.toNat < 0xF5 then + match rest with + | b1 :: b2 :: b3 :: rest1 => + match contVal b1, contVal b2, contVal b3 with + | some v1, some v2, some v3 => + if (b.toNat = 0xF0 → 0x10 ≤ v1) ∧ (b.toNat = 0xF4 → v1 < 0x10) then + (decode rest1).map fun cs => + Char.ofNat ((b.toNat - 0xF0) * 262144 + v1 * 4096 + v2 * 64 + v3) :: cs + else none + | _, _, _ => none + | _ => none + else none + +end Jws.Utf8 diff --git a/Main.lean b/Main.lean new file mode 100644 index 0000000..09da9f2 --- /dev/null +++ b/Main.lean @@ -0,0 +1,127 @@ +import Jws + +/- + Command line front end. The shape follows the other slices in + this org: one request per line on stdin, one answer per line + on stdout. A request is a word, a space, and a payload. The + payload is decimal character codes separated by commas. + + One operation: + + check the payload carries six fields separated by + semicolons (code 59). Each field is text written as + character codes, as above. Field 1: the token text. + Field 2: the current time as decimal digits, or empty + for no time comparisons. Field 3: the allowed + algorithms, names separated by commas inside the + field text. Field 4: the required claims, same style. + Field 5: the expected issuer, or empty. Field 6: the + expected audience, or empty. + + The answer is `ok:` and the codes of the canonical reprinted + token when the token is accepted, `none` when it is not, and + `error` when the request itself is malformed. + + Full statement of the format and the rules: SPEC.md. +-/ + +/-- One decimal number, at most seven digits, the way the house + writes a character code. -/ +def number (s : String) : Option Nat := + if s.isEmpty ∨ ¬ s.all (·.isDigit) then none + else + let n := s.foldl (fun a c => a * 10 + (c.toNat - 48)) 0 + if n < 10000000 then some n else none + +/-- The comma separated numbers of a payload or a field. -/ +def numbers (s : String) : Option (List Nat) := + if s.isEmpty then some [] + else (s.splitOn ",").mapM number + +/-- Numbers into characters, asking each number to be a scalar + value: below the surrogate gap or above it, within range. -/ +def toChars (ns : List Nat) : Option (List Char) := + ns.mapM fun n => + if n < 0xD800 ∨ (0xE000 ≤ n ∧ n ≤ 0x10FFFF) then some (Char.ofNat n) + else none + +/-- Characters back into the comma separated answer codes. -/ +def showCodes (cs : List Char) : String := + ",".intercalate (cs.map fun c => toString c.toNat) + +/-- Remove one trailing newline and carriage return. -/ +def chomp (line : String) : String := + let cs := line.toList + if cs.getLast? = some '\n' then + let cs := cs.dropLast + String.ofList (if cs.getLast? = some '\r' then cs.dropLast else cs) + else line + +/-- Split a field text into names at its commas. An empty field + names nothing. -/ +def names (cs : List Char) : List (List Char) := + if cs.isEmpty then [] else Jws.splitSep ',' cs + +/-- Read the current time field: empty for no time comparisons, + else an optional minus sign and at most 19 digits. -/ +def readNow (cs : List Char) : Option (Option Int) := + match cs with + | [] => some none + | _ => + let (neg, ds) := match cs with + | '-' :: r => (true, r) + | _ => (false, cs) + if ds.isEmpty ∨ ds.length > 19 ∨ ¬ ds.all (·.isDigit) then none + else + let n : Nat := ds.foldl (fun a c => a * 10 + (c.toNat - 48)) 0 + some (some (if neg then -(n : Int) else (n : Int))) + +/-- Answer one `check` request: the codes of the six fields. -/ +def answerCheck (fields : List (List Nat)) : Option String := + match fields with + | [tf, nf, af, rf, isf, auf] => + match toChars tf, toChars nf, toChars af, toChars rf, toChars isf, toChars auf with + | some t, some nowCs, some allowCs, some reqCs, some issCs, some audCs => + match readNow nowCs with + | none => none + | some now => + let ctx : Jws.Context := + { allow := names allowCs + now := now + required := names reqCs + issuer := if issCs.isEmpty then none else some issCs + audience := if audCs.isEmpty then none else some audCs } + match Jws.parseToken t with + | none => some "none" + | some tok => + some (if Jws.accept tok ctx then "ok:" ++ showCodes (Jws.printToken tok) + else "none") + | _, _, _, _, _, _ => none + | _ => none + +/-- Answer one request line. -/ +def answer (line : String) : String := + let line := chomp line + if line.isEmpty then "error" + else + match line.splitOn " " with + | [op, arg] => + if op = "check" then + match (arg.splitOn ";").mapM numbers with + | some fields => + match answerCheck fields with + | some out => out + | none => "error" + | none => "error" + else "error" + | _ => "error" + +partial def loop : IO Unit := do + let stdin ← IO.getStdin + match ← stdin.getLine with + | "" => return + | line => + IO.println (answer line) + loop + +def main : IO Unit := loop diff --git a/Proofs.lean b/Proofs.lean new file mode 100644 index 0000000..8a82bf6 --- /dev/null +++ b/Proofs.lean @@ -0,0 +1,4 @@ +import Proofs.RoundTrip +import Proofs.B64 +import Proofs.Utf8 +import Proofs.Token diff --git a/Proofs/B64.lean b/Proofs/B64.lean new file mode 100644 index 0000000..3aeaecb --- /dev/null +++ b/Proofs/B64.lean @@ -0,0 +1,211 @@ +import Jws.Base64url + +open Jws.Base64url + +namespace Jws.Base64url.Proofs + +theorem charLe (a c : Char) : a ≤ c ↔ a.toNat ≤ c.toNat := by + simp [Char.le_def, UInt32.le_iff_toNat_le] + +set_option maxRecDepth 10000 in +/-- The finite table check, delegated to the kernel: every value + below 64 reads back through the alphabet. -/ +theorem value_tbl : ∀ n : Fin 64, value (char n.val) = some n.val := by + decide +kernel + +theorem value_char {n : Nat} (h : n < 64) : value (char n) = some n := + value_tbl ⟨n, h⟩ + +/-- The dot that joins the parts is never a character of the + encoding. The token round trip proof needs this. -/ +theorem char_ne_dot {n : Nat} (h : n < 64) : char n ≠ '.' := by + intro e + have hv : value '.' = none := by decide + have hvc := value_char h + rw [e, hv] at hvc + cases hvc + +set_option maxRecDepth 10000 in +theorem upper_tbl : ∀ k : Fin 26, (char k.val).toNat = k.val + 65 := by decide +kernel +set_option maxRecDepth 10000 in +theorem lower_tbl : ∀ k : Fin 26, (char (k.val + 26)).toNat = k.val + 97 := by decide +kernel +set_option maxRecDepth 10000 in +theorem digit_tbl : ∀ k : Fin 10, (char (k.val + 52)).toNat = k.val + 48 := by decide +kernel +theorem char_62 : char 62 = '-' := by decide +kernel +theorem char_63 : char 63 = '_' := by decide +kernel + +/-- Anything the decoder accepts is a character the encoder + writes, with the same value. -/ +theorem value_some {c : Char} {v : Nat} (h : value c = some v) : + v < 64 ∧ char v = c := by + unfold value at h + simp only [charLe] at h + have hA : 'A'.toNat = 65 := rfl + have hZ : 'Z'.toNat = 90 := rfl + have ha : 'a'.toNat = 97 := rfl + have hz : 'z'.toNat = 122 := rfl + have h0 : '0'.toNat = 48 := rfl + have h9 : '9'.toNat = 57 := rfl + rw [hA, hZ, ha, hz, h0, h9] at h + split at h + · rename_i hc + cases h + refine ⟨by omega, ?_⟩ + apply Char.toNat_inj.mp + have htb := upper_tbl ⟨c.toNat - 65, by omega⟩ + simp only at htb + rw [htb]; omega + split at h + · rename_i _ hc + cases h + refine ⟨by omega, ?_⟩ + apply Char.toNat_inj.mp + have htb := lower_tbl ⟨c.toNat - 97, by omega⟩ + simp only at htb + have e : c.toNat - 97 + 26 = c.toNat - 71 := by omega + rw [e] at htb + rw [htb]; omega + split at h + · rename_i _ _ hc + cases h + refine ⟨by omega, ?_⟩ + apply Char.toNat_inj.mp + have htb := digit_tbl ⟨c.toNat - 48, by omega⟩ + simp only at htb + have e : c.toNat - 48 + 52 = c.toNat + 4 := by omega + rw [e] at htb + rw [htb]; omega + split at h + · rename_i hc + cases h; subst hc; exact ⟨by omega, char_62⟩ + split at h + · rename_i hc + cases h; subst hc; exact ⟨by omega, char_63⟩ + · cases h + +theorem toNat_toUInt8 {k : Nat} (h : k < 256) : k.toUInt8.toNat = k := by + simp; omega + +/-- Decoding the encoding of any bytes gives the bytes back. -/ +theorem decode_encode : ∀ bs : List UInt8, decode (encode bs) = some bs := by + intro bs + induction bs using encode.induct with + | case1 a b c rest ih => + have ha := UInt8.toNat_lt a + have hb := UInt8.toNat_lt b + have hc := UInt8.toNat_lt c + rw [encode.eq_1, decode.eq_4, value_char (by omega), value_char (by omega), + value_char (by omega), value_char (by omega), ih] + simp only [Option.some.injEq, List.cons.injEq, and_true] + refine ⟨?_, ?_, ?_⟩ <;> apply UInt8.toNat_inj.mp <;> + rw [toNat_toUInt8 (by omega)] <;> omega + | case2 a b => + have ha := UInt8.toNat_lt a + have hb := UInt8.toNat_lt b + rw [encode.eq_2, decode.eq_3, value_char (by omega), value_char (by omega), + value_char (by omega)] + simp only + rw [ite_eq_left (by omega)] + simp only [Option.some.injEq, List.cons.injEq, and_true] + refine ⟨?_, ?_⟩ <;> apply UInt8.toNat_inj.mp <;> + rw [toNat_toUInt8 (by omega)] <;> omega + | case3 a => + have ha := UInt8.toNat_lt a + rw [encode.eq_3, decode.eq_2, value_char (by omega), value_char (by omega)] + simp only + rw [ite_eq_left (by omega)] + simp only [Option.some.injEq, List.cons.injEq, and_true] + apply UInt8.toNat_inj.mp; rw [toNat_toUInt8 (by omega)]; omega + | case4 => rfl + +/-- Encoding the decoding of a text the decoder accepted gives + the text back. -/ +theorem encode_decode : ∀ (cs : List Char) (bs : List UInt8), + decode cs = some bs → encode bs = cs := by + intro cs bs + induction cs using decode.induct generalizing bs with + | case1 => + intro h + rw [decode.eq_1, Option.some.injEq] at h + subst h; rfl + | case2 c0 c1 v0 v1 h1 h0 hm => + intro h + rw [decode.eq_2, h0, h1] at h + simp only [ite_eq_left hm, Option.some.injEq] at h + subst h + obtain ⟨lt0, ch0⟩ := value_some h0 + obtain ⟨lt1, ch1⟩ := value_some h1 + rw [encode.eq_3, toNat_toUInt8 (by omega)] + have e1 : (v0 * 4 + v1 / 16) * 65536 / 262144 = v0 := by omega + have e2 : (v0 * 4 + v1 / 16) * 65536 / 4096 % 64 = v1 := by omega + rw [e1, e2, ch0, ch1] + | case3 c0 c1 v0 v1 h1 h0 hm => + intro h + rw [decode.eq_2, h0, h1] at h + simp only [ite_eq_right hm] at h + cases h + | case4 c0 c1 hn => + intro h + rw [decode.eq_2] at h + split at h + · exact (hn _ _ ‹_› ‹_›).elim + · cases h + | case5 c0 c1 c2 v0 v1 v2 h2 h1 h0 hm => + intro h + rw [decode.eq_3, h0, h1, h2] at h + simp only [ite_eq_left hm, Option.some.injEq] at h + subst h + obtain ⟨lt0, ch0⟩ := value_some h0 + obtain ⟨lt1, ch1⟩ := value_some h1 + obtain ⟨lt2, ch2⟩ := value_some h2 + rw [encode.eq_2, toNat_toUInt8 (by omega), toNat_toUInt8 (by omega)] + have e1 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256) / 262144 = + v0 := by omega + have e2 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256) / 4096 % 64 = + v1 := by omega + have e3 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256) / 64 % 64 = + v2 := by omega + rw [e1, e2, e3, ch0, ch1, ch2] + | case6 c0 c1 c2 v0 v1 v2 h2 h1 h0 hm => + intro h + rw [decode.eq_3, h0, h1, h2] at h + simp only [ite_eq_right hm] at h + cases h + | case7 c0 c1 c2 hn => + intro h + rw [decode.eq_3] at h + split at h + · exact (hn _ _ _ ‹_› ‹_› ‹_›).elim + · cases h + | case8 c0 c1 c2 c3 rest v0 v1 v2 v3 tail hrec h3 h2 h1 h0 ih => + intro h + rw [decode.eq_4, h0, h1, h2, h3, hrec] at h + simp only [Option.some.injEq] at h + subst h + obtain ⟨lt0, ch0⟩ := value_some h0 + obtain ⟨lt1, ch1⟩ := value_some h1 + obtain ⟨lt2, ch2⟩ := value_some h2 + obtain ⟨lt3, ch3⟩ := value_some h3 + rw [encode.eq_1, toNat_toUInt8 (by omega), toNat_toUInt8 (by omega), + toNat_toUInt8 (by omega), ih tail hrec] + have e1 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256 + + (v2 % 4 * 64 + v3)) / 262144 = v0 := by omega + have e2 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256 + + (v2 % 4 * 64 + v3)) / 4096 % 64 = v1 := by omega + have e3 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256 + + (v2 % 4 * 64 + v3)) / 64 % 64 = v2 := by omega + have e4 : ((v0 * 4 + v1 / 16) * 65536 + (v1 % 16 * 16 + v2 / 4) * 256 + + (v2 % 4 * 64 + v3)) % 64 = v3 := by omega + rw [e1, e2, e3, e4, ch0, ch1, ch2, ch3] + | case9 c0 c1 c2 c3 rest hn ih => + intro h + rw [decode.eq_4] at h + split at h + · exact (hn _ _ _ _ _ ‹_› ‹_› ‹_› ‹_› ‹_›).elim + · cases h + | case10 t h0 h1 h2 h3 => + intro h + rw [decode.eq_5 _ h0 h1 h2 h3] at h + cases h + +end Jws.Base64url.Proofs diff --git a/Proofs/RoundTrip.lean b/Proofs/RoundTrip.lean new file mode 100644 index 0000000..a7682d8 --- /dev/null +++ b/Proofs/RoundTrip.lean @@ -0,0 +1,1104 @@ +/- + The round trip proof: for every well formed value, parsing its + printed form gives the value back. + + The statement proved here is `parse_print`. A matching statement + in a spec file still has to be added by a reviewer, so this file + builds on its own, in the same shape as the codec proofs. + + Helper lemmas live in `Json.Proofs`. The main step is `key`, a + strong induction on the length of the printed form that proves + three statements together: one for a value, one for the inside of + an array, and one for the inside of an object. The array and + object statements spend the value statement at the same bound, + and the value statement spends them at a smaller bound, because + the brackets around a body add two characters to its length. +-/ + +import Json + +namespace Json.Proofs + +open Json + +set_option maxRecDepth 10000 + +/-- What may follow a printed number inside a printed array or + object, or at the end of a text: the next character, if there + is one, is not a digit, a point, or an exponent letter, so the + number parser stops at the end of the number. -/ +def TailOk (rest : List Char) : Prop := + ∀ c, rest.head? = some c → + isDigit c = false ∧ c ≠ '.' ∧ c ≠ 'e' ∧ c ≠ 'E' + +theorem tailOk_nil : TailOk [] := by + intro c h + simp at h + +theorem tailOk_comma (rest : List Char) : TailOk (',' :: rest) := by + intro c h + simp at h + subst h + exact ⟨by decide, by decide, by decide, by decide⟩ + +theorem tailOk_rbracket (rest : List Char) : TailOk (']' :: rest) := by + intro c h + simp at h + subst h + exact ⟨by decide, by decide, by decide, by decide⟩ + +theorem tailOk_rbrace (rest : List Char) : TailOk ('}' :: rest) := by + intro c h + simp at h + subst h + exact ⟨by decide, by decide, by decide, by decide⟩ + +theorem char_le_toNat {a b : Char} (h : a ≤ b) : a.toNat ≤ b.toNat := by + rw [Char.le_def, UInt32.le_iff_toNat_le] at h + exact h + +theorem char_le_of_toNat {a b : Char} (h : a.toNat ≤ b.toNat) : a ≤ b := by + rw [Char.le_def, UInt32.le_iff_toNat_le] + exact h + +/-- The digit range of a character, in code points. -/ +theorem isDigit_range {c : Char} (h : IsDigit c) : 48 ≤ c.toNat ∧ c.toNat ≤ 57 := by + have h1 : ('0' : Char).toNat ≤ c.toNat := by + have := h.1 + rwa [Char.le_def] at this + have h2 : c.toNat ≤ ('9' : Char).toNat := by + have := h.2 + rwa [Char.le_def] at this + exact ⟨by simpa using h1, by simpa using h2⟩ + +/-- A character the grammar treats as a digit passes the digit + test. -/ +theorem isDigit_of_isDigit {c : Char} (h : IsDigit c) : isDigit c = true := by + show decide ('0' ≤ c ∧ c ≤ '9') = true + rw [decide_eq_true_eq] + exact h + +/-- A character that passes the digit test is a digit. -/ +theorem isDigit_of_test {c : Char} (h : isDigit c = true) : IsDigit c := by + have h2 : decide ('0' ≤ c ∧ c ≤ '9') = true := h + rwa [decide_eq_true_eq] at h2 + +/-- A digit differs from each character with a code point outside + the digit range. -/ +theorem IsDigit.ne_of_toNat {c d : Char} (h : IsDigit c) + (hd : d.toNat < 48 ∨ 57 < d.toNat) : c ≠ d := by + intro e + have h1 := isDigit_range h + have h2 : c.toNat = d.toNat := by rw [e] + omega + +/-- Whitespace fails on a character that is none of the four + whitespace characters. -/ +theorem isWs_of_ne {c : Char} (h1 : c ≠ ' ') (h2 : c ≠ '\t') + (h3 : c ≠ '\n') (h4 : c ≠ '\r') : isWs c = false := by + simp [isWs, h1, h2, h3, h4] + +/-- A digit is not whitespace, and is not one of the marks that can + follow or extend a number. -/ +theorem IsDigit.isWs_false {c : Char} (h : IsDigit c) : isWs c = false := + isWs_of_ne + (IsDigit.ne_of_toNat h (by decide)) + (IsDigit.ne_of_toNat h (by decide)) + (IsDigit.ne_of_toNat h (by decide)) + (IsDigit.ne_of_toNat h (by decide)) + +theorem IsDigit.ne_rbracket {c : Char} (h : IsDigit c) : c ≠ ']' := + IsDigit.ne_of_toNat h (by decide) + +theorem IsDigit.ne_rbrace {c : Char} (h : IsDigit c) : c ≠ '}' := + IsDigit.ne_of_toNat h (by decide) + +theorem IsDigit.ne_comma {c : Char} (h : IsDigit c) : c ≠ ',' := + IsDigit.ne_of_toNat h (by decide) + +/-- The hex digit for a value below 16 reads back as that value. -/ +theorem hexVal_hexDigit : ∀ n : Fin 16, hexVal (hexDigit n.val) = some n.val := by + decide + +theorem hexVal_of_hexDigit {n : Nat} (h : n < 16) : hexVal (hexDigit n) = some n := + hexVal_hexDigit ⟨n, h⟩ + +/-- Skipping whitespace over a list whose head is not whitespace + changes nothing. -/ +theorem skipWs_cons {c : Char} {cs : List Char} (h : isWs c = false) : + skipWs (c :: cs) = c :: cs := by + simp [skipWs, h] + +/-- The same, for a list followed by more text. -/ +theorem skipWs_append_of_head {l rest : List Char} {c : Char} + (h : l.head? = some c) (hw : isWs c = false) : + skipWs (l ++ rest) = l ++ rest := by + cases l with + | nil => simp at h + | cons d l => + simp at h + subst h + simp [List.cons_append, skipWs_cons hw] + +/-- A run of digits at the head of a text is read whole: when the + text after the run does not start with a digit, the digit run + reader stops at the end of the run. -/ +theorem takeDigits_append {ds rest : List Char} + (hd : ∀ d ∈ ds, isDigit d = true) + (hr : ∀ c, rest.head? = some c → isDigit c = false) : + takeDigits (ds ++ rest) = (ds, rest) := by + induction ds generalizing rest with + | nil => + cases rest with + | nil => rfl + | cons c r => + have hc : isDigit c = false := hr c rfl + simp [takeDigits, hc] + | cons d ds ih => + have hdd : isDigit d = true := hd d (by simp) + have ih' := ih (fun x hx => hd x (List.mem_cons_of_mem d hx)) hr + simp [takeDigits, hdd, ih'] + +/-- The head of a text built from a fraction part, an exponent + part, and a tail is never a digit. -/ +theorem head_not_digit_of_parts {frac exp rest : List Char} + (hf : FracOk frac) (he : ExpOk exp) (ht : TailOk rest) : + ∀ c, (frac ++ exp ++ rest).head? = some c → isDigit c = false := by + obtain rfl | ⟨d, ds, hfr, hd, -⟩ := hf + · obtain rfl | ⟨ec, sgn, d, ds, hex, hec, -, -, -⟩ := he + · intro c hc + exact (ht c hc).1 + · intro c hc + have hthis : (exp ++ rest).head? = some ec := by + rw [hex] + cases sgn <;> simp + have h1 : (exp ++ rest).head? = some c := by simpa using hc + rw [hthis] at h1 + have hcc : ec = c := Option.some.inj h1 + subst hcc + cases hec with + | inl h => rw [h]; decide + | inr h => rw [h]; decide + · intro c hc + have h1 : (frac ++ exp ++ rest).head? = some '.' := by + rw [hfr]; simp + have hcc : c = '.' := by + rw [h1] at hc + exact (Option.some.inj hc).symm + subst hcc + decide + +/-- The head of a text built from an exponent part and a tail is + never a digit and never a point. -/ +theorem head_not_digit_or_dot_of_exp {exp rest : List Char} + (he : ExpOk exp) (ht : TailOk rest) : + ∀ c, (exp ++ rest).head? = some c → isDigit c = false ∧ c ≠ '.' := by + obtain rfl | ⟨ec, sgn, d, ds, hex, hec, -, -, -⟩ := he + · intro c hc + exact ⟨(ht c hc).1, (ht c hc).2.1⟩ + · intro c hc + have h1 : (exp ++ rest).head? = some ec := by + rw [hex] + cases sgn <;> simp + have h2 : (exp ++ rest).head? = some c := by simpa using hc + rw [h1] at h2 + have hcc : ec = c := Option.some.inj h2 + subst hcc + cases hec with + | inl h => rw [h]; exact ⟨by decide, by decide⟩ + | inr h => rw [h]; exact ⟨by decide, by decide⟩ + +/-- The integer part of a well formed number text is read whole. -/ +theorem parseIntPart_append {hd : Char} {intDs tail : List Char} + (h0 : (hd = '0' ∧ intDs = []) ∨ ('1' ≤ hd ∧ hd ≤ '9')) + (hds : ∀ d ∈ intDs, IsDigit d) + (ht : ∀ c, tail.head? = some c → isDigit c = false) : + parseIntPart ((hd :: intDs) ++ tail) = some (hd :: intDs, tail) := by + have hdig : ∀ d ∈ intDs, isDigit d = true := + fun d hd' => isDigit_of_isDigit (hds d hd') + cases h0 with + | inl h => + obtain ⟨rfl, rfl⟩ := h + simp [parseIntPart] + | inr h => + have hne : hd ≠ '0' := by + intro e + have h1 : ('1' : Char).val ≤ hd.val := h.1 + rw [e] at h1 + exact (by decide : ¬ ((49 : UInt32) ≤ (48 : UInt32))) h1 + have htk : takeDigits (intDs ++ tail) = (intDs, tail) := + takeDigits_append hdig ht + simp [parseIntPart, hne, h, htk] + +/-- The fraction part of a well formed number text is read whole. -/ +theorem parseFracOpt_append {frac tail : List Char} (hf : FracOk frac) + (ht : ∀ c, tail.head? = some c → isDigit c = false ∧ c ≠ '.') : + parseFracOpt (frac ++ tail) = some (frac, tail) := by + cases hf with + | inl h => + rw [h] + simp only [List.nil_append] + cases tail with + | nil => simp [parseFracOpt] + | cons c r => + have hc : c ≠ '.' := (ht c rfl).2 + simp [parseFracOpt, hc] + | inr h => + obtain ⟨d, ds, rfl, hd, hds⟩ := h + have hdig : ∀ x ∈ d :: ds, isDigit x = true := + fun x hx => isDigit_of_isDigit (by + cases hx with + | head => exact hd + | tail _ hx => exact hds x hx) + have ht' : ∀ c, tail.head? = some c → isDigit c = false := + fun c hc => (ht c hc).1 + have htk : takeDigits ((d :: ds) ++ tail) = (d :: ds, tail) := + takeDigits_append hdig ht' + simp only [List.cons_append] at htk + simp [parseFracOpt, htk] + +/-- The exponent part of a well formed number text is read + whole. -/ +theorem parseExpOpt_append {exp rest : List Char} (he : ExpOk exp) + (ht : TailOk rest) : + parseExpOpt (exp ++ rest) = some (exp, rest) := by + cases he with + | inl h => + rw [h] + simp only [List.nil_append] + cases rest with + | nil => rfl + | cons c r => + have hc := ht c rfl + have hne : ¬ (c = 'e' ∨ c = 'E') := by + intro hp + cases hp with + | inl h1 => exact hc.2.2.1 h1 + | inr h2 => exact hc.2.2.2 h2 + simp [parseExpOpt, hne] + | inr h => + obtain ⟨ec, sgn, d, ds, rfl, hec, hsgn, hd, hds⟩ := h + have hdig : ∀ x ∈ d :: ds, isDigit x = true := + fun x hx => isDigit_of_isDigit (by + cases hx with + | head => exact hd + | tail _ hx => exact hds x hx) + have ht' : ∀ c, rest.head? = some c → isDigit c = false := + fun c hc => (ht c hc).1 + have htk : takeDigits ((d :: ds) ++ rest) = (d :: ds, rest) := + takeDigits_append hdig ht' + simp only [List.cons_append] at htk + have hdp : d ≠ '+' := IsDigit.ne_of_toNat hd (by decide) + have hdm : d ≠ '-' := IsDigit.ne_of_toNat hd (by decide) + cases hsgn with + | inl h => + rw [h] + simp [parseExpOpt, hec, htk, hdp, hdm] + | inr h => + cases h with + | inl h => + rw [h] + simp [parseExpOpt, hec, htk, hdp, hdm] + | inr h => + rw [h] + simp [parseExpOpt, hec, htk, hdp, hdm] + +/-- A well formed number text at the head of a text is read whole, + when what follows cannot extend the number. -/ +theorem parseNumber_append {cs rest : List Char} (h : WFNum cs) + (ht : TailOk rest) : + parseNumber (cs ++ rest) = some (cs, rest) := by + obtain ⟨neg, hd, intDs, frac, exp, hcs, hhd, hds, hfrac, hexp⟩ := h + have ht1 : ∀ c, (frac ++ exp ++ rest).head? = some c → isDigit c = false := + head_not_digit_of_parts hfrac hexp ht + have ht2 : ∀ c, (exp ++ rest).head? = some c → isDigit c = false ∧ c ≠ '.' := + head_not_digit_or_dot_of_exp hexp ht + have hint : parseIntPart ((hd :: intDs) ++ (frac ++ exp ++ rest)) = + some (hd :: intDs, frac ++ exp ++ rest) := + parseIntPart_append hhd hds ht1 + have hfrac' : parseFracOpt (frac ++ (exp ++ rest)) = some (frac, exp ++ rest) := + parseFracOpt_append hfrac ht2 + have hexp' : parseExpOpt (exp ++ rest) = some (exp, rest) := + parseExpOpt_append hexp ht + have hshape : cs ++ rest = + (if neg then ['-'] else []) ++ ((hd :: intDs) ++ (frac ++ (exp ++ rest))) := by + rw [hcs] + simp [List.append_assoc] + rw [hshape] + have hint2 : parseIntPart (hd :: (intDs ++ (frac ++ (exp ++ rest)))) = + some (hd :: intDs, frac ++ exp ++ rest) := by + simpa only [List.cons_append, List.append_assoc] using hint + cases neg with + | true => + simp [parseNumber, hint2, hfrac', hexp', List.append_assoc, List.cons_append, hcs] + | false => + have hne : hd ≠ '-' := by + cases hhd with + | inl h => rw [h.1]; decide + | inr h => + have h01 : ('0' : Char).toNat ≤ ('1' : Char).toNat := by decide + have hdig : IsDigit hd := + ⟨char_le_of_toNat (Nat.le_trans h01 (char_le_toNat h.1)), h.2⟩ + exact IsDigit.ne_of_toNat hdig (by decide) + simp [parseNumber, hne, hint2, hfrac', hexp', List.append_assoc, List.cons_append, hcs] + +/-- The hex value of the character `0`. -/ +theorem hexVal_zero : hexVal '0' = some 0 := by decide + +/-- A list of length under four is not a list of four or more cells. + Used for the side condition of the parser's short-tail equation. -/ +theorem not_four_cons (xs : List Char) (n : Nat) (h : xs.length = n) (hn : n < 4) : + ∀ (a b cc d : Char) (r : List Char), xs ≠ a :: b :: cc :: d :: r := by + intro a b cc d r he + have hl := congrArg List.length he + simp only [List.length_cons] at hl + omega + +/-- One step of the string body reader at a closing quote. -/ +theorem parseStrBody_quote (T : List Char) : + parseStrBody ('"' :: T) = some ([], T) := by + cases T with + | nil => + rw [Json.parseStrBody.eq_2] + simp + | cons d T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [] 0 rfl (by decide))] + simp + | cons e T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e] 1 rfl (by decide))] + simp + | cons f T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e, f] 2 rfl (by decide))] + simp + | cons g T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e, f, g] 3 rfl (by decide))] + simp + | cons h T => + rw [Json.parseStrBody.eq_3] + simp + +/-- One step of the string body reader at a raw character. -/ +theorem parseStrBody_raw {c : Char} (h1 : c ≠ '"') (h2 : c ≠ '\\') + (h3 : ¬ c.toNat < 32) (T : List Char) : + parseStrBody (c :: T) = (parseStrBody T).map (fun (cs, r) => (c :: cs, r)) := by + cases T with + | nil => + rw [Json.parseStrBody.eq_2] + simp [h1, h2, h3, Json.parseStrBody.eq_1] + | cons d T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [] 0 rfl (by decide))] + simp [h1, h2, h3] + | cons e T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e] 1 rfl (by decide))] + simp [h1, h2, h3] + | cons f T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e, f] 2 rfl (by decide))] + simp [h1, h2, h3] + | cons g T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [e, f, g] 3 rfl (by decide))] + simp [h1, h2, h3] + | cons h T => + rw [Json.parseStrBody.eq_3] + simp [h1, h2, h3] + +/-- One step of the string body reader at a one-letter escape. -/ +theorem parseStrBody_escape (e ch : Char) (hne : e ≠ 'u') (hse : simpleEscape e = some ch) + (T : List Char) : + parseStrBody ('\\' :: e :: T) = + (parseStrBody T).map (fun (cs, r) => (ch :: cs, r)) := by + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [] 0 rfl (by decide))] + simp [hne, hse] + | cons a T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [a] 1 rfl (by decide))] + simp [hne, hse] + | cons b T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [a, b] 2 rfl (by decide))] + simp [hne, hse] + | cons cc T => + cases T with + | nil => + rw [Json.parseStrBody.eq_4 _ _ _ (not_four_cons [a, b, cc] 3 rfl (by decide))] + simp [hne, hse] + | cons d T => + rw [Json.parseStrBody.eq_3] + simp [hne, hse] + +/-- One step of the string body reader at a `\uXXXX` escape whose + value is below the surrogate range. -/ +theorem parseStrBody_unicode {a b cc d : Char} {w x y z : Nat} + (ha : hexVal a = some w) (hb : hexVal b = some x) + (hc : hexVal cc = some y) (hd : hexVal d = some z) + (hsmall : w * 4096 + x * 256 + y * 16 + z < 55296) (T : List Char) : + parseStrBody ('\\' :: 'u' :: a :: b :: cc :: d :: T) = + (parseStrBody T).map + (fun (cs, r) => (Char.ofNat (w * 4096 + x * 256 + y * 16 + z) :: cs, r)) := by + rw [Json.parseStrBody.eq_3] + simp [ha, hb, hc, hd, hsmall] + +/-- Reading back an escaped string body gives the string back: + the escapes the printer writes are read as the characters + they stand for, and the closing quote ends the body. -/ +theorem parseStrBody_escapeAll (cs : List Char) (rest : List Char) : + parseStrBody (escapeAll cs ++ '"' :: rest) = some (cs, rest) := by + induction cs generalizing rest with + | nil => exact parseStrBody_quote rest + | cons c cs ih => + have hinput : escapeAll (c :: cs) ++ '"' :: rest = + escape c ++ (escapeAll cs ++ '"' :: rest) := by + simp [escapeAll, List.append_assoc] + rw [hinput] + by_cases h1 : c = '"' + · subst h1 + show parseStrBody ('\\' :: '"' :: (escapeAll cs ++ '"' :: rest)) = + some ('"' :: cs, rest) + rw [parseStrBody_escape '"' '"' (by decide) (by decide) _] + simp [ih] + · by_cases h2 : c = '\\' + · subst h2 + show parseStrBody ('\\' :: '\\' :: (escapeAll cs ++ '"' :: rest)) = + some ('\\' :: cs, rest) + rw [parseStrBody_escape '\\' '\\' (by decide) (by decide) _] + simp [ih] + · by_cases h3 : c.toNat = 8 + · have hch : Char.ofNat 8 = c := by + have h4 := Char.ofNat_toNat c + rw [h3] at h4 + exact h4 + simp only [escape, h1, h2, h3, List.cons_append, List.nil_append, if_true, if_false] + rw [parseStrBody_escape 'b' (Char.ofNat 8) (by decide) (by decide) _] + rw [ih, hch] + rfl + · by_cases h4 : c.toNat = 9 + · have hch : Char.ofNat 9 = c := by + have h5 := Char.ofNat_toNat c + rw [h4] at h5 + exact h5 + simp [escape, h1, h2, h3, h4] + rw [parseStrBody_escape 't' (Char.ofNat 9) (by decide) (by decide) _] + rw [ih, hch] + rfl + · by_cases h5 : c.toNat = 10 + · have hch : Char.ofNat 10 = c := by + have h6 := Char.ofNat_toNat c + rw [h5] at h6 + exact h6 + simp [escape, h1, h2, h3, h4, h5] + rw [parseStrBody_escape 'n' (Char.ofNat 10) (by decide) (by decide) _] + rw [ih, hch] + rfl + · by_cases h6 : c.toNat = 12 + · have hch : Char.ofNat 12 = c := by + have h7 := Char.ofNat_toNat c + rw [h6] at h7 + exact h7 + simp [escape, h1, h2, h3, h4, h5, h6] + rw [parseStrBody_escape 'f' (Char.ofNat 12) (by decide) (by decide) _] + rw [ih, hch] + rfl + · by_cases h7 : c.toNat = 13 + · have hch : Char.ofNat 13 = c := by + have h8 := Char.ofNat_toNat c + rw [h7] at h8 + exact h8 + simp [escape, h1, h2, h3, h4, h5, h6, h7] + rw [parseStrBody_escape 'r' (Char.ofNat 13) (by decide) (by decide) _] + rw [ih, hch] + rfl + · by_cases h8 : c.toNat < 32 + · have hA : hexVal (hexDigit (c.toNat / 16)) = some (c.toNat / 16) := + hexVal_of_hexDigit (by omega) + have hB : hexVal (hexDigit (c.toNat % 16)) = some (c.toNat % 16) := + hexVal_of_hexDigit (by omega) + have hm : 0 * 4096 + 0 * 256 + (c.toNat / 16) * 16 + + (c.toNat % 16) = c.toNat := by omega + have hch : Char.ofNat (0 * 4096 + 0 * 256 + (c.toNat / 16) * 16 + + (c.toNat % 16)) = c := by + rw [hm] + exact Char.ofNat_toNat c + have hsmall : 0 * 4096 + 0 * 256 + (c.toNat / 16) * 16 + + (c.toNat % 16) < 55296 := by omega + simp [escape, h1, h2, h3, h4, h5, h6, h7, h8] + rw [parseStrBody_unicode hexVal_zero hexVal_zero hA hB hsmall _] + rw [ih, hch] + rfl + · simp [escape, h1, h2, h3, h4, h5, h6, h7, h8] + rw [parseStrBody_raw h1 h2 h8 _] + simp [ih] + +/-! ## Printed forms: lengths and head characters -/ + +/-- A member of a joined list is no longer than the whole join. -/ +theorem mem_join_length {y : List Char} {ys : List (List Char)} (h : y ∈ ys) : + y.length ≤ (join ys).length := by + induction ys with + | nil => simp at h + | cons z zs ih => + cases zs with + | nil => + have hy : y = z := by simpa using h + simp [join, hy] + | cons w ws => + have hjoin : join (z :: w :: ws) = z ++ ',' :: join (w :: ws) := by simp [join] + rw [hjoin] + cases List.mem_cons.mp h with + | inl h1 => + rw [h1] + simp only [List.length_append, List.length_cons] + omega + | inr h2 => + have hle := ih h2 + simp only [List.length_append, List.length_cons] + omega + +/-- The printed length of an array is its body length plus the + two brackets. -/ +theorem print_length_arr (vs : List Json) : + (print (Json.arr vs)).length = (join (vs.map print)).length + 2 := by + simp [print, List.length_append] + +/-- The printed length of an object is its body length plus the + two braces. -/ +theorem print_length_obj (kvs : List (List Char × Json)) : + (print (Json.obj kvs)).length = (join (kvs.map printPair)).length + 2 := by + simp [print, List.length_append] + +/-- The printed length of one object pair. -/ +theorem printPair_length (kv : List Char × Json) : + (printPair kv).length = (printStr kv.1).length + 1 + (print kv.2).length := by + cases kv with + | mk k v => + simp only [printPair, List.length_append, List.length_cons] + omega + +/-- The head character of a printed well formed value: never a + closing bracket, a closing brace, a comma, or whitespace. + The parser's body readers rely on this. -/ +theorem print_head {v : Json} (hv : WF v) : + ∃ c cs, print v = c :: cs ∧ c ≠ ']' ∧ c ≠ '}' ∧ c ≠ ',' ∧ isWs c = false := by + cases v with + | null => + exact ⟨'n', ['u', 'l', 'l'], by simp [print], by decide, by decide, by decide, + by decide⟩ + | bool b => + cases b with + | true => + exact ⟨'t', ['r', 'u', 'e'], by simp [print], by decide, by decide, by decide, + by decide⟩ + | false => + exact ⟨'f', ['a', 'l', 's', 'e'], by simp [print], by decide, by decide, by decide, + by decide⟩ + | str cs => + exact ⟨'"', escapeAll cs ++ ['"'], by simp [print, printStr], by decide, by decide, + by decide, by decide⟩ + | arr vs => + exact ⟨'[', join (vs.map print) ++ [']'], by simp [print], by decide, by decide, + by decide, by decide⟩ + | obj kvs => + exact ⟨'{', join (kvs.map printPair) ++ ['}'], by simp [print], by decide, by decide, + by decide, by decide⟩ + | num cs => + have hnum : WFNum cs := by + cases hv with + | num h => exact h + obtain ⟨neg, hd, intDs, frac, exp, hcs, hhd, -, -, -⟩ := hnum + have hdig : IsDigit hd := by + cases hhd with + | inl h => rw [h.1]; exact ⟨by decide, by decide⟩ + | inr h => + exact ⟨char_le_of_toNat (Nat.le_trans (by decide) (char_le_toNat h.1)), h.2⟩ + cases neg with + | true => + exact ⟨'-', (hd :: intDs) ++ frac ++ exp, by simp [print, hcs, List.append_assoc], + by decide, by decide, by decide, by decide⟩ + | false => + exact ⟨hd, intDs ++ frac ++ exp, by simp [print, hcs, List.append_assoc], + IsDigit.ne_of_toNat hdig (by decide), IsDigit.ne_of_toNat hdig (by decide), + IsDigit.ne_of_toNat hdig (by decide), IsDigit.isWs_false hdig⟩ + +/-- A printed well formed value has at least one character. -/ +theorem print_length_pos {v : Json} (hv : WF v) : 0 < (print v).length := by + obtain ⟨c, cs, h, -, -, -, -⟩ := print_head hv + rw [h] + simp + +/-! ## The round trip -/ + +/-- The three round trip statements at a printed length bound: + one for a value, one for the inside of an array, and one for + the inside of an object. -/ +def Triple (n : Nat) : Prop := + (∀ v, WF v → (print v).length ≤ n → ∀ (f : Nat) (rest : List Char), + (print v).length + 1 ≤ f → TailOk rest → + parseValue f (print v ++ rest) = some (v, rest)) ∧ + (∀ vs, (∀ v ∈ vs, WF v) → (join (vs.map print)).length ≤ n → + ∀ (f : Nat) (rest : List Char), (join (vs.map print)).length + 2 ≤ f → + parseEls f (join (vs.map print) ++ ']' :: rest) = some (vs, rest)) ∧ + (∀ kvs, (∀ kv ∈ kvs, WF kv.2) → (join (kvs.map printPair)).length ≤ n → + ∀ (f : Nat) (rest : List Char), (join (kvs.map printPair)).length + 2 ≤ f → + parseObjEls f (join (kvs.map printPair) ++ '}' :: rest) = some (kvs, rest)) + +/-- The round trip at any bound, by induction on a larger bound: + the bounded form of strong induction. The value statement calls + the two body statements at a smaller bound, spending the two + enclosing brackets. Each body statement calls the value + statement at the same bound for its members, and its own tail + recursion is structural. The fuel each call needs is bounded by + the printed length, which is what makes the counts line up. -/ +theorem key : ∀ n : Nat, Triple n := by + have aux : ∀ k n, n ≤ k → Triple n := by + intro k + induction k with + | zero => + intro n hn + have hn0 : n = 0 := Nat.eq_zero_of_le_zero hn + subst hn0 + refine ⟨?_, ?_, ?_⟩ + · intro v hv hlen _ _ _ _ + have hpos := print_length_pos hv + omega + · intro vs hvs hlen f rest hf + cases vs with + | nil => + cases f with + | zero => omega + | succ fuel => + show parseEls (fuel + 1) (']' :: rest) = some ([], rest) + rw [Json.parseEls.eq_2] + have hsk : skipWs (']' :: rest) = ']' :: rest := skipWs_cons (by decide) + simp [hsk] + | cons v vs => + have hmem : print v ∈ (v :: vs).map print := + List.mem_map.mpr ⟨v, List.mem_cons_self, rfl⟩ + have hle := mem_join_length hmem + have hpos := print_length_pos (hvs v (List.mem_cons_self)) + omega + · intro kvs hkvs hlen f rest hf + cases kvs with + | nil => + cases f with + | zero => omega + | succ fuel => + show parseObjEls (fuel + 1) ('}' :: rest) = some ([], rest) + rw [Json.parseObjEls.eq_2] + have hsk : skipWs ('}' :: rest) = '}' :: rest := skipWs_cons (by decide) + simp [hsk] + | cons kv kvs => + have hmem : printPair kv ∈ (kv :: kvs).map printPair := + List.mem_map.mpr ⟨kv, List.mem_cons_self, rfl⟩ + have hle := mem_join_length hmem + have hpos : 0 < (printPair kv).length := by + rw [printPair_length] + omega + omega + | succ k ihk => + intro n hn + have ih : ∀ m, m < n → Triple m := fun m hm => ihk m (by omega) + have hP : ∀ v, WF v → (print v).length ≤ n → ∀ (f : Nat) (rest : List Char), + (print v).length + 1 ≤ f → TailOk rest → + parseValue f (print v ++ rest) = some (v, rest) := by + intro v hv hlen f rest hf htail + cases f with + | zero => omega + | succ fuel => + cases v with + | null => simp [print, Json.parseValue.eq_2, skipWs, isWs] + | bool b => cases b <;> simp [print, Json.parseValue.eq_2, skipWs, isWs] + | str cs => + have hform : print (Json.str cs) ++ rest = + '"' :: (escapeAll cs ++ '"' :: rest) := by + simp [print, printStr, List.append_assoc] + rw [hform] + have hsk : skipWs ('"' :: (escapeAll cs ++ '"' :: rest)) = + '"' :: (escapeAll cs ++ '"' :: rest) := skipWs_cons (by decide) + simp [Json.parseValue.eq_2, hsk, parseStrBody_escapeAll] + | num cs => + have hnum : WFNum cs := by + cases hv with + | num h => exact h + have hnumc := hnum + obtain ⟨neg, hd, intDs, frac, exp, hcs, hhd, -, -, -⟩ := hnumc + have hdig : IsDigit hd := by + cases hhd with + | inl h => rw [h.1]; exact ⟨by decide, by decide⟩ + | inr h => + exact ⟨char_le_of_toNat (Nat.le_trans (by decide) (char_le_toNat h.1)), + h.2⟩ + cases neg with + | true => + have hform : print (Json.num cs) ++ rest = + '-' :: hd :: (intDs ++ (frac ++ (exp ++ rest))) := by + simp [print, hcs, List.append_assoc] + rw [hform] + have hsk : skipWs ('-' :: hd :: (intDs ++ (frac ++ (exp ++ rest)))) = + '-' :: hd :: (intDs ++ (frac ++ (exp ++ rest))) := + skipWs_cons (by decide) + have hnumres : parseNumber ('-' :: hd :: (intDs ++ (frac ++ (exp ++ rest)))) = + some (cs, rest) := by + have h := parseNumber_append hnum htail + rwa [show cs ++ rest = '-' :: hd :: (intDs ++ (frac ++ (exp ++ rest))) from + by simp [hcs, List.append_assoc]] at h + simp [Json.parseValue.eq_2, hsk, hnumres] + | false => + have hform : print (Json.num cs) ++ rest = + hd :: (intDs ++ (frac ++ (exp ++ rest))) := by + simp [print, hcs, List.append_assoc] + rw [hform] + have hsk : skipWs (hd :: (intDs ++ (frac ++ (exp ++ rest)))) = + hd :: (intDs ++ (frac ++ (exp ++ rest))) := + skipWs_cons (IsDigit.isWs_false hdig) + have hdis : hd = '-' ∨ isDigit hd := Or.inr (isDigit_of_isDigit hdig) + have hne_q : hd ≠ '"' := IsDigit.ne_of_toNat hdig (by decide) + have hne_lb : hd ≠ '[' := IsDigit.ne_of_toNat hdig (by decide) + have hne_lc : hd ≠ '{' := IsDigit.ne_of_toNat hdig (by decide) + have hne_t : hd ≠ 't' := IsDigit.ne_of_toNat hdig (by decide) + have hne_f : hd ≠ 'f' := IsDigit.ne_of_toNat hdig (by decide) + have hne_n : hd ≠ 'n' := IsDigit.ne_of_toNat hdig (by decide) + have hnumres : parseNumber (hd :: (intDs ++ (frac ++ (exp ++ rest)))) = + some (cs, rest) := by + have h := parseNumber_append hnum htail + rwa [show cs ++ rest = hd :: (intDs ++ (frac ++ (exp ++ rest))) from + by simp [hcs, List.append_assoc]] at h + simp [Json.parseValue.eq_2, hsk, hdis, hnumres, hne_q, hne_lb, hne_lc, hne_t, + hne_f, hne_n] + | arr vs => + have hvs : ∀ x ∈ vs, WF x := by + cases hv with + | arr h => exact h + have hlenE : (join (vs.map print)).length < n := by + have h1 : (print (Json.arr vs)).length = + (join (vs.map print)).length + 2 := print_length_arr vs + omega + have hE := (ih _ hlenE).2.1 vs hvs (Nat.le_refl _) fuel rest (by + have h1 : (print (Json.arr vs)).length = + (join (vs.map print)).length + 2 := print_length_arr vs + omega) + have hform : print (Json.arr vs) ++ rest = + '[' :: (join (vs.map print) ++ ']' :: rest) := by + simp [print, List.append_assoc] + rw [hform] + have hsk : skipWs ('[' :: (join (vs.map print) ++ ']' :: rest)) = + '[' :: (join (vs.map print) ++ ']' :: rest) := skipWs_cons (by decide) + simp [Json.parseValue.eq_2, hsk, hE] + | obj kvs => + have hkvs : ∀ kv ∈ kvs, WF kv.2 := by + cases hv with + | obj h => exact h + have hlenO : (join (kvs.map printPair)).length < n := by + have h1 : (print (Json.obj kvs)).length = + (join (kvs.map printPair)).length + 2 := print_length_obj kvs + omega + have hO := (ih _ hlenO).2.2 kvs hkvs (Nat.le_refl _) fuel rest (by + have h1 : (print (Json.obj kvs)).length = + (join (kvs.map printPair)).length + 2 := print_length_obj kvs + omega) + have hform : print (Json.obj kvs) ++ rest = + '{' :: (join (kvs.map printPair) ++ '}' :: rest) := by + simp [print, List.append_assoc] + rw [hform] + have hsk : skipWs ('{' :: (join (kvs.map printPair) ++ '}' :: rest)) = + '{' :: (join (kvs.map printPair) ++ '}' :: rest) := skipWs_cons (by decide) + simp [Json.parseValue.eq_2, hsk, hO] + have hE : ∀ vs, (∀ v ∈ vs, WF v) → (join (vs.map print)).length ≤ n → + ∀ (f : Nat) (rest : List Char), (join (vs.map print)).length + 2 ≤ f → + parseEls f (join (vs.map print) ++ ']' :: rest) = some (vs, rest) := by + intro vs + induction vs with + | nil => + intro _ _ f rest hf + cases f with + | zero => omega + | succ fuel => + show parseEls (fuel + 1) (']' :: rest) = some ([], rest) + rw [Json.parseEls.eq_2] + have hsk : skipWs (']' :: rest) = ']' :: rest := skipWs_cons (by decide) + simp [hsk] + | cons v vs ih => + intro hvs hlen f rest hf + have hv : WF v := hvs v (List.mem_cons_self) + have hvs' : ∀ x ∈ vs, WF x := fun x hx => hvs x (List.mem_cons_of_mem v hx) + obtain ⟨c, cs, hprint, hcne1, -, -, hcws⟩ := print_head hv + have hmemE : print v ∈ (v :: vs).map print := + List.mem_map.mpr ⟨v, List.mem_cons_self, rfl⟩ + have hple : (print v).length ≤ n := + Nat.le_trans (mem_join_length hmemE) hlen + cases f with + | zero => omega + | succ fuel => + cases vs with + | nil => + have hjoin : join ((v :: []).map print) = print v := by simp [join] + have hPv := hP v hv hple fuel (']' :: rest) (by + have hmem : print v ∈ (v :: []).map print := + List.mem_map.mpr ⟨v, List.mem_cons_self, rfl⟩ + have hle := mem_join_length hmem + simp only [List.map_cons, List.map_nil, join] at hf + omega) (tailOk_rbracket rest) + rw [hjoin] + show parseEls (fuel + 1) (print v ++ ']' :: rest) = some (v :: [], rest) + have hsk1 : skipWs (print v ++ ']' :: rest) = print v ++ ']' :: rest := + skipWs_append_of_head (by simp [hprint]) hcws + have hPv2 : parseValue fuel (c :: (cs ++ ']' :: rest)) = + some (v, ']' :: rest) := by + have e : print v ++ ']' :: rest = c :: (cs ++ ']' :: rest) := by + simp [hprint] + rw [e] at hPv + exact hPv + have hsk3 : skipWs (']' :: rest) = ']' :: rest := skipWs_cons (by decide) + rw [Json.parseEls.eq_2, hsk1, hprint] + simp [hcne1, hPv2, hsk3] + | cons w ws => + have hjoin : join ((v :: w :: ws).map print) = + print v ++ ',' :: join ((w :: ws).map print) := by simp [join] + have hmono : (join ((w :: ws).map print)).length ≤ + (join ((v :: w :: ws).map print)).length := by + rw [hjoin] + simp only [List.length_append, List.length_cons] + omega + have hlen' : (join ((w :: ws).map print)).length ≤ n := Nat.le_trans hmono hlen + have hf' : (join ((w :: ws).map print)).length + 2 ≤ fuel := by + have hbl : (join ((v :: w :: ws).map print)).length = + (print v).length + 1 + (join ((w :: ws).map print)).length := by + rw [hjoin] + simp only [List.length_append, List.length_cons] + omega + omega + have hEls := ih hvs' hlen' fuel rest hf' + have hPv := hP v hv hple fuel + ((',' :: join ((w :: ws).map print)) ++ ']' :: rest) (by + have hmem : print v ∈ (v :: w :: ws).map print := + List.mem_map.mpr ⟨v, List.mem_cons_self, rfl⟩ + have hle := mem_join_length hmem + omega) (tailOk_comma _) + have hPvT : parseValue fuel + (print v ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest))) = + some (v, ',' :: (join ((w :: ws).map print) ++ ']' :: rest)) := hPv + have hPv2 : parseValue fuel + (c :: (cs ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest)))) = + some (v, ',' :: (join ((w :: ws).map print) ++ ']' :: rest)) := by + have e : print v ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest)) = + c :: (cs ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest))) := by + simp [hprint] + rw [e] at hPvT + exact hPvT + obtain ⟨c2, cs2, hprint2, hc2ne1, -, -, hc2ws⟩ := + print_head (hvs' w (List.mem_cons_self)) + have hjoinw : join ((w :: ws).map print) = + print w ++ (if ws = [] then [] else ',' :: join (ws.map print)) := by + cases ws with + | nil => simp [join] + | cons x xs => simp [join] + have hskA : skipWs + (c :: (cs ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest)))) = + c :: (cs ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest))) := + skipWs_cons hcws + have hskB : skipWs (',' :: (join ((w :: ws).map print) ++ ']' :: rest)) = + ',' :: (join ((w :: ws).map print) ++ ']' :: rest) := + skipWs_cons (by decide) + have hsk2 : skipWs (join ((w :: ws).map print) ++ ']' :: rest) = + c2 :: (cs2 ++ + ((if ws = [] then [] else ',' :: join (ws.map print)) ++ + ']' :: rest)) := by + have e1 : join ((w :: ws).map print) ++ ']' :: rest = + print w ++ + ((if ws = [] then [] else ',' :: join (ws.map print)) ++ + ']' :: rest) := by + rw [hjoinw, List.append_assoc] + rw [e1] + have e2 := skipWs_append_of_head (l := print w) + (rest := (if ws = [] then [] else ',' :: join (ws.map print)) ++ + ']' :: rest) (by simp [hprint2]) hc2ws + rw [e2, hprint2] + exact List.cons_append + rw [hjoin, List.append_assoc, hprint] + show parseEls (fuel + 1) + (c :: (cs ++ (',' :: (join ((w :: ws).map print) ++ ']' :: rest)))) = + some (v :: w :: ws, rest) + rw [Json.parseEls.eq_2] + simp only [hskA, hPv2, hskB, hsk2, hEls] + simp [hcne1, hc2ne1] + have hO : ∀ kvs, (∀ kv ∈ kvs, WF kv.2) → (join (kvs.map printPair)).length ≤ n → + ∀ (f : Nat) (rest : List Char), (join (kvs.map printPair)).length + 2 ≤ f → + parseObjEls f (join (kvs.map printPair) ++ '}' :: rest) = some (kvs, rest) := by + intro kvs + induction kvs with + | nil => + intro _ _ f rest hf + cases f with + | zero => omega + | succ fuel => + show parseObjEls (fuel + 1) ('}' :: rest) = some ([], rest) + rw [Json.parseObjEls.eq_2] + have hsk : skipWs ('}' :: rest) = '}' :: rest := skipWs_cons (by decide) + simp [hsk] + | cons kv kvs ih => + intro hkvs hlen f rest hf + cases kv with + | mk k v => + have hv2 : WF v := hkvs (k, v) (List.mem_cons_self) + have hkvs' : ∀ x ∈ kvs, WF x.2 := + fun x hx => hkvs x (List.mem_cons_of_mem (k, v) hx) + have hpp : printPair (k, v) = '"' :: (escapeAll k ++ '"' :: ':' :: print v) := by + simp [printPair, printStr, List.append_assoc] + have hple : (print v).length ≤ n := by + have h1 : (print v).length ≤ (printPair (k, v)).length := by + have h := printPair_length (k, v) + dsimp only at h + omega + have hmem : printPair (k, v) ∈ ((k, v) :: kvs).map printPair := + List.mem_map.mpr ⟨(k, v), List.mem_cons_self, rfl⟩ + have h2 := mem_join_length hmem + omega + cases f with + | zero => omega + | succ fuel => + cases kvs with + | nil => + have hjoin : join (((k, v) :: []).map printPair) = printPair (k, v) := by + simp [join] + have hPv := hP v hv2 hple fuel ('}' :: rest) (by + have h1 : (print v).length ≤ (printPair (k, v)).length := by + have h := printPair_length (k, v) + dsimp only at h + omega + have hmem : printPair (k, v) ∈ ((k, v) :: []).map printPair := + List.mem_map.mpr ⟨(k, v), List.mem_cons_self, rfl⟩ + have h2 := mem_join_length hmem + simp only [List.map_cons, List.map_nil, join] at hf + omega) (tailOk_rbrace rest) + rw [hjoin] + show parseObjEls (fuel + 1) (printPair (k, v) ++ '}' :: rest) = + some ((k, v) :: [], rest) + have hsk1 : skipWs (printPair (k, v) ++ '}' :: rest) = + printPair (k, v) ++ '}' :: rest := + skipWs_append_of_head (c := '"') (by simp [hpp]) (by decide) + have hskC : skipWs (':' :: (print v ++ '}' :: rest)) = + ':' :: (print v ++ '}' :: rest) := skipWs_cons (by decide) + rw [Json.parseObjEls.eq_2] + simp [hsk1, hpp, parseStrBody_escapeAll, hskC, hPv, skipWs, isWs] + | cons kv' kvs' => + have hjoin : join (((k, v) :: kv' :: kvs').map printPair) = + printPair (k, v) ++ ',' :: join ((kv' :: kvs').map printPair) := by + simp [join] + have hmono : (join ((kv' :: kvs').map printPair)).length ≤ + (join (((k, v) :: kv' :: kvs').map printPair)).length := by + rw [hjoin] + simp only [List.length_append, List.length_cons] + omega + have hlen' : (join ((kv' :: kvs').map printPair)).length ≤ n := + Nat.le_trans hmono hlen + have hf' : (join ((kv' :: kvs').map printPair)).length + 2 ≤ fuel := by + have hbl : (join (((k, v) :: kv' :: kvs').map printPair)).length = + (printPair (k, v)).length + 1 + + (join ((kv' :: kvs').map printPair)).length := by + rw [hjoin] + simp only [List.length_append, List.length_cons] + omega + omega + have hO' := ih hkvs' hlen' fuel rest hf' + have hPv := hP v hv2 hple fuel + ((',' :: join ((kv' :: kvs').map printPair)) ++ '}' :: rest) (by + have h1 : (print v).length ≤ (printPair (k, v)).length := by + have h := printPair_length (k, v) + dsimp only at h + omega + have hmem : printPair (k, v) ∈ ((k, v) :: kv' :: kvs').map printPair := + List.mem_map.mpr ⟨(k, v), List.mem_cons_self, rfl⟩ + have h2 := mem_join_length hmem + omega) (tailOk_comma _) + cases kv' with + | mk k' v' => + have hPv2 : parseValue fuel + (print v ++ + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest))) = + some (v, ',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest)) := hPv + have hskA : skipWs ('"' :: (escapeAll k ++ '"' :: ':' :: + (print v ++ + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest))))) = + '"' :: (escapeAll k ++ '"' :: ':' :: + (print v ++ + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest)))) := + skipWs_cons (by decide) + have hskC2 : skipWs (':' :: (print v ++ + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest)))) = + ':' :: (print v ++ + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest))) := + skipWs_cons (by decide) + have hskT2 : skipWs + (',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest)) = + ',' :: (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest) := + skipWs_cons (by decide) + have hpp' : printPair (k', v') = + '"' :: (escapeAll k' ++ '"' :: ':' :: print v') := by + simp [printPair, printStr, List.append_assoc] + have hjoin' : join (((k', v') :: kvs').map printPair) = + printPair (k', v') ++ + (if kvs' = [] then [] else ',' :: join (kvs'.map printPair)) := by + cases kvs' with + | nil => simp [join] + | cons x xs => simp [join] + have hsk4 : skipWs (join (((k', v') :: kvs').map printPair) ++ + '}' :: rest) = + '"' :: (escapeAll k' ++ '"' :: (':' :: (print v' ++ + ((if kvs' = [] then [] else ',' :: join (kvs'.map printPair)) ++ + '}' :: rest)))) := by + have e1 : join (((k', v') :: kvs').map printPair) ++ '}' :: rest = + printPair (k', v') ++ + ((if kvs' = [] then [] else ',' :: join (kvs'.map printPair)) ++ + '}' :: rest) := by + rw [hjoin', List.append_assoc] + rw [e1] + have e2 := skipWs_append_of_head (l := printPair (k', v')) (c := '"') + (rest := (if kvs' = [] then [] else ',' :: join (kvs'.map printPair)) ++ + '}' :: rest) (by simp [hpp']) (by decide) + rw [e2, hpp'] + simp [List.append_assoc] + rw [hjoin, List.append_assoc, hpp] + rw [Json.parseObjEls.eq_2] + simp only [List.cons_append, List.append_assoc] + simp only [hskA, parseStrBody_escapeAll, hskC2, hPv2, hskT2, hsk4, hO'] + simp + exact ⟨hP, hE, hO⟩ + intro n + exact aux n n (Nat.le_refl n) + +/-- The round trip: parsing the printed form of a well formed value + gives the value back. -/ +theorem parse_print {v : Json} (hv : WF v) : parse (print v) = some v := by + have h := (key (print v).length).1 v hv (Nat.le_refl _) ((print v).length + 1) [] + (Nat.le_refl _) tailOk_nil + have h2 : parseValue ((print v).length + 1) (print v) = some (v, []) := by + simpa using h + unfold parse + simp [h2, skipWs] + +end Json.Proofs diff --git a/Proofs/Token.lean b/Proofs/Token.lean new file mode 100644 index 0000000..1abdee7 --- /dev/null +++ b/Proofs/Token.lean @@ -0,0 +1,114 @@ +import Jws.Token +import Proofs.RoundTrip +import Proofs.B64 +import Proofs.Utf8 + +namespace Jws.Proofs + +open Jws + +/-- Splitting reads back a join whose first part is free of the + separator: the first part comes off whole. -/ +theorem splitSep_append (sep : Char) (p rest : List Char) (h : sep ∉ p) : + splitSep sep (p ++ sep :: rest) = p :: splitSep sep rest := by + induction p with + | nil => + simp only [List.nil_append] + rw [splitSep] + exact if_pos rfl + | cons c cs ih => + have hc : c ≠ sep := by + intro e + apply h + rw [e] + exact List.mem_cons_self + have hcs : sep ∉ cs := fun hm => h (List.mem_cons_of_mem _ hm) + rw [List.cons_append, splitSep, if_neg hc, ih hcs] + +/-- Splitting a text with no separator in it gives the text back + as the one part. -/ +theorem splitSep_of_not_mem (sep : Char) (p : List Char) (h : sep ∉ p) : + splitSep sep p = [p] := by + induction p with + | nil => rfl + | cons c cs ih => + have hc : c ≠ sep := by + intro e + apply h + rw [e] + exact List.mem_cons_self + have hcs : sep ∉ cs := fun hm => h (List.mem_cons_of_mem _ hm) + rw [splitSep, if_neg hc, ih hcs] + +/-- No character the base64url encoder writes is the dot. -/ +theorem encode_ne_dot : ∀ (bs : List UInt8) (c : Char), + c ∈ Base64url.encode bs → c ≠ '.' := by + intro bs + induction bs using Base64url.encode.induct with + | case1 a b d rest ih => + intro c h + rw [Base64url.encode] at h + simp only [List.mem_cons] at h + have ha := UInt8.toNat_lt a + have hb := UInt8.toNat_lt b + have hd := UInt8.toNat_lt d + rcases h with rfl | rfl | rfl | rfl | h + · exact Base64url.Proofs.char_ne_dot (by omega) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · exact ih c h + | case2 a b => + intro c h + rw [Base64url.encode] at h + simp only [List.mem_cons, List.mem_singleton] at h + have ha := UInt8.toNat_lt a + have hb := UInt8.toNat_lt b + rcases h with rfl | rfl | rfl | h + · exact Base64url.Proofs.char_ne_dot (by omega) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · cases h + | case3 a => + intro c h + rw [Base64url.encode] at h + simp only [List.mem_cons, List.mem_singleton] at h + have ha := UInt8.toNat_lt a + rcases h with rfl | rfl | h + · exact Base64url.Proofs.char_ne_dot (by omega) + · exact Base64url.Proofs.char_ne_dot (Nat.mod_lt _ (by decide)) + · cases h + | case4 => + intro c h + cases h + +/-- The printed token splits into its three printed parts. -/ +theorem splitSep_print (t : Token) : + splitSep '.' (printToken t) = + [Base64url.encode (Utf8.encode (Json.print t.header)), + Base64url.encode (Utf8.encode (Json.print t.claims)), + Base64url.encode t.sig] := by + have n1 : ∀ c ∈ Base64url.encode (Utf8.encode (Json.print t.header)), c ≠ '.' := + fun c hc => encode_ne_dot _ c hc + have n2 : ∀ c ∈ Base64url.encode (Utf8.encode (Json.print t.claims)), c ≠ '.' := + fun c hc => encode_ne_dot _ c hc + have n3 : ∀ c ∈ Base64url.encode t.sig, c ≠ '.' := + fun c hc => encode_ne_dot _ c hc + simp only [printToken, joinSep] + rw [splitSep_append _ _ _ (fun hm => n1 _ hm rfl), + splitSep_append _ _ _ (fun hm => n2 _ hm rfl), + splitSep_of_not_mem _ _ (fun hm => n3 _ hm rfl)] + +/-- The plan's main claim: parsing a printed token gives the + token back, for tokens whose JSON parts are well formed in + the json slice's sense. The proof is a chain of the three + round trips under it: the JSON text round trip, the UTF-8 + round trip, and the base64url round trip, with the split and + join of the three parts read back by the two lemmas above. -/ +theorem parseToken_print (t : Token) (hh : Json.WF t.header) (hc : Json.WF t.claims) : + parseToken (printToken t) = some t := by + obtain ⟨h, c, s⟩ := t + simp only [parseToken, splitSep_print, Base64url.Proofs.decode_encode, + Utf8.Proofs.decode_encode, Json.Proofs.parse_print hh, Json.Proofs.parse_print hc] + +end Jws.Proofs diff --git a/Proofs/Utf8.lean b/Proofs/Utf8.lean new file mode 100644 index 0000000..0735767 --- /dev/null +++ b/Proofs/Utf8.lean @@ -0,0 +1,135 @@ +import Jws.Utf8 +import Proofs.B64 + +namespace Jws.Utf8.Proofs + +open Jws.Utf8 Jws.Base64url.Proofs + +/-- A character value is below 110000, and no character value + lies in the surrogate gap D800 to DFFF: the Unicode scalar + range every `Char` lives in. -/ +theorem char_bounds (c : Char) : + c.toNat < 0x110000 ∧ ¬ (0xD800 ≤ c.toNat ∧ c.toNat < 0xE000) := by + have h := c.valid + show c.val.toNat < 0x110000 ∧ ¬ (0xD800 ≤ c.val.toNat ∧ c.val.toNat < 0xE000) + rcases h with h | ⟨h1, h2⟩ + · exact ⟨by omega, by omega⟩ + · exact ⟨by omega, by omega⟩ + +/-- A byte whose value is known to be a continuation byte reads + back as its payload value. -/ +theorem contVal_of_toNat {b : UInt8} {k : Nat} (h : b.toNat = 0x80 + k) (hk : k < 64) : + contVal b = some k := by + unfold contVal + rw [h, if_pos ⟨by omega, by omega⟩, Nat.add_sub_cancel_left] + +/-- One step of the decoder on a plain byte. -/ +theorem decode_one (b : UInt8) (bs : List UInt8) (hb : b.toNat < 0x80) : + decode (b :: bs) = (decode bs).map (Char.ofNat b.toNat :: ·) := by + cases bs with + | nil => rw [decode.eq_3, if_pos hb] + | cons b1 bs' => rw [decode.eq_2, if_pos hb] + +/-- One step of the decoder on a two byte form. -/ +theorem decode_two (b b1 : UInt8) (bs : List UInt8) (v : Nat) + (hb : 0xC2 ≤ b.toNat ∧ b.toNat < 0xE0) (hv : contVal b1 = some v) : + decode (b :: b1 :: bs) = + (decode bs).map (Char.ofNat ((b.toNat - 0xC0) * 64 + v) :: ·) := by + rw [decode.eq_2, if_neg (by omega : ¬ b.toNat < 0x80), + if_neg (by omega : ¬ b.toNat < 0xC2), if_pos (by omega : b.toNat < 0xE0), hv] + +/-- One step of the decoder on a three byte form. -/ +theorem decode_three (b b1 b2 : UInt8) (bs : List UInt8) (v1 v2 : Nat) + (hb : 0xE0 ≤ b.toNat ∧ b.toNat < 0xF0) + (hside : (b.toNat = 0xE0 → 0x20 ≤ v1) ∧ (b.toNat = 0xED → v1 < 0x20)) + (hv1 : contVal b1 = some v1) (hv2 : contVal b2 = some v2) : + decode (b :: b1 :: b2 :: bs) = + (decode bs).map (Char.ofNat ((b.toNat - 0xE0) * 4096 + v1 * 64 + v2) :: ·) := by + rw [decode.eq_2, if_neg (by omega : ¬ b.toNat < 0x80), + if_neg (by omega : ¬ b.toNat < 0xC2), if_neg (by omega : ¬ b.toNat < 0xE0), + if_pos (by omega : b.toNat < 0xF0)] + dsimp only + rw [hv1, hv2] + dsimp only + rw [if_pos hside] + +/-- One step of the decoder on a four byte form. -/ +theorem decode_four (b b1 b2 b3 : UInt8) (bs : List UInt8) (v1 v2 v3 : Nat) + (hb : 0xF0 ≤ b.toNat ∧ b.toNat < 0xF5) + (hside : (b.toNat = 0xF0 → 0x10 ≤ v1) ∧ (b.toNat = 0xF4 → v1 < 0x10)) + (hv1 : contVal b1 = some v1) (hv2 : contVal b2 = some v2) + (hv3 : contVal b3 = some v3) : + decode (b :: b1 :: b2 :: b3 :: bs) = + (decode bs).map + (Char.ofNat ((b.toNat - 0xF0) * 262144 + v1 * 4096 + v2 * 64 + v3) :: ·) := by + rw [decode.eq_2, if_neg (by omega : ¬ b.toNat < 0x80), + if_neg (by omega : ¬ b.toNat < 0xC2), if_neg (by omega : ¬ b.toNat < 0xE0), + if_neg (by omega : ¬ b.toNat < 0xF0), if_pos (by omega : b.toNat < 0xF5)] + dsimp only + rw [hv1, hv2, hv3] + dsimp only + rw [if_pos hside] + +/-- Decoding one encoded character in front of any bytes decodes + the character and then the bytes. The four cases are the four + lengths; each one applies the step lemma for its length, + checks the side conditions with arithmetic, and rebuilds the + character value. -/ +theorem decode_append_char (c : Char) (bs : List UInt8) : + decode (encodeChar c ++ bs) = (decode bs).map (c :: ·) := by + obtain ⟨hmax, hnsur⟩ := char_bounds c + by_cases h1 : c.toNat < 0x80 + · rw [encodeChar, if_pos h1, List.cons_append, List.nil_append, + decode_one _ _ (by rw [toNat_toUInt8 (by omega)]; exact h1), + toNat_toUInt8 (by omega), Char.ofNat_toNat] + · rw [encodeChar, if_neg h1] + by_cases h2 : c.toNat < 0x800 + · have hlead : (0xC0 + c.toNat / 64).toUInt8.toNat = 0xC0 + c.toNat / 64 := + toNat_toUInt8 (by omega) + rw [if_pos h2, List.cons_append, List.cons_append, List.nil_append, + decode_two _ _ _ _ ⟨by omega, by omega⟩ + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))), + hlead, + show (0xC0 + c.toNat / 64 - 0xC0) * 64 + c.toNat % 64 = c.toNat by omega, + Char.ofNat_toNat] + · rw [if_neg h2] + by_cases h3 : c.toNat < 0x10000 + · have hlead : (0xE0 + c.toNat / 4096).toUInt8.toNat = 0xE0 + c.toNat / 4096 := + toNat_toUInt8 (by omega) + rw [if_pos h3, List.cons_append, List.cons_append, List.cons_append, + List.nil_append, + decode_three _ _ _ _ _ _ ⟨by omega, by omega⟩ + ⟨fun hb => by rw [hlead] at hb; omega, + fun hb => by rw [hlead] at hb; omega⟩ + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))) + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))), + hlead, + show (0xE0 + c.toNat / 4096 - 0xE0) * 4096 + (c.toNat / 64) % 64 * 64 + + c.toNat % 64 = c.toNat by omega, + Char.ofNat_toNat] + · have hlead : (0xF0 + c.toNat / 262144).toUInt8.toNat = 0xF0 + c.toNat / 262144 := + toNat_toUInt8 (by omega) + rw [if_neg h3, List.cons_append, List.cons_append, List.cons_append, + List.cons_append, List.nil_append, + decode_four _ _ _ _ _ _ _ _ ⟨by omega, by omega⟩ + ⟨fun hb => by rw [hlead] at hb; omega, + fun hb => by rw [hlead] at hb; omega⟩ + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))) + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))) + (contVal_of_toNat (toNat_toUInt8 (by omega)) (Nat.mod_lt _ (by decide))), + hlead, + show (0xF0 + c.toNat / 262144 - 0xF0) * 262144 + (c.toNat / 4096) % 64 * 4096 + + (c.toNat / 64) % 64 * 64 + c.toNat % 64 = c.toNat by omega, + Char.ofNat_toNat] + +/-- Decoding the encoding of any text gives the text back. -/ +theorem decode_encode : ∀ cs : List Char, decode (encode cs) = some cs := by + intro cs + induction cs with + | nil => rfl + | cons c cs ih => + show decode (encodeChar c ++ encode cs) = some (c :: cs) + rw [decode_append_char, ih] + rfl + +end Jws.Utf8.Proofs diff --git a/README.md b/README.md index fd90273..b8a3dc2 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,72 @@ -# jws +# jws: a JWS compact parser and checker, with a proved round trip -A Lean parser for JWS compact serialization. This is a Proven Commons project: it rebuilds a widely used format in Lean and proves the new code follows its written specification. +A parser and checker for the JWS compact form (RFC 7515) in Lean, with the plan's main claim proved: printing a parsed token gives the token back. The parser reads the three base64url parts, decodes the header and payload as JSON, and holds the signature bytes unchanged. The checker runs the shape and claims rules of `SPEC.md` (algorithm allowlist, required claims, and the `exp`, `nbf`, `iss`, and `aud` claims against a caller supplied context). Tests run the RFC 7515 Appendix A examples and a differential corpus against an oracle implementing the same rules; see `tests/TEST-RESULTS.md` for the counts. -Status: not verified. No code has landed yet. Plan issue 1 holds the first slice, and it is unclaimed. The specification has not been approved by a reviewer, so do not call this verified. +Status: in progress. The first slice from plan issue 1 has landed locally. The specification has not yet been approved by a reviewer apart from its author, and no statement file (`Spec.lean`) has been added by a reviewer, so this is not verified. ## Limit, stated up front This slice does not verify a signature. It must never be described as a verified token on its own. Signature verification is the second slice. A caller that reads claims from this slice without verifying the signature has not checked authenticity. -## First slice +## Where the JSON code came from -A JSON Web Signature in compact form is three base64url parts joined by dots. The first part is the header. The second part is the payload. The third part is the signature. +The JSON parser, printer, value type, and round trip proof are the json repo's, carried in unchanged under `Json/` and `Proofs/RoundTrip.lean` (the repos in this org do not import each other). Everything else is new here: the base64url codec (`Jws/Base64url.lean`), a strict UTF-8 codec (`Jws/Utf8.lean`), and the token framing, claims reading, and checker (`Jws/Token.lean`). The base64url code follows the shape of the codec repo's Base64 work, with the URL safe alphabet and no padding. -- Parse the compact form. -- Decode the header and payload as JSON, using the json repo. -- Read the registered claims exp, nbf, iss, aud, and iat. -- Reject a header with alg set to none. -- Reject an alg outside a caller supplied allowlist. -- Reject a missing required claim, an expired exp, a not yet valid nbf, and a mismatched iss or aud. -- Return the signature bytes unchanged, for a verifier the caller supplies. +## What is proved -## Main claim +| Claim | Theorem | +|-------|---------| +| Parsing the printed form of a token gives the token back, for tokens whose JSON parts are well formed in the json slice's sense. | `Jws.Proofs.parseToken_print` | +| Decoding the base64url encoding of any bytes gives the bytes back. | `Jws.Base64url.Proofs.decode_encode` | +| Encoding the decoding of any text the decoder accepts gives the text back. | `Jws.Base64url.Proofs.encode_decode` | +| Decoding the UTF-8 encoding of any text gives the text back. | `Jws.Utf8.Proofs.decode_encode` | +| Parsing the canonical printed form of a well formed JSON value gives the value back (carried in with the JSON code). | `Json.Proofs.parse_print` | -Printing a parsed token gives the same token back. A token is accepted only when its framing matches the compact form, its header alg is on the allowlist, and its registered claims pass the checks above. +The two base64url round trips check against `propext` and `Quot.sound` only. The token and UTF-8 round trips check against the standard three (`propext`, `Classical.choice`, `Quot.sound`). A reader can confirm with `#print axioms` on each name. -## Test oracles +## What is not proved -- PyJWT and Go JWT libraries. -- RFC 7515 Appendix A and B test vectors. -- RFC 7519 examples. +- That the checker accepts exactly the tokens `SPEC.md` says it should. The acceptance rules live in one function (`Jws.accept`) and their 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. +- The reverse printing direction at the token level (printing a parsed token is how the round trip is stated; the base64url and JSON layers each prove their own reverse directions). +- Anything about the numeric meaning of claims beyond the exact decimal reading the checker itself uses (whole seconds, comparisons). The reading code is exercised by the tests, including exponent spellings and very large exponents. +- 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. -## Score +Two readings worth naming, both stated in `SPEC.md` and pinned by tests: -16 out of 18, first pass score in mission issue 19, the round 4 sheet. Created at the org owner's direction on that sheet, on the judgement that the slice, claim, and oracles are sound and that it duplicates no existing repo. +- An empty signature part is well formed, so it can pass the checks. Only the algorithm name `none` is turned away outright, together with any context whose allowlist names `none`. +- When a JSON object repeats a name, lookup reads the first copy, as the JSON code's lookup does. -## What is not in this slice +## What you must trust -- Signature verification. -- Any claim that a token read here is authentic. +- The Lean kernel, which checks the proofs. +- The Lean compiler, with the C compiler and linker shipped with it, and the runtime, which turn the proved definitions into the program that runs. +- Lean's core library, including the compiled code it puts in place of some definitions the proofs reason about. +- The operating system and hardware it runs on. +- For the tests: Python's standard library (the oracle's JSON reading and exact decimals), PyJWT and cryptography in the test venv when present (token building and decode cross checks only; verdicts never come from PyJWT), and the RFC 7515 text for the fixed examples. The results file names the versions used. -## Layout, when the first slice lands +This project has no dependencies beyond Lean itself. The test harness's PyJWT corpus is optional and lives in a venv inside the work directory. -The layout follows the Proven Commons template in the mission repo, under bar/template. Spec.lean holds the statements a reviewer approves. Proofs.lean holds the proofs under the same names. Main.lean holds the command line tool used by the tests. The bar check runs in CI on every change. +## Run it + +Needs Linux and [elan](https://github.com/leanprover/elan). Build, then feed request lines to the tool: + +``` +lake build +python3 tests/run_tests.py +``` + +A request is one `check` line; `SPEC.md` states the field format in full. The token text, the current time, the allowlist, the required claims, the expected issuer, and the expected audience are the six fields, each written as comma separated decimal character codes, the fields separated by semicolons. The answer is `ok:` and the codes of the canonical reprinted token, `none` when the token is not accepted, and `error` when the request is malformed. + +## Re-check it + +Needs Linux, elan, and Python 3.11 or later. + +``` +lake build +grep -rn "sorry" --include="*.lean" Json Jws Proofs Main.lean Json.lean Jws.lean Proofs.lean +python3 tests/run_tests.py +``` + +The grep should print nothing: there is no `sorry` anywhere. The test script runs the fixed RFC cases, the mutation cases, the malformed request cases, and the differential corpus (PyJWT built tokens when the venv is present, plus a seeded random corpus), and writes the counts to `tests/TEST-RESULTS.md`. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ef172de --- /dev/null +++ b/SPEC.md @@ -0,0 +1,178 @@ +# JWS compact form: parser and checker — specification + +This is the first slice of the jws repo: parse a JWS in compact +form (RFC 7515 section 5.2), run a set of shape and claims +checks on it, and print it back in a canonical form. + +**This slice does not verify the signature.** A token that +passes every check below is well formed and its claims are +consistent with the check context. It is not thereby verified: +nothing here shows the signature was made by the holder of any +key, or that it covers these header and payload bytes at all. +Signature verification is the second slice and is out of scope +here. Do not treat a passing answer from this code as a verified +token. + +## Framing + +A compact token is three base64url parts joined by dots: + +``` +part1 "." part2 "." part3 +``` + +- Part 1 is the header: UTF-8 JSON text, encoded in base64url. +- Part 2 is the payload, the claims set: UTF-8 JSON text, + encoded in base64url. +- Part 3 is the signature: raw bytes, encoded in base64url. + +Exactly three parts. Two parts or four parts are turned away. +An empty part is allowed by the framing (the empty text is +well formed base64url for zero bytes); the checks below decide +whether what it decodes to is acceptable. In particular an +empty signature part is well formed. + +## Base64url, strict + +Decoding follows RFC 4648 section 5 with no padding, and is +strict: + +- The alphabet is `A-Z a-z 0-9 - _`. Any other character turns + the part away, including `=` (padding is not part of the + compact form, so a padded part is turned away) and the + standard base64 characters `+` and `/`. +- A part whose length is 1 mod 4 is impossible and is turned + away. +- The unused bits in the final character must be zero. In a + final group of two characters (one byte), the second + character's low four bits are unused and must be zero. In a + final group of three characters (two bytes), the third + character's low two bits must be zero. + +Encoding writes no padding and always sets the unused bits to +zero, so decoding an encoding and encoding a decoding are both +the identity. Both directions are proved in this repo. + +The header and payload bytes must then be UTF-8, read strictly: +overlong forms, encodings of the surrogate values U+D800 to +U+DFFF, values above U+10FFFF, stray continuation bytes, and +truncated sequences are all turned away. The texts must then +parse as JSON under the rules of the json slice carried into +this repo (RFC 8259 grammar; lone surrogate escapes in strings +are turned away, as that slice's parser cannot hold them). + +## The check + +The caller supplies a context: + +- an allowlist of algorithm names; +- the current time in seconds, an integer, or no time (see + below); +- a list of required claim names; +- an expected issuer, or none; +- an expected audience, or none. + +The token is accepted only when every rule holds: + +1. The framing is exactly three parts, each strict base64url + (above). The signature bytes are returned unchanged; their + content is not examined. +2. The header decodes to a JSON object. +3. The payload decodes to a JSON object (the claims set). +4. The header carries `alg`, and its value is a string. +5. `alg` is not `none`. This holds whatever the allowlist says. + As a companion rule, an allowlist that itself names `none` + is an error at the check level: such a context rejects every + token. (Asking to allow `none` is asking for unsecured + tokens; this slice does not accept them under any context.) +6. `alg` is on the allowlist. Names are compared exactly, + character for character: `None` and `hs256` are different + names from `none` and `HS256`. +7. Every required claim is present in the claims set. Presence + is all this rule asks; the value's type does not matter for + this rule. +8. When `exp`, `nbf`, or `iat` is present, its value must be a + JSON number whose value is a whole number of seconds. A + non-number, or a number with a fractional part (for example + `2000000000.5`), turns the token away. This type rule holds + even when no current time is supplied. The value is judged + as an exact decimal, not by its spelling: `2.0e9` is the + whole number 2000000000, and a value like `1e999999999` is + compared exactly, without ever being written out in full. +9. When `exp` is present and a current time is supplied, the + current time must be before `exp`. A current time equal to + `exp` rejects (the token is expired at that second). +10. When `nbf` is present and a current time is supplied, the + current time must be at or after `nbf`. A current time + equal to `nbf` accepts. +11. `iat` carries no comparison rule in this slice; only the + type rule (rule 8) applies to it. +12. When `iss` is present, its value must be a string. +13. When `aud` is present, its value must be a string or an + array of strings (every member a string). +14. When an expected issuer is given, `iss` must be present + and exactly equal to it. A missing `iss` rejects. +15. When an expected audience is given, `aud` must be that + string, or an array holding that string as a member. A + missing `aud` rejects. + +When no current time is supplied, rules 9 and 10 are skipped; +every other rule, including the type rule 8, still applies. + +Names are read from objects with the JSON code's lookup: when a +name appears more than once in an object, the first copy is the +one read. + +## Model and printing + +A token value holds the header JSON value, the claims JSON +value, and the signature bytes. Printing a token prints each +JSON part with the json slice's canonical printer (no spaces, +object pairs in the order held, numbers kept as their source +text, strings escaped in that printer's fixed way), encodes the +text as UTF-8, base64url-encodes without padding, and joins the +three parts with dots, the signature last. + +The main claim, proved in this repo: for a token whose JSON +parts are well formed in the json slice's sense (its `WF`), +parsing the printed token gives the token back +(`Jws.Proofs.parseToken_print`). Under it, the base64url round +trips (both directions, for arbitrary bytes and for every text +the decoder accepts) and the UTF-8 decode-after-encode round +trip are also proved, and the json slice's own parse-after-print +theorem is carried in with its code. + +## The command line front end + +One request per line on stdin, one answer per line on stdout. +A request is a word, a space, and a payload. The payload is +decimal character codes separated by commas. The one operation +is `check`: + +``` +check ;;;;; +``` + +The six fields are separated by semicolons (code 59). Each +field is a text written as character codes, in the same comma +separated decimal form as the payload: + +- Field 1: the token text. +- Field 2: the current time as decimal digits, with an optional + leading minus sign, at most 19 digits. Empty means no current + time (rules 9 and 10 skipped). +- Field 3: the allowlist. The field text is the algorithm names + joined by commas inside the text itself, for example the text + `HS256,RS256`. Empty means no algorithms are allowed. +- Field 4: the required claims, in the same comma joined style. + Empty means no claims are required. +- Field 5: the expected issuer. Empty means none. +- Field 6: the expected audience. Empty means none. + +The answer is `ok:` followed by the character codes of the +canonical reprinted token when the token is accepted, `none` +when it is not accepted, and `error` when the request itself +is malformed (wrong field count, a field that is not a code +list, a code that is not a Unicode scalar value, a current +time field that is not an optional minus sign plus at most 19 +digits, an unknown operation). diff --git a/lake-manifest.json b/lake-manifest.json new file mode 100644 index 0000000..c53f76e --- /dev/null +++ b/lake-manifest.json @@ -0,0 +1,6 @@ +{"version": "1.3.0", + "packagesDir": ".lake/packages", + "packages": [], + "name": "jws", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/lakefile.toml b/lakefile.toml new file mode 100644 index 0000000..a6ad22a --- /dev/null +++ b/lakefile.toml @@ -0,0 +1,16 @@ +name = "jws" +version = "0.1.0" +defaultTargets = ["Json", "Jws", "Proofs", "jws"] + +[[lean_lib]] +name = "Json" + +[[lean_lib]] +name = "Jws" + +[[lean_lib]] +name = "Proofs" + +[[lean_exe]] +name = "jws" +root = "Main" diff --git a/lean-toolchain b/lean-toolchain new file mode 100644 index 0000000..a6aff06 --- /dev/null +++ b/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.35.0 diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md new file mode 100644 index 0000000..86ca850 --- /dev/null +++ b/tests/TEST-RESULTS.md @@ -0,0 +1,47 @@ +# Test results: JWS compact parser and checker + +Run with `python3 tests/run_tests.py`. The record is +written by the run. Seed 20261026 for the random corpus. +The run happened on 2026-10-26/27. + +## The oracle and PyJWT + +PyJWT 2.15.1 was present (a venv inside the work directory, with cryptography). It built 20 tokens (HS256, RS256, ES256, and none algorithms). +Its unverified decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. +Verdicts are always the oracle's (SPEC.md rules reimplemented in the harness). PyJWT built well formed tokens and cross checked decoding only. + +## Counts + +- Total check and request inputs: 425 +- Agreements with the oracle: 425 +- Failures: 0 +- Of the verdict cases: 55 accepted, 360 rejected, 10 malformed requests answered error + +## RFC 7515 fixed cases + +The A.5 example is the RFC's unsecured (alg none) compact example; in the final RFC text A.6 and A.7 are JSON Serialization examples, not compact tokens, so the none cases use A.5. The RFC 7519 section 3.1 example token is byte for byte the A.1 token, so it is covered by the A.1 cases. + +- [ok] A.1 accept (HS256 allowed, now before exp, issuer joe) +- [ok] A.1 reject (alg not allowed) +- [ok] A.1 reject (now equal to exp) +- [ok] A.1 reject (now after exp) +- [ok] A.1 reject (wrong issuer) +- [ok] A.1 accept (required claims present) +- [ok] A.1 reject (required claim missing) +- [ok] A.1 accept (no time checks, issuer checked) +- [ok] A.2 accept (RS256 allowed, now before exp, issuer joe) +- [ok] A.2 reject (alg not allowed) +- [ok] A.3 accept (ES256 allowed, now before exp) +- [ok] A.3 reject (now equal to exp) +- [ok] A.5 reject (alg none, HS256 allowed) +- [ok] A.5 reject (alg none, allowlist names none) +- [ok] A.5 reject (alg none, allowlist names none and HS256) + +## Named differences and pinned readings + +- An empty signature part is well formed base64url, so a token with an empty third part can pass the checks. Only alg none itself is turned away, not the empty signature. +- Claim and header name lookup reads the first copy of a repeated name (the JSON code's List.lookup order). The duplicate iss and alg cases pin this. +- The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. +- Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. +- PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. + diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..51fae4e --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +"""Test harness for the JWS compact parser and checker. + +Three parts, matching the slice plan: + +1. Fixed cases: the RFC 7515 Appendix A examples (A.1 HS256, + A.2 RS256, A.3 ES256, A.5 the unsecured none example; the + RFC 7519 section 3.1 example token is byte for byte the A.1 + token, so the A.1 cases cover it), each with contexts that + accept and contexts that reject. + +2. Differential: tokens built with PyJWT (in the work dir venv) + when it is present, plus hand built tokens, each run under + several check contexts. Every verdict (and, on acceptance, + the canonical reprinted token) is compared against an oracle + that reimplements SPEC.md in Python. PyJWT also cross checks + the parsing: its own unverified decode of each well formed + token is compared with the oracle's parse. + +3. Mutations and malformed requests: padding added, characters + outside the alphabet, two or four parts, a header that is not + an object, alg missing, alg none with an empty signature, + exp equal to now, nbf equal to now, audience arrays that do + and do not hold the expected value, a fractional exp, and + more; plus request lines that must be answered with `error`. + +The oracle is the authority for verdicts. PyJWT never decides a +verdict here: it builds well formed tokens and cross checks +decoding, because its own checks differ from this slice's rules +(it verifies signatures and registered claims its own way when +asked, and accepts some framings this slice turns away). + +Run: python3 tests/run_tests.py (uses .lake/build/bin/jws and +the venv's python when present for the PyJWT corpus). +Writes tests/TEST-RESULTS.md with real counts. +""" + +import base64 +import json +import random +import re +import subprocess +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXE = ROOT / ".lake" / "build" / "bin" / "jws" +SEED = 20261026 + +# --------------------------------------------------------------- +# The oracle: SPEC.md reimplemented in Python. +# JSON values are tuples mirroring the Lean Json type: +# ('null',) ('bool', b) ('num', source text) ('str', s) +# ('arr', [v ...]) ('obj', [(k, v) ...]) pairs in source order +# --------------------------------------------------------------- + +ALPHABET = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + + +def b64url_decode_strict(part): + """Bytes for a base64url part under the strict rules, or None.""" + if any(ch not in ALPHABET for ch in part): + return None + if len(part) % 4 == 1: + return None + pad = "=" * (-len(part) % 4) + try: + raw = base64.urlsafe_b64decode(part + pad) + except Exception: + return None + # Re-encoding must reproduce the part exactly. This is what + # turns away nonzero unused bits in the final character. + if base64.urlsafe_b64encode(raw).rstrip(b"=").decode() != part: + return None + return raw + + +def b64url_encode(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +class BadJson(Exception): + pass + + +def _no_constant(x): + raise BadJson(x) + + +def _walk_check(v): + """Reject lone surrogates anywhere, as the Lean parser does.""" + if isinstance(v, tuple): + tag = v[0] + if tag == "str": + if any(0xD800 <= ord(ch) <= 0xDFFF for ch in v[1]): + raise BadJson("lone surrogate") + elif tag == "arr": + for item in v[1]: + _walk_check(item) + elif tag == "obj": + for k, item in v[1]: + if any(0xD800 <= ord(ch) <= 0xDFFF for ch in k): + raise BadJson("lone surrogate in key") + _walk_check(item) + + +def json_parse(text): + """Parse a JSON text into the tuple form, or None.""" + try: + val = json.loads( + text, + object_pairs_hook=lambda pairs: ("obj", list(pairs)), + parse_int=lambda s: ("num", s), + parse_float=lambda s: ("num", s), + parse_constant=_no_constant, + ) + except Exception: + return None + + def conv(v): + if isinstance(v, tuple): + if v[0] == "obj": + return ("obj", [(k, conv(x)) for (k, x) in v[1]]) + return v + if isinstance(v, list): + return ("arr", [conv(x) for x in v]) + if isinstance(v, str): + return ("str", v) + if isinstance(v, bool): + return ("bool", v) + if v is None: + return ("null",) + raise BadJson("unexpected") + + try: + out = conv(val) + _walk_check(out) + except BadJson: + return None + return out + + +HEX = "0123456789abcdef" + + +def json_print(v): + tag = v[0] + if tag == "null": + return "null" + if tag == "bool": + return "true" if v[1] else "false" + if tag == "num": + return v[1] + if tag == "str": + out = ['"'] + for ch in v[1]: + n = ord(ch) + if ch == '"': + out.append('\\"') + elif ch == "\\": + out.append("\\\\") + elif n == 8: + out.append("\\b") + elif n == 9: + out.append("\\t") + elif n == 10: + out.append("\\n") + elif n == 12: + out.append("\\f") + elif n == 13: + out.append("\\r") + elif n < 32: + out.append("\\u00" + HEX[n // 16] + HEX[n % 16]) + else: + out.append(ch) + out.append('"') + return "".join(out) + if tag == "arr": + return "[" + ",".join(json_print(x) for x in v[1]) + "]" + if tag == "obj": + return "{" + ",".join(json_print(("str", k)) + ":" + json_print(x) + for (k, x) in v[1]) + "}" + raise BadJson(tag) + + +def lookup(name, obj): + """First copy wins, as in the Lean code.""" + if obj[0] != "obj": + return None + for (k, v) in obj[1]: + if k == name: + return v + return None + + +NUM_RE = re.compile(r"^-?(0|[0-9]+)(\.[0-9]+)?([eE][+-]?[0-9]+)?$") + + +def date_claim(claims, name): + """'absent', 'bad', or a Decimal integer value.""" + v = lookup(name, claims) + if v is None: + return "absent" + if v[0] != "num" or not NUM_RE.match(v[1]): + return "bad" + d = Decimal(v[1]) + if d != d.to_integral_value(): + return "bad" + return d + + +def shape_ok(claims): + iss = lookup("iss", claims) + if iss is not None and iss[0] != "str": + return False + aud = lookup("aud", claims) + if aud is not None: + if aud[0] == "str": + pass + elif aud[0] == "arr" and all(x[0] == "str" for x in aud[1]): + pass + else: + return False + return True + + +def oracle_check(token_text, now, allow, required, issuer, audience): + """The canonical reprinted token when accepted, else None. + + now is None for no time comparisons. allow, required are + lists of names. issuer, audience are strings or None.""" + if "none" in allow: + return None + parts = token_text.split(".") + if len(parts) != 3: + return None + raws = [b64url_decode_strict(p) for p in parts] + if any(r is None for r in raws): + return None + try: + header_text = raws[0].decode("utf-8") + claims_text = raws[1].decode("utf-8") + except UnicodeDecodeError: + return None + header = json_parse(header_text) + claims = json_parse(claims_text) + if header is None or claims is None: + return None + if header[0] != "obj" or claims[0] != "obj": + return None + alg = lookup("alg", header) + if alg is None or alg[0] != "str": + return None + if alg[1] == "none" or alg[1] not in allow: + return None + for name in required: + if lookup(name, claims) is None: + return None + for name in ("exp", "nbf", "iat"): + if date_claim(claims, name) == "bad": + return None + if now is not None: + exp = date_claim(claims, "exp") + if exp not in ("absent", "bad") and not (Decimal(now) < exp): + return None + nbf = date_claim(claims, "nbf") + if nbf not in ("absent", "bad") and (Decimal(now) < nbf): + return None + if not shape_ok(claims): + return None + if issuer is not None: + iss = lookup("iss", claims) + if iss is None or iss[0] != "str" or iss[1] != issuer: + return None + if audience is not None: + aud = lookup("aud", claims) + if aud is None: + return None + if aud[0] == "str": + if aud[1] != audience: + return None + elif aud[0] == "arr": + if not any(x[0] == "str" and x[1] == audience for x in aud[1]): + return None + else: + return None + canon = ".".join([ + b64url_encode(json_print(header).encode("utf-8")), + b64url_encode(json_print(claims).encode("utf-8")), + b64url_encode(raws[2]), + ]) + return canon + + +# --------------------------------------------------------------- +# The Lean side: request encoding and the runner. +# --------------------------------------------------------------- + +def codes_of(text): + return ",".join(str(ord(ch)) for ch in text) + + +def check_request(token_text, now, allow, required, issuer, audience): + now_text = "" if now is None else str(now) + allow_text = ",".join(allow) + req_text = ",".join(required) + iss_text = "" if issuer is None else issuer + aud_text = "" if audience is None else audience + fields = [token_text, now_text, allow_text, req_text, iss_text, aud_text] + return "check " + ";".join(codes_of(f) for f in fields) + + +def run_lean(lines): + proc = subprocess.run([str(EXE)], input="\n".join(lines) + "\n", + capture_output=True, text=True, timeout=1200) + if proc.returncode != 0: + raise RuntimeError("lean exe failed: " + proc.stderr[:2000]) + return proc.stdout.splitlines() + + +def decode_answer(ans): + """'ok:' -> token text; 'none' -> None; 'error' -> 'error'.""" + if ans == "none": + return None + if ans == "error": + return "error" + if ans.startswith("ok:"): + body = ans[3:] + if body == "": + return "" + return "".join(chr(int(x)) for x in body.split(",")) + raise ValueError("bad answer: " + ans[:80]) + + +# --------------------------------------------------------------- +# Case collection. Each entry: (name, request line, expected) +# where expected is a token text (accepted, canonical), None +# (rejected), or "error" (malformed request). +# --------------------------------------------------------------- + +CASES = [] + + +def add_check(name, token, now, allow, required, issuer, audience): + want = oracle_check(token, now, allow, required, issuer, audience) + CASES.append((name, check_request(token, now, allow, required, issuer, + audience), want)) + + +def add_raw(name, line, want): + CASES.append((name, line, want)) + + +# --- RFC 7515 Appendix A ------------------------------------- + +PAYLOAD_B64 = ("eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9" + "leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ") +A1 = ("eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9." + PAYLOAD_B64 + + ".dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") +A2 = ("eyJhbGciOiJSUzI1NiJ9." + PAYLOAD_B64 + + ".cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZmh7AAuH" + "Im4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjbKBYNX4BAynRFdiu" + "B--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHlb1L07Qe7K0GarZRmB_eSN93" + "83LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZESc6BfI7noOPqvhJ1phCnvWh6IeYI2w9Q" + "OYEUipUTI8np6LbgGY9Fs98rqVt5AXLIhWkWywlVmtVrBp0igcN_IoypGlUPQGe77Rw") +A3 = ("eyJhbGciOiJFUzI1NiJ9." + PAYLOAD_B64 + + ".DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQ" + "xfKTUJqPP3-Kg6NU1Q") +A5 = "eyJhbGciOiJub25lIn0." + PAYLOAD_B64 + "." + +add_check("A.1 accept (HS256 allowed, now before exp, issuer joe)", + A1, 1300819000, ["HS256"], [], "joe", None) +add_check("A.1 reject (alg not allowed)", A1, 1300819000, ["RS256"], [], + None, None) +add_check("A.1 reject (now equal to exp)", A1, 1300819380, ["HS256"], [], + None, None) +add_check("A.1 reject (now after exp)", A1, 1400000000, ["HS256"], [], + None, None) +add_check("A.1 reject (wrong issuer)", A1, 1300819000, ["HS256"], [], + "bob", None) +add_check("A.1 accept (required claims present)", A1, 1300819000, + ["HS256"], ["exp", "iss"], None, None) +add_check("A.1 reject (required claim missing)", A1, 1300819000, + ["HS256"], ["sub"], None, None) +add_check("A.1 accept (no time checks, issuer checked)", A1, None, + ["HS256"], [], "joe", None) +add_check("A.2 accept (RS256 allowed, now before exp, issuer joe)", + A2, 1300819000, ["RS256"], [], "joe", None) +add_check("A.2 reject (alg not allowed)", A2, 1300819000, ["HS256"], [], + None, None) +add_check("A.3 accept (ES256 allowed, now before exp)", A3, 1300819000, + ["ES256"], [], None, None) +add_check("A.3 reject (now equal to exp)", A3, 1300819380, ["ES256"], [], + None, None) +add_check("A.5 reject (alg none, HS256 allowed)", A5, 1300819000, + ["HS256"], [], None, None) +add_check("A.5 reject (alg none, allowlist names none)", A5, 1300819000, + ["none"], [], None, None) +add_check("A.5 reject (alg none, allowlist names none and HS256)", + A5, 1300819000, ["none", "HS256"], [], None, None) +add_check("HS256 token reject (allowlist names none at all)", A1, + 1300819000, ["none", "HS256"], [], None, None) + +# --- Hand built tokens for mutations -------------------------- + +def hand_token(header_bytes, payload_bytes, sig_bytes): + return ".".join([b64url_encode(header_bytes), + b64url_encode(payload_bytes), + b64url_encode(sig_bytes)]) + + +GOOD_H = b'{"alg":"HS256"}' +GOOD_P = b'{"iss":"joe","exp":2000000000}' +SIG = bytes(range(32)) +BASE = hand_token(GOOD_H, GOOD_P, SIG) +NOW = 1500000000 + +add_check("base accept", BASE, NOW, ["HS256"], [], "joe", None) + +parts = BASE.split(".") +add_check("mutation: padding on header part", + parts[0] + "=." + parts[1] + "." + parts[2], + NOW, ["HS256"], [], None, None) +add_check("mutation: padding on payload part", + parts[0] + "." + parts[1] + "=." + parts[2], + NOW, ["HS256"], [], None, None) +add_check("mutation: padding on signature part", + parts[0] + "." + parts[1] + "." + parts[2] + "=", + NOW, ["HS256"], [], None, None) +add_check("mutation: plus character in signature", + parts[0] + "." + parts[1] + "." + parts[2][:-1] + "+", + NOW, ["HS256"], [], None, None) +add_check("mutation: slash character in payload", + parts[0] + "." + "/" + parts[1][1:] + "." + parts[2], + NOW, ["HS256"], [], None, None) +add_check("mutation: star character in header", + "*" + parts[0][1:] + "." + parts[1] + "." + parts[2], + NOW, ["HS256"], [], None, None) +add_check("mutation: two parts only", parts[0] + "." + parts[1], + NOW, ["HS256"], [], None, None) +add_check("mutation: four parts", BASE + ".AAAA", + NOW, ["HS256"], [], None, None) +add_check("mutation: empty token", "", NOW, ["HS256"], [], None, None) +add_check("mutation: two dots only", "..", NOW, ["HS256"], [], None, None) +add_check("mutation: header part length 1 mod 4", + "AAAAA." + parts[1] + "." + parts[2], + NOW, ["HS256"], [], None, None) +add_check("mutation: signature part length 1 mod 4", + parts[0] + "." + parts[1] + ".AAAAA", + NOW, ["HS256"], [], None, None) +# signature 'AR': last character value 17, unused bits nonzero in a +# two character (one byte) final group. +add_check("mutation: signature unused bits set", + parts[0] + "." + parts[1] + ".AR", + NOW, ["HS256"], [], None, None) +add_check("empty signature part is well formed (accepted)", + parts[0] + "." + parts[1] + ".", + NOW, ["HS256"], [], "joe", None) +add_check("one byte signature (accepted)", + hand_token(GOOD_H, GOOD_P, b"\x01"), + NOW, ["HS256"], [], None, None) + +add_check("mutation: header is an array", + hand_token(b'["HS256"]', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: header is a string", + hand_token(b'"HS256"', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: payload is a number", + hand_token(GOOD_H, b'42', SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: payload is not JSON", + hand_token(GOOD_H, b'{oops', SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: header is not UTF-8", + hand_token(b'\xff\xfe{"alg":"HS256"}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: alg missing", + hand_token(b'{"typ":"JWT"}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: alg not a string", + hand_token(b'{"alg":256}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("mutation: alg none with a signature present", + hand_token(b'{"alg":"none"}', GOOD_P, SIG), + NOW, ["HS256", "none"], [], None, None) +add_check("alg None capital (case differs, accepted when allowed)", + hand_token(b'{"alg":"None"}', GOOD_P, SIG), + NOW, ["None"], [], None, None) + +def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): + return hand_token(header_bytes, claims_bytes, sig) + +add_check("mutation: exp equal to now (reject)", + claims_token(b'{"exp":1500000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: exp one second after now (accept)", + claims_token(b'{"exp":1500000001}'), + NOW, ["HS256"], [], None, None) +add_check("nbf equal to now (accept)", + claims_token(b'{"nbf":1500000000,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: nbf one second after now (reject)", + claims_token(b'{"nbf":1500000001,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: fractional exp (reject)", + claims_token(b'{"exp":2000000000.5}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: fractional exp with no time checks (reject)", + claims_token(b'{"exp":2000000000.5}'), + None, ["HS256"], [], None, None) +add_check("mutation: exp as a string (reject)", + claims_token(b'{"exp":"2000000000"}'), + NOW, ["HS256"], [], None, None) +add_check("exp in exponent form, same value (accept)", + claims_token(b'{"exp":2.0e9}'), + NOW, ["HS256"], [], None, None) +add_check("exp with scaled exponent (accept)", + claims_token(b'{"exp":20000000000e-1}'), + NOW, ["HS256"], [], None, None) +add_check("exp huge exponent text (accept, no bulk)", + claims_token(b'{"exp":1e999999999}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: negative exp, now zero (reject)", + claims_token(b'{"exp":-1}'), + 0, ["HS256"], [], None, None) +add_check("negative now before a small exp (accept)", + claims_token(b'{"exp":100}'), + -5, ["HS256"], [], None, None) +add_check("mutation: fractional iat (reject)", + claims_token(b'{"iat":1.5,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("iat whole in exponent form (accept)", + claims_token(b'{"iat":1.5e9,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: nbf as a boolean (reject)", + claims_token(b'{"nbf":true,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: iss a number (reject)", + claims_token(b'{"iss":7,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: aud a number (reject)", + claims_token(b'{"aud":7,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: aud array with a number inside (reject)", + claims_token(b'{"aud":["api",7],"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("aud array containing the expected value (accept)", + claims_token(b'{"aud":["api","web"],"exp":2000000000}'), + NOW, ["HS256"], [], None, "web") +add_check("mutation: aud array missing the expected value (reject)", + claims_token(b'{"aud":["api","web"],"exp":2000000000}'), + NOW, ["HS256"], [], None, "mobile") +add_check("aud string equal to the expected value (accept)", + claims_token(b'{"aud":"api","exp":2000000000}'), + NOW, ["HS256"], [], None, "api") +add_check("mutation: expected audience, aud missing (reject)", + claims_token(b'{"exp":2000000000}'), + NOW, ["HS256"], [], None, "api") +add_check("mutation: expected issuer, iss missing (reject)", + claims_token(b'{"exp":2000000000}'), + NOW, ["HS256"], [], "joe", None) +add_check("required claim of number type still counts as present", + claims_token(b'{"foo":42,"exp":2000000000}'), + NOW, ["HS256"], ["foo"], None, None) +add_check("duplicate iss: the first copy is the one read (accept joe)", + claims_token(b'{"iss":"joe","iss":"bob","exp":2000000000}'), + NOW, ["HS256"], [], "joe", None) +add_check("duplicate iss: the second copy is not read (reject bob)", + claims_token(b'{"iss":"joe","iss":"bob","exp":2000000000}'), + NOW, ["HS256"], [], "bob", None) +add_check("duplicate alg: the first copy is the one read", + hand_token(b'{"alg":"HS256","alg":"none"}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("payload whitespace and key order kept in the reprint", + claims_token(b'{ "exp" : 2000000000 , "iss" : "joe" }'), + NOW, ["HS256"], [], "joe", None) + +# Unicode handling in strings. +add_check("iss with a raw UTF-8 character (accept)", + claims_token('{"iss":"jé","exp":2000000000}'.encode("utf-8")), + NOW, ["HS256"], [], "jé", None) +add_check("iss with an escaped character, same value (accept)", + claims_token(b'{"iss":"j\\u00e9","exp":2000000000}'), + NOW, ["HS256"], [], "jé", None) +add_check("iss with a surrogate pair escape (accept)", + claims_token(b'{"iss":"\\uD83D\\uDE00","exp":2000000000}'), + NOW, ["HS256"], [], "\U0001F600", None) +add_check("mutation: iss with a lone surrogate escape (reject)", + claims_token(b'{"iss":"\\uD83D","exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("mutation: payload with an overlong UTF-8 lead (reject)", + hand_token(GOOD_H, b'{"iss":"j\xc0\xaf","exp":2000000000}', SIG), + NOW, ["HS256"], [], None, None) + +# --- Malformed requests --------------------------------------- + +add_raw("malformed: unknown operation", "peek " + codes_of(BASE), "error") +add_raw("malformed: five fields", + "check " + ";".join([codes_of(BASE), codes_of("1500000000"), + codes_of("HS256"), "", ""]), "error") +add_raw("malformed: seven fields", + "check " + ";".join([codes_of(BASE), codes_of("1500000000"), + codes_of("HS256"), "", "", "", ""]), "error") +add_raw("malformed: a field that is not numbers", + "check " + ";".join(["1,2,x", "", "", "", "", ""]), "error") +add_raw("malformed: a code above the scalar range", + "check " + ";".join([codes_of(BASE), "1114112", "", "", "", ""]), + "error") +add_raw("malformed: a surrogate code in the token field", + "check " + ";".join(["55296", "", "", "", "", ""]), "error") +add_raw("malformed: now field with letters", + "check " + ";".join([codes_of(BASE), codes_of("12a3"), + codes_of("HS256"), "", "", ""]), "error") +add_raw("malformed: now field over 19 digits", + "check " + ";".join([codes_of(BASE), codes_of("99999999999999999999"), + codes_of("HS256"), "", "", ""]), "error") +add_raw("malformed: empty line", "", "error") +add_raw("malformed: check with no payload", "check", "error") +add_check("now field with a minus sign and digits (read as negative)", + claims_token(b'{"exp":100,"nbf":-50}'), + -5, ["HS256"], [], None, None) + +# --------------------------------------------------------------- +# PyJWT corpus: tokens built by PyJWT when present, contexts +# varied. Verdicts still come from the oracle. PyJWT decode +# (signature check off) cross checks the oracle's parse. +# --------------------------------------------------------------- + +PYJWT_INFO = {"present": False, "version": None, "tokens": 0, + "decode_comparisons": 0, "decode_agreements": 0, + "decode_differences": []} + + +def build_pyjwt_cases(): + venv_python = ROOT / "venv" / "bin" / "python" + if not venv_python.exists(): + return + script = r''' +import json, sys +import jwt +from jwt import __version__ as v +from cryptography.hazmat.primitives.asymmetric import rsa, ec + +out = {"version": v, "tokens": []} +rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +ec_key = ec.generate_private_key(ec.SECP256R1()) + +payloads = [ + {"iss": "joe", "exp": 2000000000, "nbf": 1400000000, "iat": 1400000000, + "aud": "api", "sub": "user-1"}, + {"iss": "issuer.example", "exp": 2000000000, "aud": ["api", "web"]}, + {"exp": 2000000000}, + {"iss": "joe", "exp": 1200000000, "aud": "api"}, + {"iss": "jé", "exp": 2000000000, "name": "José"}, + {"iss": "joe", "exp": 2000000000, "nbf": 1600000000}, + {"iss": "joe", "exp": 9999999999, "aud": ["a", "b", "c"], "admin": True}, + {"iss": "joe", "exp": 2000000000, "scope": ["read", "write"], "n": 42}, +] +builders = [] +for p in payloads: + builders.append(("HS256", jwt.encode(p, "secret", algorithm="HS256"))) + builders.append(("none", jwt.encode(p, "", algorithm="none"))) +builders.append(("RS256", jwt.encode(payloads[0], rsa_key, algorithm="RS256"))) +builders.append(("RS256", jwt.encode(payloads[1], rsa_key, algorithm="RS256"))) +builders.append(("ES256", jwt.encode(payloads[0], ec_key, algorithm="ES256"))) +builders.append(("ES256", jwt.encode(payloads[6], ec_key, algorithm="ES256"))) +for alg, tok in builders: + decoded = jwt.decode(tok, options={"verify_signature": False}) + header = jwt.get_unverified_header(tok) + out["tokens"].append({"alg": alg, "token": tok, + "header": header, "claims": decoded}) +print(json.dumps(out)) +''' + proc = subprocess.run([str(venv_python), "-c", script], + capture_output=True, text=True, timeout=300) + if proc.returncode != 0: + PYJWT_INFO["error"] = proc.stderr[-500:] + return + data = json.loads(proc.stdout) + PYJWT_INFO["present"] = True + PYJWT_INFO["version"] = data["version"] + random.seed(SEED) + contexts = [ + ("right alg, inside validity", lambda alg: (1500000000, [alg], [], + None, None)), + ("wrong alg", lambda alg: (1500000000, ["HS256"] if alg != "HS256" + else ["RS256"], [], None, None)), + ("issuer and audience set", lambda alg: (1500000000, [alg], [], + "joe", "api")), + ("after the common exp", lambda alg: (2100000000, [alg], [], + None, None)), + ("no time checks", lambda alg: (None, [alg], [], None, None)), + ] + for item in data["tokens"]: + tok = item["token"] + PYJWT_INFO["tokens"] += 1 + # Cross check the oracle parse against PyJWT's decode. + parts = tok.split(".") + header = json_parse(b64url_decode_strict(parts[0]).decode("utf-8")) + claims = json_parse(b64url_decode_strict(parts[1]).decode("utf-8")) + + def plain(v): + if v[0] == "obj": + return {k: plain(x) for (k, x) in v[1]} + if v[0] == "arr": + return [plain(x) for x in v[1]] + if v[0] == "num": + d = Decimal(v[1]) + return int(d) if d == d.to_integral_value() else float(d) + return v[1] + + PYJWT_INFO["decode_comparisons"] += 1 + if plain(header) == item["header"] and plain(claims) == item["claims"]: + PYJWT_INFO["decode_agreements"] += 1 + else: + PYJWT_INFO["decode_differences"].append(tok[:40]) + for label, make in contexts: + now, allow, required, issuer, audience = make(item["alg"]) + if item["alg"] == "none": + allow = ["none"] if "wrong" not in label else ["HS256"] + add_check("pyjwt %s token, %s" % (item["alg"], label), + tok, now, allow, required, issuer, audience) + + +# --------------------------------------------------------------- +# Random differential corpus (oracle builds and settles all). +# --------------------------------------------------------------- + +def build_random_cases(count=240): + rng = random.Random(SEED) + algs = ["HS256", "HS384", "RS256", "ES256", "PS256", "EdDSA", "none", + "None", "hs256"] + issuers = ["joe", "issuer.example", "jé", "api-issuer"] + auds = ["api", "web", "mobile"] + + def rand_claims(): + claims = {} + if rng.random() < 0.8: + claims["iss"] = rng.choice(issuers) + if rng.random() < 0.8: + claims["exp"] = rng.choice( + [1400000000, 1500000000, 1600000000, 2000000000, 9999999999]) + if rng.random() < 0.4: + claims["nbf"] = rng.choice([1400000000, 1500000000, 1600000000]) + if rng.random() < 0.4: + claims["iat"] = rng.choice([1400000000, 1500000000]) + if rng.random() < 0.6: + claims["aud"] = (rng.choice(auds) if rng.random() < 0.5 + else rng.sample(auds, rng.randint(1, 3))) + if rng.random() < 0.3: + claims["sub"] = "user-%d" % rng.randint(1, 99) + return claims + + for i in range(count): + alg = rng.choice(algs) + header = {"alg": alg} + if rng.random() < 0.4: + header["typ"] = "JWT" + if rng.random() < 0.2: + header["kid"] = "key-%d" % rng.randint(1, 5) + claims = rand_claims() + sig = bytes(rng.randrange(256) for _ in range(rng.choice( + [0, 1, 16, 32, 64]))) + tok = hand_token( + json.dumps(header, separators=(",", ":")).encode("utf-8"), + json.dumps(claims, separators=(",", ":"), + ensure_ascii=False).encode("utf-8"), + sig) + now = rng.choice([None, 1450000000, 1500000000, 1550000000, + 1700000000, 2100000000]) + allow = rng.sample(algs, rng.randint(1, 3)) + required = rng.sample(["iss", "exp", "aud", "sub", "nbf"], + rng.randint(0, 2)) + issuer = rng.choice([None, "joe", "issuer.example", "bob"]) + audience = rng.choice([None, "api", "web", "desktop"]) + add_check("random %d (alg %s)" % (i, alg), tok, now, allow, + required, issuer, audience) + + +# --------------------------------------------------------------- +# Run everything and write the record. +# --------------------------------------------------------------- + +def main(): + if not EXE.exists(): + print("executable missing; run lake build first", file=sys.stderr) + return 1 + build_pyjwt_cases() + build_random_cases() + lines = [req for (_, req, _) in CASES] + answers = run_lean(lines) + if len(answers) != len(CASES): + print("answer count mismatch: %d vs %d" % (len(answers), len(CASES)), + file=sys.stderr) + return 1 + failures = [] + accepted = 0 + rejected = 0 + errors = 0 + for (name, _, want), ans in zip(CASES, answers): + try: + got = decode_answer(ans) + except ValueError: + failures.append((name, "unreadable answer " + ans[:60], want)) + continue + if want == "error": + errors += 1 + ok = got == "error" + elif want is None: + rejected += 1 + ok = got is None + else: + accepted += 1 + ok = got == want + if not ok: + failures.append((name, got, want)) + + fixed = [c for c in CASES if c[0].startswith("A.")] + fixed_fail = [f for f in failures if f[0].startswith("A.")] + + lines_out = [] + lines_out.append("# Test results: JWS compact parser and checker") + lines_out.append("") + lines_out.append("Run with `python3 tests/run_tests.py`. The record is") + lines_out.append("written by the run. Seed %d for the random corpus." + % SEED) + lines_out.append("The run happened on 2026-10-26/27.") + lines_out.append("") + lines_out.append("## The oracle and PyJWT") + lines_out.append("") + if PYJWT_INFO["present"]: + lines_out.append("PyJWT %s was present (a venv inside the work" + " directory, with cryptography). It built %d tokens" + " (HS256, RS256, ES256, and none algorithms)." + % (PYJWT_INFO["version"], PYJWT_INFO["tokens"])) + lines_out.append("Its unverified decode was compared with the oracle's" + " parse on %d tokens: %d agreements, %d differences." + % (PYJWT_INFO["decode_comparisons"], + PYJWT_INFO["decode_agreements"], + len(PYJWT_INFO["decode_differences"]))) + elif "error" in PYJWT_INFO: + lines_out.append("PyJWT was attempted in the work directory venv but" + " the builder failed; the oracle built and settled" + " every corpus token. Error: " + + PYJWT_INFO["error"].replace("\n", " ")) + else: + lines_out.append("PyJWT was not present. The oracle built and" + " settled every corpus token.") + lines_out.append("Verdicts are always the oracle's (SPEC.md rules" + " reimplemented in the harness). PyJWT built well" + " formed tokens and cross checked decoding only.") + lines_out.append("") + lines_out.append("## Counts") + lines_out.append("") + lines_out.append("- Total check and request inputs: %d" % len(CASES)) + lines_out.append("- Agreements with the oracle: %d" + % (len(CASES) - len(failures))) + lines_out.append("- Failures: %d" % len(failures)) + lines_out.append("- Of the verdict cases: %d accepted, %d rejected," + " %d malformed requests answered error" + % (accepted, rejected, errors)) + lines_out.append("") + lines_out.append("## RFC 7515 fixed cases") + lines_out.append("") + lines_out.append("The A.5 example is the RFC's unsecured (alg none)" + " compact example; in the final RFC text A.6 and A.7" + " are JSON Serialization examples, not compact" + " tokens, so the none cases use A.5. The RFC 7519" + " section 3.1 example token is byte for byte the" + " A.1 token, so it is covered by the A.1 cases.") + lines_out.append("") + for (name, _, _) in fixed: + mark = "FAIL" if any(f[0] == name for f in fixed_fail) else "ok" + lines_out.append("- [%s] %s" % (mark, name)) + lines_out.append("") + lines_out.append("## Named differences and pinned readings") + lines_out.append("") + lines_out.append("- An empty signature part is well formed base64url," + " so a token with an empty third part can pass the" + " checks. Only alg none itself is turned away, not" + " the empty signature.") + lines_out.append("- Claim and header name lookup reads the first copy" + " of a repeated name (the JSON code's List.lookup" + " order). The duplicate iss and alg cases pin this.") + lines_out.append("- The none rule is the exact name `none`. An alg of" + " `None` is a different name and can be allowed.") + lines_out.append("- Date claims are judged by value, not by spelling:" + " `2.0e9` and `20000000000e-1` are the whole number" + " 2000000000, and `1e999999999` compares without" + " ever being written out.") + lines_out.append("- PyJWT decode differences (its parser against the" + " oracle's on well formed tokens): %d." + % len(PYJWT_INFO["decode_differences"])) + if failures: + lines_out.append("") + lines_out.append("## Failures") + lines_out.append("") + for (name, got, want) in failures: + lines_out.append("- %s: got %r, wanted %r" % (name, got, want)) + lines_out.append("") + text = "\n".join(lines_out) + (ROOT / "tests" / "TEST-RESULTS.md").write_text(text + "\n") + print("cases: %d, agreements: %d, failures: %d" + % (len(CASES), len(CASES) - len(failures), len(failures))) + print("accepted: %d, rejected: %d, error answers: %d" + % (accepted, rejected, errors)) + print("pyjwt present: %s, decode comparisons: %d, agreements: %d" + % (PYJWT_INFO["present"], PYJWT_INFO["decode_comparisons"], + PYJWT_INFO["decode_agreements"])) + for f in failures[:20]: + print("FAIL:", f) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 593f7d73733a3a22ae17bc03ff75d6525bfc064b Mon Sep 17 00:00:00 2001 From: shah Date: Tue, 27 Oct 2026 19:48:29 -0500 Subject: [PATCH 02/23] Reject an empty signature part at the check, per review --- Jws/Token.lean | 1 + README.md | 2 +- SPEC.md | 50 ++++++++++++++++++++++++------------------- tests/TEST-RESULTS.md | 9 ++++---- tests/run_tests.py | 34 +++++++++++++++++++++++------ 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index 9a222cc..27c8f34 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -257,6 +257,7 @@ def accept (t : Token) (ctx : Context) : Bool := match lookup "alg".toList t.header with | some (.str alg) => alg != "none".toList && + !t.sig.isEmpty && ctx.allow.contains alg && ctx.required.all (fun n => (lookup n t.claims).isSome) && (match dateClaim "exp".toList t.claims with diff --git a/README.md b/README.md index b8a3dc2..2947a8b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The two base64url round trips check against `propext` and `Quot.sound` only. The Two readings worth naming, both stated in `SPEC.md` and pinned by tests: -- An empty signature part is well formed, so it can pass the checks. Only the algorithm name `none` is turned away outright, together with any context whose allowlist names `none`. +- 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 `none` is turned away outright, together with any context whose allowlist names `none`, so the none case never reaches acceptance. - When a JSON object repeats a name, lookup reads the first copy, as the JSON code's lookup does. ## What you must trust diff --git a/SPEC.md b/SPEC.md index ef172de..cdffc50 100644 --- a/SPEC.md +++ b/SPEC.md @@ -30,7 +30,8 @@ Exactly three parts. Two parts or four parts are turned away. An empty part is allowed by the framing (the empty text is well formed base64url for zero bytes); the checks below decide whether what it decodes to is acceptable. In particular an -empty signature part is well formed. +empty signature part is well formed base64url, and rule 2 +below turns it away at the check. ## Base64url, strict @@ -76,22 +77,27 @@ The token is accepted only when every rule holds: 1. The framing is exactly three parts, each strict base64url (above). The signature bytes are returned unchanged; their - content is not examined. -2. The header decodes to a JSON object. -3. The payload decodes to a JSON object (the claims set). -4. The header carries `alg`, and its value is a string. -5. `alg` is not `none`. This holds whatever the allowlist says. + content is not examined beyond the emptiness rule below. +2. An empty signature part is always turned away. RFC 7515 + never produces an empty signature for a signing algorithm, + and the one algorithm that uses no signature, `none`, is + itself turned away by rule 6, so the none case never + reaches acceptance. +3. The header decodes to a JSON object. +4. The payload decodes to a JSON object (the claims set). +5. The header carries `alg`, and its value is a string. +6. `alg` is not `none`. This holds whatever the allowlist says. As a companion rule, an allowlist that itself names `none` is an error at the check level: such a context rejects every token. (Asking to allow `none` is asking for unsecured tokens; this slice does not accept them under any context.) -6. `alg` is on the allowlist. Names are compared exactly, +7. `alg` is on the allowlist. Names are compared exactly, character for character: `None` and `hs256` are different names from `none` and `HS256`. -7. Every required claim is present in the claims set. Presence +8. Every required claim is present in the claims set. Presence is all this rule asks; the value's type does not matter for this rule. -8. When `exp`, `nbf`, or `iat` is present, its value must be a +9. When `exp`, `nbf`, or `iat` is present, its value must be a JSON number whose value is a whole number of seconds. A non-number, or a number with a fractional part (for example `2000000000.5`), turns the token away. This type rule holds @@ -99,25 +105,25 @@ The token is accepted only when every rule holds: as an exact decimal, not by its spelling: `2.0e9` is the whole number 2000000000, and a value like `1e999999999` is compared exactly, without ever being written out in full. -9. When `exp` is present and a current time is supplied, the - current time must be before `exp`. A current time equal to - `exp` rejects (the token is expired at that second). -10. When `nbf` is present and a current time is supplied, the +10. When `exp` is present and a current time is supplied, the + current time must be before `exp`. A current time equal to + `exp` rejects (the token is expired at that second). +11. When `nbf` is present and a current time is supplied, the current time must be at or after `nbf`. A current time equal to `nbf` accepts. -11. `iat` carries no comparison rule in this slice; only the - type rule (rule 8) applies to it. -12. When `iss` is present, its value must be a string. -13. When `aud` is present, its value must be a string or an +12. `iat` carries no comparison rule in this slice; only the + type rule (rule 9) applies to it. +13. When `iss` is present, its value must be a string. +14. When `aud` is present, its value must be a string or an array of strings (every member a string). -14. When an expected issuer is given, `iss` must be present +15. When an expected issuer is given, `iss` must be present and exactly equal to it. A missing `iss` rejects. -15. When an expected audience is given, `aud` must be that +16. When an expected audience is given, `aud` must be that string, or an array holding that string as a member. A missing `aud` rejects. -When no current time is supplied, rules 9 and 10 are skipped; -every other rule, including the type rule 8, still applies. +When no current time is supplied, rules 10 and 11 are skipped; +every other rule, including the type rule 9, still applies. Names are read from objects with the JSON code's lookup: when a name appears more than once in an object, the first copy is the @@ -160,7 +166,7 @@ separated decimal form as the payload: - Field 1: the token text. - Field 2: the current time as decimal digits, with an optional leading minus sign, at most 19 digits. Empty means no current - time (rules 9 and 10 skipped). + time (rules 10 and 11 skipped). - Field 3: the allowlist. The field text is the algorithm names joined by commas inside the text itself, for example the text `HS256,RS256`. Empty means no algorithms are allowed. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 86ca850..0ed6e30 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -12,10 +12,10 @@ Verdicts are always the oracle's (SPEC.md rules reimplemented in the harness). P ## Counts -- Total check and request inputs: 425 -- Agreements with the oracle: 425 +- Total check and request inputs: 426 +- Agreements with the oracle: 426 - Failures: 0 -- Of the verdict cases: 55 accepted, 360 rejected, 10 malformed requests answered error +- Of the verdict cases: 53 accepted, 363 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases @@ -29,6 +29,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - [ok] A.1 accept (required claims present) - [ok] A.1 reject (required claim missing) - [ok] A.1 accept (no time checks, issuer checked) +- [ok] A.1 reject (signature part emptied, otherwise passing context) - [ok] A.2 accept (RS256 allowed, now before exp, issuer joe) - [ok] A.2 reject (alg not allowed) - [ok] A.3 accept (ES256 allowed, now before exp) @@ -39,7 +40,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final ## Named differences and pinned readings -- An empty signature part is well formed base64url, so a token with an empty third part can pass the checks. Only alg none itself is turned away, not the empty signature. +- Named change from the 2026-10-27 review: an empty signature part is now always turned away (SPEC rule 2). RFC 7515 never produces an empty signature for a signing algorithm, and alg none is turned away on its own, so the none case never reaches acceptance. The earlier reading accepted an empty signature part for a signing algorithm. The fixed case that pinned that reading now expects rejection, a new fixed case empties the A.1 token's signature part under an otherwise passing context and expects rejection, and generated corpus tokens with empty signature parts follow the same new rule in the oracle. - Claim and header name lookup reads the first copy of a repeated name (the JSON code's List.lookup order). The duplicate iss and alg cases pin this. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. diff --git a/tests/run_tests.py b/tests/run_tests.py index 51fae4e..c22d2e1 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -7,7 +7,9 @@ A.2 RS256, A.3 ES256, A.5 the unsecured none example; the RFC 7519 section 3.1 example token is byte for byte the A.1 token, so the A.1 cases cover it), each with contexts that - accept and contexts that reject. + accept and contexts that reject. The A.1 token with its + signature part emptied, under an otherwise passing context, + is a fixed reject case. 2. Differential: tokens built with PyJWT (in the work dir venv) when it is present, plus hand built tokens, each run under @@ -238,6 +240,11 @@ def oracle_check(token_text, now, allow, required, issuer, audience): raws = [b64url_decode_strict(p) for p in parts] if any(r is None for r in raws): return None + if len(raws[2]) == 0: + # An empty signature part is always turned away (SPEC + # rule 2): no signing algorithm produces one, and alg + # none is turned away on its own below. + return None try: header_text = raws[0].decode("utf-8") claims_text = raws[1].decode("utf-8") @@ -385,6 +392,10 @@ def add_raw(name, line, want): ["HS256"], ["sub"], None, None) add_check("A.1 accept (no time checks, issuer checked)", A1, None, ["HS256"], [], "joe", None) +A1_EMPTY_SIG = A1.rsplit(".", 1)[0] + "." +add_check("A.1 reject (signature part emptied, otherwise passing" + " context)", A1_EMPTY_SIG, 1300819000, ["HS256"], [], + "joe", None) add_check("A.2 accept (RS256 allowed, now before exp, issuer joe)", A2, 1300819000, ["RS256"], [], "joe", None) add_check("A.2 reject (alg not allowed)", A2, 1300819000, ["HS256"], [], @@ -454,7 +465,9 @@ def hand_token(header_bytes, payload_bytes, sig_bytes): add_check("mutation: signature unused bits set", parts[0] + "." + parts[1] + ".AR", NOW, ["HS256"], [], None, None) -add_check("empty signature part is well formed (accepted)", +add_check("empty signature part (reject; this case pinned the" + " earlier reading as an accept, flipped by the" + " 2026-10-27 review change)", parts[0] + "." + parts[1] + ".", NOW, ["HS256"], [], "joe", None) add_check("one byte signature (accepted)", @@ -877,10 +890,19 @@ def main(): lines_out.append("") lines_out.append("## Named differences and pinned readings") lines_out.append("") - lines_out.append("- An empty signature part is well formed base64url," - " so a token with an empty third part can pass the" - " checks. Only alg none itself is turned away, not" - " the empty signature.") + lines_out.append("- Named change from the 2026-10-27 review: an" + " empty signature part is now always turned away" + " (SPEC rule 2). RFC 7515 never produces an empty" + " signature for a signing algorithm, and alg none" + " is turned away on its own, so the none case" + " never reaches acceptance. The earlier reading" + " accepted an empty signature part for a signing" + " algorithm. The fixed case that pinned that" + " reading now expects rejection, a new fixed case" + " empties the A.1 token's signature part under an" + " otherwise passing context and expects rejection," + " and generated corpus tokens with empty signature" + " parts follow the same new rule in the oracle.") lines_out.append("- Claim and header name lookup reads the first copy" " of a repeated name (the JSON code's List.lookup" " order). The duplicate iss and alg cases pin this.") From a1c8467fb2be646e7c26e987a357f76b854f4ff1 Mon Sep 17 00:00:00 2001 From: shah Date: Tue, 27 Oct 2026 20:38:48 -0500 Subject: [PATCH 03/23] Add the bar file set: Spec.lean, comparator.json, bar.toml, CI workflow --- .github/workflows/bar.yml | 21 ++++ Proofs.lean | 1 + Proofs/SpecProofs.lean | 114 ++++++++++++++++++ README.md | 240 +++++++++++++++++++++++++++++--------- SPEC.md | 24 ++++ Spec.lean | 159 +++++++++++++++++++++++++ bar.toml | 62 ++++++++++ comparator.json | 1 + lakefile.toml | 3 + 9 files changed, 571 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/bar.yml create mode 100644 Proofs/SpecProofs.lean create mode 100644 Spec.lean create mode 100644 bar.toml create mode 100644 comparator.json diff --git a/.github/workflows/bar.yml b/.github/workflows/bar.yml new file mode 100644 index 0000000..32457ed --- /dev/null +++ b/.github/workflows/bar.yml @@ -0,0 +1,21 @@ +name: verified bar + +on: + push: + pull_request: + # Rerun when the spec reviewer approves, or an approval is dismissed. + pull_request_review: + types: [submitted, dismissed] + +jobs: + bar: + # pull-requests: read lets the check confirm the spec approval on GitHub. + permissions: + contents: read + pull-requests: read + # Both lines pin the same commit of Proven-Commons/mission: bar v2, as + # proposed in its pull request #2. Move both to a release tag once + # mission publishes one. + uses: Proven-Commons/mission/.github/workflows/verified-bar.yml@6ba85c27abaaf07905c9316aa96dc29f7b5847a2 + with: + bar-ref: 6ba85c27abaaf07905c9316aa96dc29f7b5847a2 diff --git a/Proofs.lean b/Proofs.lean index 8a82bf6..e363d86 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -2,3 +2,4 @@ import Proofs.RoundTrip import Proofs.B64 import Proofs.Utf8 import Proofs.Token +import Proofs.SpecProofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean new file mode 100644 index 0000000..b6cc346 --- /dev/null +++ b/Proofs/SpecProofs.lean @@ -0,0 +1,114 @@ +/- + Proofs of the statements in `Spec.lean`, under the same + names. + + The round trips are the theorems proved in + `Proofs/Token.lean` and `Proofs/B64.lean`. The single + tokens are checked in the kernel with `decide +kernel`: no + native evaluation, which the bar forbids. +-/ + +import Jws +import Proofs.Token +import Proofs.B64 + +open Jws + +theorem Spec.parseToken_print (t : Token) (hh : Json.WF t.header) + (hc : Json.WF t.claims) : + parseToken (printToken t) = some t := + Jws.Proofs.parseToken_print t hh hc + +theorem Spec.decode_encode (bs : List UInt8) : + Base64url.decode (Base64url.encode bs) = some bs := + Jws.Base64url.Proofs.decode_encode bs + + +theorem Spec.accepts_rfc_a1 : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".toList with + | some t => accept t + { allow := ["HS256".toList], now := none, required := [], + issuer := some "joe".toList, audience := none } + | none => false) = true := by + decide +kernel + + +theorem Spec.accepts_hand_built : + (match parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1500000000, + required := [], issuer := some "joe".toList, + audience := none } + | none => false) = true := by + decide +kernel + + +theorem Spec.rejects_unsecured_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := by + decide +kernel + + +theorem Spec.rejects_unsecured_none_when_allowlist_names_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["none".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := by + decide +kernel + + +theorem Spec.rejects_unsecured_none_when_allowlist_mixes_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["none".toList, "HS256".toList], + now := some 1300819000, required := [], issuer := none, + audience := none } + | none => false) = false := by + decide +kernel + + +theorem Spec.rejects_empty_signature : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1300819000, + required := [], issuer := some "joe".toList, + audience := none } + | none => false) = false := by + decide +kernel + + +theorem Spec.rejects_alg_outside_allowlist : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".toList with + | some t => accept t + { allow := ["RS256".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := by + decide +kernel + + +theorem Spec.rejects_part_not_base64url : + (parseToken + "eyJhbGciOiJIUzI1NiJ9=.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).isSome + = false := by + decide +kernel + + +theorem Spec.rejects_header_not_object : + (match parseToken + "WyJIUzI1NiJd.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1500000000, + required := [], issuer := none, audience := none } + | none => false) = false := by + decide +kernel diff --git a/README.md b/README.md index 2947a8b..f4adb6c 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,204 @@ # jws: a JWS compact parser and checker, with a proved round trip -A parser and checker for the JWS compact form (RFC 7515) in Lean, with the plan's main claim proved: printing a parsed token gives the token back. The parser reads the three base64url parts, decodes the header and payload as JSON, and holds the signature bytes unchanged. The checker runs the shape and claims rules of `SPEC.md` (algorithm allowlist, required claims, and the `exp`, `nbf`, `iss`, and `aud` claims against a caller supplied context). Tests run the RFC 7515 Appendix A examples and a differential corpus against an oracle implementing the same rules; see `tests/TEST-RESULTS.md` for the counts. +Status: in-progress + +A parser and checker for the JWS compact form (RFC 7515) +in Lean. The parser reads the three base64url parts, +decodes the header and payload as JSON, and holds the +signature bytes unchanged. The checker runs the shape and +claims rules of `SPEC.md`: 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 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. + +## How to run it + +`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. It answers `ok:` and the codes of +the canonical reprinted token 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. -Status: in progress. The first slice from plan issue 1 has landed locally. The specification has not yet been approved by a reviewer apart from its author, and no statement file (`Spec.lean`) has been added by a reviewer, so this is not verified. - -## Limit, stated up front - -This slice does not verify a signature. It must never be described as a verified token on its own. Signature verification is the second slice. A caller that reads claims from this slice without verifying the signature has not checked authenticity. - -## Where the JSON code came from +``` +lake build +python3 tests/run_tests.py +``` -The JSON parser, printer, value type, and round trip proof are the json repo's, carried in unchanged under `Json/` and `Proofs/RoundTrip.lean` (the repos in this org do not import each other). Everything else is new here: the base64url codec (`Jws/Base64url.lean`), a strict UTF-8 codec (`Jws/Utf8.lean`), and the token framing, claims reading, and checker (`Jws/Token.lean`). The base64url code follows the shape of the codec repo's Base64 work, with the URL safe alphabet and no padding. +`python3 tests/run_tests.py` runs the fixed RFC 7515 +Appendix A cases, the mutation cases, the malformed +request cases, and the differential corpus against an +oracle implementing the same rules (with tokens built by +PyJWT when the work directory venv is present), and +writes the counts to `tests/TEST-RESULTS.md`. ## What is proved +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 | |-------|---------| -| Parsing the printed form of a token gives the token back, for tokens whose JSON parts are well formed in the json slice's sense. | `Jws.Proofs.parseToken_print` | -| Decoding the base64url encoding of any bytes gives the bytes back. | `Jws.Base64url.Proofs.decode_encode` | -| Encoding the decoding of any text the decoder accepts gives the text back. | `Jws.Base64url.Proofs.encode_decode` | -| Decoding the UTF-8 encoding of any text gives the text back. | `Jws.Utf8.Proofs.decode_encode` | -| Parsing the canonical printed form of a well formed JSON value gives the value back (carried in with the JSON code). | `Json.Proofs.parse_print` | - -The two base64url round trips check against `propext` and `Quot.sound` only. The token and UTF-8 round trips check against the standard three (`propext`, `Classical.choice`, `Quot.sound`). A reader can confirm with `#print axioms` on each name. +| Parsing the printed form of a token gives the token back, for tokens whose JSON parts 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` | +| 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.rejects_unsecured_none_when_allowlist_names_none` | +| 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. | `Spec.rejects_empty_signature` | +| The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). | `Spec.rejects_alg_outside_allowlist` | +| A token whose first part carries a padding character does not parse: `=` is not in the base64url alphabet. | `Spec.rejects_part_not_base64url` | +| A token whose header part decodes to a JSON array, not an object, is refused. | `Spec.rejects_header_not_object` | + +The token round trip rests on the proofs in +`Proofs/Token.lean`, where it is +`Jws.Proofs.parseToken_print`: 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 +(`Jws.Utf8.Proofs.decode_encode`, in `Proofs/Utf8.lean`), +and the base64url round trip. The base64url layer proves +both directions: `Spec.decode_encode` above, and the +reverse, encoding a decoding gives the text back +(`Jws.Base64url.Proofs.encode_decode`, in +`Proofs/B64.lean`). The single token theorems are single +inputs checked in the kernel with `decide +kernel`, each +one a named fixed case in the test harness, run through +the same `Jws.parseToken` and `Jws.accept` the command +line tool calls. + +The token and UTF-8 round trips check against the +standard three axioms only (`propext`, `Classical.choice`, +`Quot.sound`), and the base64url round trips against +`propext` and `Quot.sound` only, which a reader can +confirm with `#print axioms` on each name. + +The command line tool in `Main.lean` calls +`Jws.parseToken`, `Jws.accept`, and `Jws.printToken` +directly, the same functions the theorems are about. ## What is not proved -- That the checker accepts exactly the tokens `SPEC.md` says it should. The acceptance rules live in one function (`Jws.accept`) and their 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. -- The reverse printing direction at the token level (printing a parsed token is how the round trip is stated; the base64url and JSON layers each prove their own reverse directions). -- Anything about the numeric meaning of claims beyond the exact decimal reading the checker itself uses (whole seconds, comparisons). The reading code is exercised by the tests, including exponent spellings and very large exponents. -- 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. - -Two readings worth naming, both 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 `none` is turned away outright, together with any context whose allowlist names `none`, so the none case never reaches acceptance. -- When a JSON object repeats a name, lookup reads the first copy, as the JSON code's lookup does. +- That the checker accepts exactly the tokens `SPEC.md` + says it should, beyond the single inputs the theorems + above pin. The acceptance rules live in one function + (`Jws.accept`), and their 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. +- The reverse printing direction at the token level: + printing a parsed token is how the round trip is + stated. The base64url and JSON layers each prove their + own reverse directions. +- Anything about the numeric meaning of claims beyond + the exact decimal reading the checker itself uses + (whole seconds, comparisons). The reading code is + exercised by the tests, including exponent spellings + and very large exponents. +- 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. + +Two readings worth naming, both 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 `none` is turned away outright, together + with any context whose allowlist names `none`, so the + none case never reaches acceptance. The theorem + `Spec.rejects_empty_signature` pins the emptied + signature on the RFC's own A.1 token. +- When a JSON object repeats a name, lookup reads the + first copy, as the JSON code's lookup does. ## What you must trust -- The Lean kernel, which checks the proofs. -- The Lean compiler, with the C compiler and linker shipped with it, and the runtime, which turn the proved definitions into the program that runs. -- Lean's core library, including the compiled code it puts in place of some definitions the proofs reason about. -- The operating system and hardware it runs on. -- For the tests: Python's standard library (the oracle's JSON reading and exact decimals), PyJWT and cryptography in the test venv when present (token building and decode cross checks only; verdicts never come from PyJWT), and the RFC 7515 text for the fixed examples. The results file names the versions used. - -This project has no dependencies beyond Lean itself. The test harness's PyJWT corpus is optional and lives in a venv inside the work directory. - -## Run it - -Needs Linux and [elan](https://github.com/leanprover/elan). Build, then feed request lines to the tool: - -``` -lake build -python3 tests/run_tests.py -``` - -A request is one `check` line; `SPEC.md` states the field format in full. The token text, the current time, the allowlist, the required claims, the expected issuer, and the expected audience are the six fields, each written as comma separated decimal character codes, the fields separated by semicolons. The answer is `ok:` and the codes of the canonical reprinted token, `none` when the token is not accepted, and `error` when the request is malformed. +- 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.md` states the reading of RFC 7515 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 + in `SPEC.md` where the RFC leaves a choice open. +- The oracle in `tests/run_tests.py`, written from + `SPEC.md`, and PyJWT and cryptography in the test venv + when present. Both are evidence, not proof: PyJWT + builds well formed tokens and cross checks decoding + only, and verdicts never come from PyJWT. The results + file names 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. ## Re-check it -Needs Linux, elan, and Python 3.11 or later. - -``` -lake build -grep -rn "sorry" --include="*.lean" Json Jws Proofs Main.lean Json.lean Jws.lean Proofs.lean -python3 tests/run_tests.py -``` - -The grep should print nothing: there is no `sorry` anywhere. The test script runs the fixed RFC cases, the mutation cases, the malformed request cases, and the differential corpus (PyJWT built tokens when the venv is present, plus a seeded random corpus), and writes the counts to `tests/TEST-RESULTS.md`. +1. `lake comparator --paranoid`: builds the challenge + file `Spec.lean` and the proofs in a sandbox and + checks that each theorem named in `comparator.json` + proves 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. +2. `python3 tests/run_tests.py`: the fixed RFC cases, + the mutation cases, the malformed request cases, and + the differential corpus, with counts and any failures + written to `tests/TEST-RESULTS.md`. The harness uses + the PyJWT library from the virtual environment in the + work directory (`venv`) when it is there, and says so + in the results file when it is not. +3. `lake build`: the libraries, the proofs, and the tool + build clean. +4. Read `SPEC.md` against RFC 7515 and check the pinned + readings are the ones you want. diff --git a/SPEC.md b/SPEC.md index cdffc50..23ae3e3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -182,3 +182,27 @@ is malformed (wrong field count, a field that is not a code list, a code that is not a Unicode scalar value, a current time field that is not an optional minus sign plus at most 19 digits, an unknown operation). + +## Properties and their theorems + +Each property a reader can check in the page (`README.md`) +names its theorem. The theorems are stated in `Spec.lean` +and proved in `Proofs/SpecProofs.lean`. The single token +theorems are single inputs, one for each named fixed case +in the test harness, run through the same `Jws.parseToken` +and `Jws.accept` the command line tool calls; the tests in +`tests/` cover each rule on many more inputs. + +| Property | Theorem | +|----------|---------| +| Parsing the printed form of a token gives the token back, for well formed JSON parts | `Spec.parseToken_print` | +| Decoding a base64url encoding gives the bytes back | `Spec.decode_encode` | +| The RFC 7515 A.1 token is accepted under its allowlist with no time checks and the issuer checked | `Spec.accepts_rfc_a1` | +| The harness's hand built token is accepted under its base context | `Spec.accepts_hand_built` | +| The RFC 7515 A.5 unsecured token is refused under an HS256 allowlist | `Spec.rejects_unsecured_none` | +| The A.5 token is refused when the allowlist names `none` | `Spec.rejects_unsecured_none_when_allowlist_names_none` | +| The A.5 token is refused when the allowlist names `none` alongside HS256 | `Spec.rejects_unsecured_none_when_allowlist_mixes_none` | +| A token with an empty signature part is refused (rule 2) | `Spec.rejects_empty_signature` | +| A token whose algorithm is outside the allowlist is refused (rule 7) | `Spec.rejects_alg_outside_allowlist` | +| A token with a part that is not strict base64url does not parse | `Spec.rejects_part_not_base64url` | +| A token whose header is not a JSON object is refused (rule 3) | `Spec.rejects_header_not_object` | diff --git a/Spec.lean b/Spec.lean new file mode 100644 index 0000000..487a848 --- /dev/null +++ b/Spec.lean @@ -0,0 +1,159 @@ +/- + The specification as Lean statements: the challenge file. + + A reviewer who did not write this file approves it, and + `bar.toml` records the SHA-256 it had then. The proofs live + in `Proofs` (`Proofs/SpecProofs.lean`); `lake comparator` + checks that each one proves exactly the statement written + here. The `sorry` below is expected in this file only. + + The statements use only the definitions in `Jws/Token.lean`, + `Jws/Base64url.lean`, `Jws/Utf8.lean`, and the JSON code + carried into this repo (`Json/Value.lean` and + `Json/Parse.lean`), the code under proof named in + `bar.toml`. No definition of their own is needed, so this + project has no `Spec/Defs.lean`. +-/ + +import Jws + +open Jws + +/-! ## The round trips + +The statements the slice stands on. Each one is the statement +of the proved theorem of the same shape in `Proofs`. -/ + +/-- Parsing the printed form of a token gives the token back, + for tokens whose JSON parts are well formed in the JSON + slice's sense. So a printer that wrote nothing, or wrote + something shorter, fails this statement. -/ +theorem Spec.parseToken_print (t : Token) (hh : Json.WF t.header) + (hc : Json.WF t.claims) : + parseToken (printToken t) = some t := sorry + +/-- Decoding the base64url encoding of any bytes gives the + bytes back. -/ +theorem Spec.decode_encode (bs : List UInt8) : + Base64url.decode (Base64url.encode bs) = some bs := sorry + +/-! ## Single tokens, accepted and refused + +Single inputs run through the same two entry points the +command line tool calls, `parseToken` and `accept`, each with +the context the test harness gives that case. Each input is +one of the named fixed cases in the test harness. The +differential tests cover these rules on many more inputs; +these statements pin them so a reader of the specification +can check each rule against one concrete case. -/ + +/-- The RFC 7515 A.1 token is accepted when HS256 is allowed + and the issuer must be `joe`, with no current time + supplied, so the time comparisons are skipped and the type + rules still hold. The harness case is "A.1 accept (no time + checks, issuer checked)". -/ +theorem Spec.accepts_rfc_a1 : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".toList with + | some t => accept t + { allow := ["HS256".toList], now := none, required := [], + issuer := some "joe".toList, audience := none } + | none => false) = true := sorry + +/-- A hand built token is accepted: header `{"alg":"HS256"}`, + claims `{"iss":"joe","exp":2000000000}`, a 32 byte + signature, HS256 allowed, the current time 1500000000 + before the expiry, and the issuer required to be `joe`. + The harness case is "base accept". -/ +theorem Spec.accepts_hand_built : + (match parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1500000000, + required := [], issuer := some "joe".toList, + audience := none } + | none => false) = true := sorry + +/-- The RFC 7515 A.5 unsecured token (algorithm `none`, empty + signature part) is refused when HS256 is the allowed + algorithm. The harness case is "A.5 reject (alg none, + HS256 allowed)". -/ +theorem Spec.rejects_unsecured_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := sorry + +/-- The A.5 token is refused when the allowlist itself names + `none`: such a context refuses every token. The harness + case is "A.5 reject (alg none, allowlist names none)". -/ +theorem Spec.rejects_unsecured_none_when_allowlist_names_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["none".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := sorry + +/-- The A.5 token is refused when the allowlist names `none` + alongside HS256. The harness case is "A.5 reject (alg + none, allowlist names none and HS256)". -/ +theorem Spec.rejects_unsecured_none_when_allowlist_mixes_none : + (match parseToken + "eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["none".toList, "HS256".toList], + now := some 1300819000, required := [], issuer := none, + audience := none } + | none => false) = false := sorry + +/-- The A.1 token with its signature part emptied is refused, + under a context that accepts the token as it stands: + HS256 allowed, the current time before the expiry, the + issuer required to be `joe`. The harness case is "A.1 + reject (signature part emptied, otherwise passing + context)", the case the 2026-10-27 review change pinned. -/ +theorem Spec.rejects_empty_signature : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1300819000, + required := [], issuer := some "joe".toList, + audience := none } + | none => false) = false := sorry + +/-- The A.1 token is refused when its algorithm, HS256, is + outside the allowlist, here RS256 alone. The harness case + is "A.1 reject (alg not allowed)". -/ +theorem Spec.rejects_alg_outside_allowlist : + (match parseToken + "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".toList with + | some t => accept t + { allow := ["RS256".toList], now := some 1300819000, + required := [], issuer := none, audience := none } + | none => false) = false := sorry + +/-- A token whose first part carries a padding character does + not parse at all: `=` is not in the base64url alphabet, + and padding is not part of the compact form. The harness + case is "mutation: padding on header part", built on its + hand built token. -/ +theorem Spec.rejects_part_not_base64url : + (parseToken + "eyJhbGciOiJIUzI1NiJ9=.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).isSome + = false := sorry + +/-- A token whose protected (header) part decodes to a JSON + array, not an object, is refused: the header `["HS256"]` + on the harness's hand built payload and signature, under + its base context. The harness case is "mutation: header + is an array". -/ +theorem Spec.rejects_header_not_object : + (match parseToken + "WyJIUzI1NiJd.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList with + | some t => accept t + { allow := ["HS256".toList], now := some 1500000000, + required := [], issuer := none, audience := none } + | none => false) = false := sorry diff --git a/bar.toml b/bar.toml new file mode 100644 index 0000000..612ad77 --- /dev/null +++ b/bar.toml @@ -0,0 +1,62 @@ +# Read by bar/check.py in Proven-Commons/mission. +# See playbook/verified-bar.md there for what each field means. + +[project] +name = "jws" +# "in-progress" until every bar item passes, then "verified". +status = "in-progress" +page = "README.md" +# GitHub handles of everyone who wrote the specification. +spec_authors = ["abu-aska"] +# The program people run. A change to it voids the approval. +entry_points = ["Main.lean"] + +[spec] +files = ["SPEC.md", "Spec.lean"] +# The code under proof: the functions the statements are about. +code = [ + # The token value. + "Jws.Token", + # The framing: split and join at the dots, the printer, the parser. + "Jws.splitSep", "Jws.joinSep", "Jws.printToken", "Jws.parseToken", + # The exact decimal reading of claim numbers, which the check uses. + "Jws.NumVal", "Jws.digitsVal", "Jws.strip", "Jws.mkNumVal", + "Jws.numVal", "Jws.NumVal.isInt", "Jws.NumVal.ofInt", + "Jws.numDigits", "Jws.magCmp", "Jws.NumVal.cmp", + # The check: the context, the claim reading, the accept function. + "Jws.Context", "Jws.ClaimDate", "Jws.ClaimDate.absent", + "Jws.ClaimDate.bad", "Jws.ClaimDate.ok", "Jws.lookup", + "Jws.dateClaim", "Jws.issShapeOk", "Jws.audShapeOk", "Jws.audOk", + "Jws.accept", + # Base64url, strict, no padding. + "Jws.Base64url.char", "Jws.Base64url.encode", "Jws.Base64url.value", + "Jws.Base64url.decode", + # The strict UTF-8 layer the header and payload pass through. + "Jws.Utf8.encodeChar", "Jws.Utf8.encode", "Jws.Utf8.contVal", + "Jws.Utf8.decode", + # The JSON value the token parts hold, its printer, and its + # well formedness, all carried in from the json slice. + "Json.Json", "Json.Json.null", "Json.Json.bool", "Json.Json.num", + "Json.Json.str", "Json.Json.arr", "Json.Json.obj", + "Json.hexDigits", "Json.hexDigit", "Json.escape", "Json.escapeAll", + "Json.printStr", "Json.join", "Json.print", "Json.printPair", + "Json.IsDigit", "Json.FracOk", "Json.ExpOk", "Json.WFNum", "Json.WF", + # The JSON reader the token parser calls. + "Json.isWs", "Json.skipWs", "Json.isDigit", "Json.hexVal", + "Json.simpleEscape", "Json.parseStrBody", "Json.takeDigits", + "Json.parseIntPart", "Json.parseFracOpt", "Json.parseExpOpt", + "Json.parseNumber", "Json.parseValue", "Json.parseEls", + "Json.parseObjEls", "Json.parse", +] + +# One entry per approval, added in a pull request the reviewer approves. +# [[spec.approval]] +# reviewer = "github-handle" +# date = "2026-11-02" +# sha256 = "..." +# record = "https://github.com/Proven-Commons/jws/pull/" + +[tests] +results = "tests/TEST-RESULTS.md" +command = "python3 tests/run_tests.py" +files = ["tests/run_tests.py"] diff --git a/comparator.json b/comparator.json new file mode 100644 index 0000000..2f9f809 --- /dev/null +++ b/comparator.json @@ -0,0 +1 @@ +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object"]} \ No newline at end of file diff --git a/lakefile.toml b/lakefile.toml index a6ad22a..53e281c 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -11,6 +11,9 @@ name = "Jws" [[lean_lib]] name = "Proofs" +[[lean_lib]] +name = "Spec" + [[lean_exe]] name = "jws" root = "Main" From e197030b44f906e70b3cfaf3461322489bfbcc22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:13:57 +0000 Subject: [PATCH 04/23] Record the oracle, seed, inputs and failures; pin the PyJWT corpus 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 35 +++-- bar.toml | 14 +- tests/TEST-RESULTS.md | 15 +- tests/make_pyjwt_corpus.py | 82 ++++++++++ tests/pyjwt_corpus.json | 306 +++++++++++++++++++++++++++++++++++++ tests/requirements.txt | 5 + tests/run_tests.py | 144 +++++++---------- 7 files changed, 487 insertions(+), 114 deletions(-) create mode 100644 tests/make_pyjwt_corpus.py create mode 100644 tests/pyjwt_corpus.json create mode 100644 tests/requirements.txt diff --git a/README.md b/README.md index f4adb6c..16861ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jws: a JWS compact parser and checker, with a proved round trip -Status: in-progress +Status: in progress A parser and checker for the JWS compact form (RFC 7515) in Lean. The parser reads the three base64url parts, @@ -52,9 +52,18 @@ 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, and the differential corpus against an -oracle implementing the same rules (with tokens built by -PyJWT when the work directory venv is present), and -writes the counts to `tests/TEST-RESULTS.md`. +oracle implementing the same rules, and writes the +counts to `tests/TEST-RESULTS.md`. It needs only the +Python standard library. The tokens PyJWT built for the +corpus 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 +``` ## What is proved @@ -169,11 +178,12 @@ pinned by tests: fidelity of the spec to the RFC is a reading, pinned in `SPEC.md` where the RFC leaves a choice open. - The oracle in `tests/run_tests.py`, written from - `SPEC.md`, and PyJWT and cryptography in the test venv - when present. Both are evidence, not proof: PyJWT - builds well formed tokens and cross checks decoding - only, and verdicts never come from PyJWT. The results - file names the versions used. + `SPEC.md`, and PyJWT and cryptography (pinned in + `tests/requirements.txt`), which built the tokens in + `tests/pyjwt_corpus.json`. Both are evidence, not proof: + PyJWT builds well formed tokens and cross checks + decoding only, and verdicts never come from PyJWT. The + results file names 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 @@ -194,10 +204,9 @@ pinned by tests: 2. `python3 tests/run_tests.py`: the fixed RFC cases, the mutation cases, the malformed request cases, and the differential corpus, with counts and any failures - written to `tests/TEST-RESULTS.md`. The harness uses - the PyJWT library from the virtual environment in the - work directory (`venv`) when it is there, and says so - in the results file when it is not. + written to `tests/TEST-RESULTS.md`. The harness reads + the PyJWT tokens from `tests/pyjwt_corpus.json` and + needs no library beyond Python's own. 3. `lake build`: the libraries, the proofs, and the tool build clean. 4. Read `SPEC.md` against RFC 7515 and check the pinned diff --git a/bar.toml b/bar.toml index 612ad77..6d95408 100644 --- a/bar.toml +++ b/bar.toml @@ -7,7 +7,7 @@ name = "jws" status = "in-progress" page = "README.md" # GitHub handles of everyone who wrote the specification. -spec_authors = ["abu-aska"] +spec_authors = ["abu-aska", "zero-aska"] # The program people run. A change to it voids the approval. entry_points = ["Main.lean"] @@ -15,8 +15,9 @@ entry_points = ["Main.lean"] files = ["SPEC.md", "Spec.lean"] # The code under proof: the functions the statements are about. code = [ - # The token value. - "Jws.Token", + # The token value, its constructor and fields. + "Jws.Token", "Jws.Token.mk", "Jws.Token.header", "Jws.Token.claims", + "Jws.Token.sig", # The framing: split and join at the dots, the printer, the parser. "Jws.splitSep", "Jws.joinSep", "Jws.printToken", "Jws.parseToken", # The exact decimal reading of claim numbers, which the check uses. @@ -24,7 +25,9 @@ code = [ "Jws.numVal", "Jws.NumVal.isInt", "Jws.NumVal.ofInt", "Jws.numDigits", "Jws.magCmp", "Jws.NumVal.cmp", # The check: the context, the claim reading, the accept function. - "Jws.Context", "Jws.ClaimDate", "Jws.ClaimDate.absent", + "Jws.Context", "Jws.Context.mk", "Jws.Context.allow", "Jws.Context.now", + "Jws.Context.required", "Jws.Context.issuer", "Jws.Context.audience", + "Jws.ClaimDate", "Jws.ClaimDate.absent", "Jws.ClaimDate.bad", "Jws.ClaimDate.ok", "Jws.lookup", "Jws.dateClaim", "Jws.issShapeOk", "Jws.audShapeOk", "Jws.audOk", "Jws.accept", @@ -59,4 +62,5 @@ code = [ [tests] results = "tests/TEST-RESULTS.md" command = "python3 tests/run_tests.py" -files = ["tests/run_tests.py"] +files = ["tests/run_tests.py", "tests/make_pyjwt_corpus.py", + "tests/pyjwt_corpus.json", "tests/requirements.txt"] diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 0ed6e30..8a6012f 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -1,20 +1,21 @@ # Test results: JWS compact parser and checker -Run with `python3 tests/run_tests.py`. The record is -written by the run. Seed 20261026 for the random corpus. -The run happened on 2026-10-26/27. +Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/build/bin/jws`. A fresh run gives the same inputs, seed, and verdicts. + +- Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) +- Seed: 20261026 +- Inputs: 426 +- Failures: 0 ## The oracle and PyJWT -PyJWT 2.15.1 was present (a venv inside the work directory, with cryptography). It built 20 tokens (HS256, RS256, ES256, and none algorithms). -Its unverified decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. -Verdicts are always the oracle's (SPEC.md rules reimplemented in the harness). PyJWT built well formed tokens and cross checked decoding only. +Verdicts come from the oracle, which reimplements the SPEC.md rules in Python. PyJWT 2.15.1 built 20 tokens (HS256, RS256, ES256, and none algorithms), kept in tests/pyjwt_corpus.json with PyJWT's own unverified decode of each. The harness reads that file, so it needs no PyJWT to run; to rebuild the corpus, make a virtual environment from tests/requirements.txt and run tests/make_pyjwt_corpus.py (README, How to run it). +PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. ## Counts - Total check and request inputs: 426 - Agreements with the oracle: 426 -- Failures: 0 - Of the verdict cases: 53 accepted, 363 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases diff --git a/tests/make_pyjwt_corpus.py b/tests/make_pyjwt_corpus.py new file mode 100644 index 0000000..29a8682 --- /dev/null +++ b/tests/make_pyjwt_corpus.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Build tests/pyjwt_corpus.json with PyJWT. + +The test harness (tests/run_tests.py) reads the tokens this script +writes, so every run of the harness, with or without PyJWT +installed, runs the same inputs. Rerun this script only to rebuild +the corpus, in a virtual environment made from the pinned +requirements: + + python3 -m venv venv + venv/bin/pip install -r tests/requirements.txt + venv/bin/python tests/make_pyjwt_corpus.py + +The corpus holds, for each token PyJWT built: the algorithm, the +token text, and PyJWT's own unverified decode of the header and +the claims. The RS256 and ES256 keys are made fresh on each +rebuild, so their signatures differ between rebuilds; nothing in +the harness depends on signature bytes beyond their being present. +""" + +import json +import sys +from pathlib import Path + +import cryptography +import jwt +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +OUT = Path(__file__).resolve().parent / "pyjwt_corpus.json" + + +def main(): + rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ec_key = ec.generate_private_key(ec.SECP256R1()) + + payloads = [ + {"iss": "joe", "exp": 2000000000, "nbf": 1400000000, + "iat": 1400000000, "aud": "api", "sub": "user-1"}, + {"iss": "issuer.example", "exp": 2000000000, "aud": ["api", "web"]}, + {"exp": 2000000000}, + {"iss": "joe", "exp": 1200000000, "aud": "api"}, + {"iss": "jé", "exp": 2000000000, "name": "José"}, + {"iss": "joe", "exp": 2000000000, "nbf": 1600000000}, + {"iss": "joe", "exp": 9999999999, "aud": ["a", "b", "c"], + "admin": True}, + {"iss": "joe", "exp": 2000000000, "scope": ["read", "write"], + "n": 42}, + ] + builders = [] + for p in payloads: + builders.append(("HS256", jwt.encode(p, "secret", algorithm="HS256"))) + builders.append(("none", jwt.encode(p, "", algorithm="none"))) + builders.append(("RS256", jwt.encode(payloads[0], rsa_key, + algorithm="RS256"))) + builders.append(("RS256", jwt.encode(payloads[1], rsa_key, + algorithm="RS256"))) + builders.append(("ES256", jwt.encode(payloads[0], ec_key, + algorithm="ES256"))) + builders.append(("ES256", jwt.encode(payloads[6], ec_key, + algorithm="ES256"))) + + tokens = [] + for alg, tok in builders: + tokens.append({ + "alg": alg, + "token": tok, + "header": jwt.get_unverified_header(tok), + "claims": jwt.decode(tok, options={"verify_signature": False}), + }) + + out = { + "pyjwt": jwt.__version__, + "cryptography": cryptography.__version__, + "tokens": tokens, + } + OUT.write_text(json.dumps(out, indent=1, ensure_ascii=False) + "\n") + print("wrote %d tokens to %s" % (len(tokens), OUT)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/pyjwt_corpus.json b/tests/pyjwt_corpus.json new file mode 100644 index 0000000..c54827c --- /dev/null +++ b/tests/pyjwt_corpus.json @@ -0,0 +1,306 @@ +{ + "pyjwt": "2.15.1", + "cryptography": "46.0.3", + "tokens": [ + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.RHVDPsvhh-43fZEoTqsMBUmtBbmJPG2lrvGkDWLVgNY", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1400000000, + "iat": 1400000000, + "aud": "api", + "sub": "user-1" + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1400000000, + "iat": 1400000000, + "aud": "api", + "sub": "user-1" + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImV4cCI6MjAwMDAwMDAwMCwiYXVkIjpbImFwaSIsIndlYiJdfQ.j4GuctX7tsRjT0S7LvligMPqOoUhRRnNrSPzew-uTEg", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "issuer.example", + "exp": 2000000000, + "aud": [ + "api", + "web" + ] + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImV4cCI6MjAwMDAwMDAwMCwiYXVkIjpbImFwaSIsIndlYiJdfQ.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "issuer.example", + "exp": 2000000000, + "aud": [ + "api", + "web" + ] + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwMDAwMDAwMDB9.JgCO_GqUQnbS0z2hCxJLE9Tpt5SMoZObHBxzGBWuTYQ", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "exp": 2000000000 + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjIwMDAwMDAwMDB9.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "exp": 2000000000 + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEyMDAwMDAwMDAsImF1ZCI6ImFwaSJ9.-6hL8p0XV2WK0O8DlGTYm87p7uImaqxSagUS0ECeoR0", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 1200000000, + "aud": "api" + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqb2UiLCJleHAiOjEyMDAwMDAwMDAsImF1ZCI6ImFwaSJ9.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 1200000000, + "aud": "api" + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqXHUwMGU5IiwiZXhwIjoyMDAwMDAwMDAwLCJuYW1lIjoiSm9zXHUwMGU5In0.mlBfn_M6Q_3MnK3Dk10TAfUY-xBTB3btAp6tabla1g0", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "jé", + "exp": 2000000000, + "name": "José" + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqXHUwMGU5IiwiZXhwIjoyMDAwMDAwMDAwLCJuYW1lIjoiSm9zXHUwMGU5In0.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "jé", + "exp": 2000000000, + "name": "José" + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTYwMDAwMDAwMH0.Z1prpwAa6ZpIA6bhQP0qrZMcpuOdLElJsLx3X3BL1CE", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1600000000 + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTYwMDAwMDAwMH0.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1600000000 + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjk5OTk5OTk5OTksImF1ZCI6WyJhIiwiYiIsImMiXSwiYWRtaW4iOnRydWV9.hsTNf8ktrgZf7Pm8wmQIBueRezV4NLvjd1ytemZ857M", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 9999999999, + "aud": [ + "a", + "b", + "c" + ], + "admin": true + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqb2UiLCJleHAiOjk5OTk5OTk5OTksImF1ZCI6WyJhIiwiYiIsImMiXSwiYWRtaW4iOnRydWV9.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 9999999999, + "aud": [ + "a", + "b", + "c" + ], + "admin": true + } + }, + { + "alg": "HS256", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsInNjb3BlIjpbInJlYWQiLCJ3cml0ZSJdLCJuIjo0Mn0.ZHgmq-435Zb2TfD_XlfXJvPw5KqE2PehlbkvgZjNfts", + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "scope": [ + "read", + "write" + ], + "n": 42 + } + }, + { + "alg": "none", + "token": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsInNjb3BlIjpbInJlYWQiLCJ3cml0ZSJdLCJuIjo0Mn0.", + "header": { + "alg": "none", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "scope": [ + "read", + "write" + ], + "n": 42 + } + }, + { + "alg": "RS256", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.Py8YuPr5CNw74vDDCP5SwvIsQvGC8Kg5rO59w-epWLvta8eZ0-1ChzAiNqqueKgrbmCy315uABwICz0RAvU-5aIXcSSmls5p4TA8r3j_jMuD0RlgGmcP5KWyYdgDl_iacctTKNjKYSuBALkMghcDhRugTRr2W19uvVJ5nMOMzxkAo-vmesz27gho8Xz1mXmpwCAwDGJjA3M7nNUzdOCuxt0YSA-dTrnXyTe7QtQ8_y3tmOEKperpUsc787zhymxqHsol-rM2WqbZ5CCwf0n_KRXQ3xzi0ZaUlLY-_0yNqMtTY8fFK884QFj5lkJ8iKfxMEbhGPSNmHCKKZHgxIq7qQ", + "header": { + "alg": "RS256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1400000000, + "iat": 1400000000, + "aud": "api", + "sub": "user-1" + } + }, + { + "alg": "RS256", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImV4cCI6MjAwMDAwMDAwMCwiYXVkIjpbImFwaSIsIndlYiJdfQ.O2TviK2bH-Wy4AyBZFPAMcfXlSm3leI3eV6XAytjpF9P8b6xEBCWmV4HYB7SSehg17yR2RCvJvPLgviHjL0WOPpCIOtnoDud8PYmPiHdzzO3EUJDG1jvfFnxDfQTQkmxFEjkfba5McLlFrVW9UVKc3wEw31UCkaRHveI6xkKUJJ0l7kOZaeo_ftSCGpWJZTiZL6Y5WRDaqWovMM44n0cHgB6NxnkDVM34k5qRL1VTG4hwphqVCppZcDEOS6qIB2_DSXbR1CCtmMfCTHaNSPIqZMqpFWCQyD9L-7lPNTc6uXeAgWnouWWrjsUsHvNjIUB8bJlXAy7LzXE1EORvQimqQ", + "header": { + "alg": "RS256", + "typ": "JWT" + }, + "claims": { + "iss": "issuer.example", + "exp": 2000000000, + "aud": [ + "api", + "web" + ] + } + }, + { + "alg": "ES256", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.CLtfjBvU_DGQgEGJzFOWFki-Cg3FTaNNO6vEg79mH0XoiUFwX1XpKqrfghVc6iGHTpqmXosiqtIGQI2aGdD3qw", + "header": { + "alg": "ES256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 2000000000, + "nbf": 1400000000, + "iat": 1400000000, + "aud": "api", + "sub": "user-1" + } + }, + { + "alg": "ES256", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjk5OTk5OTk5OTksImF1ZCI6WyJhIiwiYiIsImMiXSwiYWRtaW4iOnRydWV9.idtR-M4ID4n_AwjQqUleLw4NGXHxewPmnsfDVG82qJMvhPZGhG2jxrdgiRWOWZ3T9LMkiM8c5Cpf66Qb2TdAvw", + "header": { + "alg": "ES256", + "typ": "JWT" + }, + "claims": { + "iss": "joe", + "exp": 9999999999, + "aud": [ + "a", + "b", + "c" + ], + "admin": true + } + } + ] +} diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..03fb1cc --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ +# The versions that built tests/pyjwt_corpus.json. Needed only to +# rebuild that corpus (tests/make_pyjwt_corpus.py); the harness +# itself uses the Python standard library alone. +PyJWT==2.15.1 +cryptography==46.0.3 diff --git a/tests/run_tests.py b/tests/run_tests.py index c22d2e1..fdbf1dd 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -11,13 +11,15 @@ signature part emptied, under an otherwise passing context, is a fixed reject case. -2. Differential: tokens built with PyJWT (in the work dir venv) - when it is present, plus hand built tokens, each run under - several check contexts. Every verdict (and, on acceptance, - the canonical reprinted token) is compared against an oracle - that reimplements SPEC.md in Python. PyJWT also cross checks - the parsing: its own unverified decode of each well formed - token is compared with the oracle's parse. +2. Differential: tokens built with PyJWT (read from the committed + corpus tests/pyjwt_corpus.json, which tests/make_pyjwt_corpus.py + builds from the pinned tests/requirements.txt), plus hand built + tokens and a seeded random corpus, each run under several check + contexts. Every verdict (and, on acceptance, the token text the + tool answers with) is compared against an oracle that + reimplements SPEC.md in Python. PyJWT also cross checks the + parsing: its own unverified decode of each token it built, kept + in the corpus, is compared with the oracle's parse. 3. Mutations and malformed requests: padding added, characters outside the alphabet, two or four parts, a header that is not @@ -32,9 +34,9 @@ (it verifies signatures and registered claims its own way when asked, and accepts some framings this slice turns away). -Run: python3 tests/run_tests.py (uses .lake/build/bin/jws and -the venv's python when present for the PyJWT corpus). -Writes tests/TEST-RESULTS.md with real counts. +Run: python3 tests/run_tests.py (uses .lake/build/bin/jws; needs +only the Python standard library). Writes tests/TEST-RESULTS.md +with real counts. """ import base64 @@ -636,65 +638,28 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): -5, ["HS256"], [], None, None) # --------------------------------------------------------------- -# PyJWT corpus: tokens built by PyJWT when present, contexts -# varied. Verdicts still come from the oracle. PyJWT decode -# (signature check off) cross checks the oracle's parse. +# PyJWT corpus: tokens PyJWT built, read from the committed +# corpus, contexts varied. Verdicts still come from the oracle. +# PyJWT's decode (signature check off), kept in the corpus, cross +# checks the oracle's parse. # --------------------------------------------------------------- -PYJWT_INFO = {"present": False, "version": None, "tokens": 0, - "decode_comparisons": 0, "decode_agreements": 0, - "decode_differences": []} +PYJWT_INFO = {"present": False, "version": None, "cryptography": None, + "tokens": 0, "decode_comparisons": 0, + "decode_agreements": 0, "decode_differences": []} + +CORPUS = ROOT / "tests" / "pyjwt_corpus.json" def build_pyjwt_cases(): - venv_python = ROOT / "venv" / "bin" / "python" - if not venv_python.exists(): - return - script = r''' -import json, sys -import jwt -from jwt import __version__ as v -from cryptography.hazmat.primitives.asymmetric import rsa, ec - -out = {"version": v, "tokens": []} -rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) -ec_key = ec.generate_private_key(ec.SECP256R1()) - -payloads = [ - {"iss": "joe", "exp": 2000000000, "nbf": 1400000000, "iat": 1400000000, - "aud": "api", "sub": "user-1"}, - {"iss": "issuer.example", "exp": 2000000000, "aud": ["api", "web"]}, - {"exp": 2000000000}, - {"iss": "joe", "exp": 1200000000, "aud": "api"}, - {"iss": "jé", "exp": 2000000000, "name": "José"}, - {"iss": "joe", "exp": 2000000000, "nbf": 1600000000}, - {"iss": "joe", "exp": 9999999999, "aud": ["a", "b", "c"], "admin": True}, - {"iss": "joe", "exp": 2000000000, "scope": ["read", "write"], "n": 42}, -] -builders = [] -for p in payloads: - builders.append(("HS256", jwt.encode(p, "secret", algorithm="HS256"))) - builders.append(("none", jwt.encode(p, "", algorithm="none"))) -builders.append(("RS256", jwt.encode(payloads[0], rsa_key, algorithm="RS256"))) -builders.append(("RS256", jwt.encode(payloads[1], rsa_key, algorithm="RS256"))) -builders.append(("ES256", jwt.encode(payloads[0], ec_key, algorithm="ES256"))) -builders.append(("ES256", jwt.encode(payloads[6], ec_key, algorithm="ES256"))) -for alg, tok in builders: - decoded = jwt.decode(tok, options={"verify_signature": False}) - header = jwt.get_unverified_header(tok) - out["tokens"].append({"alg": alg, "token": tok, - "header": header, "claims": decoded}) -print(json.dumps(out)) -''' - proc = subprocess.run([str(venv_python), "-c", script], - capture_output=True, text=True, timeout=300) - if proc.returncode != 0: - PYJWT_INFO["error"] = proc.stderr[-500:] - return - data = json.loads(proc.stdout) + """Read the tokens PyJWT built (tests/pyjwt_corpus.json, made by + tests/make_pyjwt_corpus.py from tests/requirements.txt). The + corpus is committed, so every run makes the same inputs whether + or not PyJWT is installed where the harness runs.""" + data = json.loads(CORPUS.read_text(encoding="utf-8")) PYJWT_INFO["present"] = True - PYJWT_INFO["version"] = data["version"] - random.seed(SEED) + PYJWT_INFO["version"] = data["pyjwt"] + PYJWT_INFO["cryptography"] = data["cryptography"] contexts = [ ("right alg, inside validity", lambda alg: (1500000000, [alg], [], None, None)), @@ -836,41 +801,42 @@ def main(): lines_out = [] lines_out.append("# Test results: JWS compact parser and checker") lines_out.append("") - lines_out.append("Run with `python3 tests/run_tests.py`. The record is") - lines_out.append("written by the run. Seed %d for the random corpus." - % SEED) - lines_out.append("The run happened on 2026-10-26/27.") + lines_out.append("Written by `python3 tests/run_tests.py`, which drives" + " the built tool `.lake/build/bin/jws`. A fresh run" + " gives the same inputs, seed, and verdicts.") + lines_out.append("") + lines_out.append("- Oracle: the SPEC.md rules reimplemented in" + " tests/run_tests.py (Python %d.%d), with tokens" + " and decodes from PyJWT %s (cryptography %s)" + % (sys.version_info[0], sys.version_info[1], + PYJWT_INFO["version"], PYJWT_INFO["cryptography"])) + lines_out.append("- Seed: %d" % SEED) + lines_out.append("- Inputs: %d" % len(CASES)) + lines_out.append("- Failures: %d" % len(failures)) lines_out.append("") lines_out.append("## The oracle and PyJWT") lines_out.append("") - if PYJWT_INFO["present"]: - lines_out.append("PyJWT %s was present (a venv inside the work" - " directory, with cryptography). It built %d tokens" - " (HS256, RS256, ES256, and none algorithms)." - % (PYJWT_INFO["version"], PYJWT_INFO["tokens"])) - lines_out.append("Its unverified decode was compared with the oracle's" - " parse on %d tokens: %d agreements, %d differences." - % (PYJWT_INFO["decode_comparisons"], - PYJWT_INFO["decode_agreements"], - len(PYJWT_INFO["decode_differences"]))) - elif "error" in PYJWT_INFO: - lines_out.append("PyJWT was attempted in the work directory venv but" - " the builder failed; the oracle built and settled" - " every corpus token. Error: " - + PYJWT_INFO["error"].replace("\n", " ")) - else: - lines_out.append("PyJWT was not present. The oracle built and" - " settled every corpus token.") - lines_out.append("Verdicts are always the oracle's (SPEC.md rules" - " reimplemented in the harness). PyJWT built well" - " formed tokens and cross checked decoding only.") + lines_out.append("Verdicts come from the oracle, which reimplements" + " the SPEC.md rules in Python. PyJWT %s built %d" + " tokens (HS256, RS256, ES256, and none algorithms)," + " kept in tests/pyjwt_corpus.json with PyJWT's own" + " unverified decode of each. The harness reads that" + " file, so it needs no PyJWT to run; to rebuild the" + " corpus, make a virtual environment from" + " tests/requirements.txt and run" + " tests/make_pyjwt_corpus.py (README, How to run" + " it)." % (PYJWT_INFO["version"], PYJWT_INFO["tokens"])) + lines_out.append("PyJWT's decode was compared with the oracle's" + " parse on %d tokens: %d agreements, %d differences." + % (PYJWT_INFO["decode_comparisons"], + PYJWT_INFO["decode_agreements"], + len(PYJWT_INFO["decode_differences"]))) lines_out.append("") lines_out.append("## Counts") lines_out.append("") lines_out.append("- Total check and request inputs: %d" % len(CASES)) lines_out.append("- Agreements with the oracle: %d" % (len(CASES) - len(failures))) - lines_out.append("- Failures: %d" % len(failures)) lines_out.append("- Of the verdict cases: %d accepted, %d rejected," " %d malformed requests answered error" % (accepted, rejected, errors)) From 739a73ead13175d00933f7bbb8d938befc842cef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:18:05 +0000 Subject: [PATCH 05/23] Turn away a header or claims object that repeats a member name 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 20 +++-- Proofs.lean | 1 + Proofs/Check.lean | 73 +++++++++++++++++++ Proofs/SpecProofs.lean | 20 +++++ README.md | 16 +++- SPEC.md | 22 +++++- Spec.lean | 35 +++++++++ bar.toml | 2 +- comparator.json | 2 +- tests/TEST-RESULTS.md | 10 +-- tests/__pycache__/run_tests.cpython-313.pyc | Bin 0 -> 49172 bytes tests/run_tests.py | 77 ++++++++++++++++++-- 12 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 Proofs/Check.lean create mode 100644 tests/__pycache__/run_tests.cpython-313.pyc diff --git a/Jws/Token.lean b/Jws/Token.lean index 27c8f34..b375de0 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -178,15 +178,23 @@ def NumVal.cmp (a b : NumVal) : Ordering := else if sa = 1 then magCmp a b else magCmp b a -/-- Look a name up in the members of a JSON object. When a name - appears more than once, the first copy is the one found, as - in the JSON code. Anything that is not an object has no - members. -/ +/-- Look a name up in the members of a JSON object. Anything that + is not an object has no members. The check below reads names + only from objects it has first found to repeat no name (see + `namesUnique`), so the copy found is the only copy. -/ def lookup (name : List Char) (j : Json) : Option Json := match j with | .obj kvs => kvs.lookup name | _ => none +/-- Whether no name in the list appears twice. RFC 7515 section 4 + and RFC 7519 section 4 ask that the header and the claims set + repeat no member name; the check turns away an object that + does (see `accept`). -/ +def namesUnique : List (List Char) → Bool + | [] => true + | n :: ns => !ns.contains n && namesUnique ns + /-- The state of one date claim (exp, nbf, iat) in a claims set: absent, present but not a whole number of seconds (a non-number, or a number with a fraction), or present with a @@ -253,7 +261,9 @@ def accept (t : Token) (ctx : Context) : Bool := if ctx.allow.contains "none".toList then false else match t.header, t.claims with - | .obj _, .obj _ => + | .obj hkvs, .obj ckvs => + namesUnique (hkvs.map Prod.fst) && + namesUnique (ckvs.map Prod.fst) && match lookup "alg".toList t.header with | some (.str alg) => alg != "none".toList && diff --git a/Proofs.lean b/Proofs.lean index e363d86..546592f 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -2,4 +2,5 @@ import Proofs.RoundTrip import Proofs.B64 import Proofs.Utf8 import Proofs.Token +import Proofs.Check import Proofs.SpecProofs diff --git a/Proofs/Check.lean b/Proofs/Check.lean new file mode 100644 index 0000000..c25ace0 --- /dev/null +++ b/Proofs/Check.lean @@ -0,0 +1,73 @@ +import Jws.Token + +/- + Proofs about the check, `Jws.accept`: what an accepted token is + known to satisfy, and the lemmas the single token statements in + `Spec.lean` use to cover every context at once. +-/ + +namespace Jws.Proofs + +open Jws + +/-- `namesUnique` is the test for a list with no name twice. -/ +theorem namesUnique_iff (ns : List (List Char)) : namesUnique ns = true ↔ ns.Nodup := by + induction ns with + | nil => simp [namesUnique] + | cons n ns ih => + rw [namesUnique, Bool.and_eq_true, ih, List.nodup_cons, Bool.not_eq_true', + Bool.eq_false_iff, ne_eq, List.contains_iff_mem] + +/-- An accepted token has a JSON object for its header and for its + claims, and neither object repeats a member name. -/ +theorem accept_names_unique (t : Token) (ctx : Context) (h : accept t ctx = true) : + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup := by + obtain ⟨hd, cl, sig⟩ := t + unfold accept at h + split at h + · cases h + · split at h + · rename_i hkvs ckvs hh hc + rw [Bool.and_eq_true, Bool.and_eq_true] at h + exact ⟨hkvs, ckvs, hh, hc, (namesUnique_iff _).mp h.1.1, (namesUnique_iff _).mp h.1.2⟩ + · cases h + +/-- The repeated name test on a token's two objects, as a value: + true when both parts are objects and neither repeats a name. -/ +def namesOk (t : Token) : Bool := + match t.header, t.claims with + | .obj hkvs, .obj ckvs => namesUnique (hkvs.map Prod.fst) && namesUnique (ckvs.map Prod.fst) + | _, _ => false + +/-- A token that fails the repeated name test is refused under + every context. -/ +theorem accept_false_of_names (t : Token) (ctx : Context) (h : namesOk t = false) : + accept t ctx = false := by + cases hp : accept t ctx + · rfl + · obtain ⟨hkvs, ckvs, hh, hc, n1, n2⟩ := accept_names_unique t ctx hp + unfold namesOk at h + rw [hh, hc] at h + dsimp only at h + rw [(namesUnique_iff _).mpr n1, (namesUnique_iff _).mpr n2] at h + cases h + +/-- A text that parses to a token failing the repeated name test: + the token is there, and every context refuses it. The premise + is a closed computation, checked in the kernel for each + concrete text. -/ +theorem refused_of_names (s : List Char) (ctx : Context) + (h : (match parseToken s with + | some t => !namesOk t + | none => false) = true) : + ∃ t, parseToken s = some t ∧ accept t ctx = false := by + cases hp : parseToken s with + | none => rw [hp] at h; cases h + | some t => + rw [hp] at h + dsimp only at h + refine ⟨t, rfl, accept_false_of_names t ctx ?_⟩ + simpa using h + +end Jws.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index b6cc346..f94cc16 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -11,6 +11,7 @@ import Jws import Proofs.Token import Proofs.B64 +import Proofs.Check open Jws @@ -112,3 +113,22 @@ theorem Spec.rejects_header_not_object : required := [], issuer := none, audience := none } | none => false) = false := by decide +kernel + + +theorem Spec.accept_names_unique (t : Token) (ctx : Context) + (h : accept t ctx = true) : + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup := + Jws.Proofs.accept_names_unique t ctx h + +theorem Spec.rejects_duplicate_alg (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList + = some t ∧ accept t ctx = false := + Jws.Proofs.refused_of_names _ ctx (by decide +kernel) + +theorem Spec.rejects_duplicate_iss (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImlzcyI6ImV2aWwifQ.AQID".toList + = some t ∧ accept t ctx = false := + Jws.Proofs.refused_of_names _ ctx (by decide +kernel) diff --git a/README.md b/README.md index 16861ce..4cde4eb 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ where they end in `sorry`) and proved in | The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). | `Spec.rejects_alg_outside_allowlist` | | A token whose first part carries a padding character does not parse: `=` is not in the base64url alphabet. | `Spec.rejects_part_not_base64url` | | A token whose header part decodes to a JSON array, not an object, is refused. | `Spec.rejects_header_not_object` | +| 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` | The token round trip rests on the proofs in `Proofs/Token.lean`, where it is @@ -116,6 +119,9 @@ directly, the same functions the theorems are about. ## What is not proved +- 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 exactly the tokens `SPEC.md` says it should, beyond the single inputs the theorems above pin. The acceptance rules live in one function @@ -153,8 +159,14 @@ pinned by tests: none case never reaches acceptance. The theorem `Spec.rejects_empty_signature` pins the emptied signature on the RFC's own A.1 token. -- When a JSON object repeats a name, lookup reads the - first copy, as the JSON code's lookup does. +- A header or claims object that repeats a member name + is turned away (`SPEC.md` rule 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_unique` proves 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. ## What you must trust diff --git a/SPEC.md b/SPEC.md index 23ae3e3..18eff0d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -121,13 +121,26 @@ The token is accepted only when every rule holds: 16. When an expected audience is given, `aud` must be that string, or an array holding that string as a member. A missing `aud` rejects. +17. Neither the header object nor the claims object repeats a + member name. Names are compared after JSON unescaping, so + `"alg"` and `"\u0061lg"` are the same name. RFC 7515 section 4 + (and step 4 of section 5.2) lets a reader either turn away a + header that repeats a parameter name or use only the last + copy, and RFC 7519 section 4 says the same for claim names; + this slice turns the token away, whatever the repeated name + and whichever copy would have passed. Members of objects + nested inside the header or the claims (for example the + value of a private claim that is itself an object) are not + checked for repeats: no rule reads a member of a nested + object. When no current time is supplied, rules 10 and 11 are skipped; every other rule, including the type rule 9, still applies. -Names are read from objects with the JSON code's lookup: when a -name appears more than once in an object, the first copy is the -one read. +Names are read from objects with the JSON code's lookup. By +rule 17 an accepted token's two objects hold each name at most +once, so the copy read is the only copy. The parser itself keeps +every pair as written; only the check turns repeats away. ## Model and printing @@ -206,3 +219,6 @@ and `Jws.accept` the command line tool calls; the tests in | A token whose algorithm is outside the allowlist is refused (rule 7) | `Spec.rejects_alg_outside_allowlist` | | A token with a part that is not strict base64url does not parse | `Spec.rejects_part_not_base64url` | | A token whose header is not a JSON object is refused (rule 3) | `Spec.rejects_header_not_object` | +| An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | +| The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | +| The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | diff --git a/Spec.lean b/Spec.lean index 487a848..f027c8c 100644 --- a/Spec.lean +++ b/Spec.lean @@ -157,3 +157,38 @@ theorem Spec.rejects_header_not_object : { allow := ["HS256".toList], now := some 1500000000, required := [], issuer := none, audience := none } | none => false) = false := sorry + +/-! ## Repeated member names + +RFC 7515 section 4 (and step 4 of section 5.2) asks a reader of a +header that repeats a parameter name either to turn it away or to +use the last copy; RFC 7519 section 4 asks the same of the claims +set. This slice turns such a token away. -/ + +/-- An accepted token has a JSON object for its header and for its + claims, and neither object repeats a member name. Members of + objects nested inside them are not covered. -/ +theorem Spec.accept_names_unique (t : Token) (ctx : Context) + (h : accept t ctx = true) : + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup := sorry + +/-- The token whose header is `{"alg":"HS256","alg":"none"}`, with + claims `{"exp":2000000000}` and signature bytes 01 02 03, + parses (the parser keeps both copies) and is refused under + every context. The harness case is "duplicate alg (HS256 then + none): refused". -/ +theorem Spec.rejects_duplicate_alg (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList + = some t ∧ accept t ctx = false := sorry + +/-- The token whose claims are + `{"iss":"joe","exp":2000000000,"iss":"evil"}`, under the header + `{"alg":"HS256"}` and signature bytes 01 02 03, parses and is + refused under every context. The harness case is "duplicate + iss (joe then evil): refused". -/ +theorem Spec.rejects_duplicate_iss (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImlzcyI6ImV2aWwifQ.AQID".toList + = some t ∧ accept t ctx = false := sorry diff --git a/bar.toml b/bar.toml index 6d95408..f410417 100644 --- a/bar.toml +++ b/bar.toml @@ -28,7 +28,7 @@ code = [ "Jws.Context", "Jws.Context.mk", "Jws.Context.allow", "Jws.Context.now", "Jws.Context.required", "Jws.Context.issuer", "Jws.Context.audience", "Jws.ClaimDate", "Jws.ClaimDate.absent", - "Jws.ClaimDate.bad", "Jws.ClaimDate.ok", "Jws.lookup", + "Jws.ClaimDate.bad", "Jws.ClaimDate.ok", "Jws.lookup", "Jws.namesUnique", "Jws.dateClaim", "Jws.issShapeOk", "Jws.audShapeOk", "Jws.audOk", "Jws.accept", # Base64url, strict, no padding. diff --git a/comparator.json b/comparator.json index 2f9f809..f9c86f8 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 8a6012f..2e33acc 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 426 +- Inputs: 438 - Failures: 0 ## The oracle and PyJWT @@ -14,9 +14,9 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 426 -- Agreements with the oracle: 426 -- Of the verdict cases: 53 accepted, 363 rejected, 10 malformed requests answered error +- Total check and request inputs: 438 +- Agreements with the oracle: 438 +- Of the verdict cases: 52 accepted, 376 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases @@ -42,7 +42,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final ## Named differences and pinned readings - Named change from the 2026-10-27 review: an empty signature part is now always turned away (SPEC rule 2). RFC 7515 never produces an empty signature for a signing algorithm, and alg none is turned away on its own, so the none case never reaches acceptance. The earlier reading accepted an empty signature part for a signing algorithm. The fixed case that pinned that reading now expects rejection, a new fixed case empties the A.1 token's signature part under an otherwise passing context and expects rejection, and generated corpus tokens with empty signature parts follow the same new rule in the oracle. -- Claim and header name lookup reads the first copy of a repeated name (the JSON code's List.lookup order). The duplicate iss and alg cases pin this. +- Named change from review 1 item 1: a header or claims object that repeats a member name is turned away (SPEC rule 17; RFC 7515 section 4 and RFC 7519 section 4 allow only that or reading the last copy). The earlier reading took the first copy; the fixed cases that pinned it (duplicate iss, duplicate alg) now expect refusal, and new cases cover duplicate exp, aud, kid, a name that is never read, and a name spelled with a JSON escape. Names inside nested objects are not checked, since the slice reads no member of a nested object; a fixed case pins that. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/__pycache__/run_tests.cpython-313.pyc b/tests/__pycache__/run_tests.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a3b3319dd875d6cfcccdd6fd86f9d55a5df4e03 GIT binary patch literal 49172 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�M(vjYNU&BO4e{Xw3i?d0r8?Wg1KHb;UclJ{6 z@#d#abyqhxO?f-p`#lrQB_#tr-ltCtir(I-uBr3>({(jhJCD~4)n03E5>Fg!>O6Jk z>Sf{5_}S)@M~8bWPL$W3b=Ms)KjIRf?i^|EaSNv!s_Lsw^qoH8t?lVQT~cz*Q&D-z z-#R(rYbkM`zI^n2oA1ha`_pYgUv2Ziu{!VQ=`&ZZ4s}$F4Yhc$o*$|`=6>o}ni%RM&I-QF@d&~>Tx+GI`trE4c#^~Z!^p=oU7{Ml;*$Ge;zSI(TN zDmgw_d&*gH)}Izp#S^`iHN(fe<)z{**x6r84k08b4~5KO1)q$hX-t~B>~&+(l5Gz! zmS!Lwmqy)1p8CI;IT<#x2-x2{>h|XlBFRxhA#G`=?d42OcJ~*Bjm-One_JFFp;z{- zV^Xik^R2&~IqA<{4dY^K{{-*=Giic`e4eQY+l@zi{RI>*7fGIrlDB1# ziEdhVE@D%LY=+REABis-Uf!1v%uw6>dm}L`6S?F}#HUb)Y4j*EoA~n|jKr<%8F$cB znCcK?T|XE_R6UhSSr`pvnr>JLlqJIVnH!3q3LePz=yP>BBlWOYc?N)?W|`B7WvD0!rWw?=A~3|j1{P|G80yj>l+Cna zmfJ4Pj*>SQUpRiuHAS}OJ^VEeoWviayDTd?7wO^s_dfvHJJf5@bh9#R@yauE`$OsF z_tW#{4usMx=(i!1Uh>eWOD}t@(b>x%YIH_Eo8hC?$LE<*O=BLTXWn(+cJ>X;Ew3>T ztv;_f5A8Cqv9esgu8Wrd@<3BI^pxL58=)mW^E*Z)j~l9I4frOJoMTXX@aqngivI?Y z{kbtgB@(c<zhds~nebv~bBoPEW|U5f;Vzh(BbnIapwWP1vvp zWbigLYFLn%yQonckwfZSeKaE@=Zb_m z(yFBtXtaw;AM=c34YiQfKKHqT?o+NGX)To&L{V8$s3>~g^{|0{qXf1xG@x1EsG(F6 zW>6*3ULXyBv?7T{60y>n6O*apC-{h7-ZL02Qe`0C{&e7-I1I;_Y-BSif#HSxkgayf zP|Jn^kqrcrQPpp#%aoYEh^>S^&n0I?jUqWCX-sj6SaA6@3Y%800x=w%PO))RT=#ET z?Fk5epqLA;4**_g2Ma92l*LDfl8KG?5bq!Y+xs6&SW~D5aUFCJKHnq9~B~_yGaDz`Sl0?hz>Ne=Z>f;EYqtA=CB0u3Gqs6VsJ`6fltCQ75p=; zqQ9EDq=!gY+zqktL=XXN%&MPuL(;^o(h|v5<@r3;CTNFD3^Y4bG*EjKQLdsOne%)e zQ;F&}j*mrwb>kx}uQ68q3N?KepSmYm^738dCAx4rkT4{+d-oOtSD{+JOUoGg%h3Us1bV70BLHXR$LMJ_WlHVXB&O8lKi{#DVSnG$vD#xJ=PF(2Cp`ma zOPkIfYxNhZsgnvK&2ccIkZKZV zCD!)kauF3pLfWKQTfsg_bSzMjUiL7Ll_XTSiml?}3R84J0glRXp=t1^UKDqaiVY&b zQ8-NfE#ok}l09EU6?2@cq=?j<(T_^kTAUIlj+PRLjZ;OjRz&zS)Om0_0jml`j$GfC8^z0kIme+_O2$T$D9upMTuluXz5gB~DVp0W8fyqy0 zKe?oV{UnxFnNv|xI*1u06HlIigLNa0Mu#f-9mNjT>Ek1ln~v-L-6V6RK@k=X_#iD} zVn9~@CZc9hesCv-oE3i^eEbtVZkK*=$6u);mN}lB7)uUGquQ68hL2NTlIzq0+zm0( z@fWG6RcB5Zt9wMLX{kEb?uee^;zVnGRV(go<>fKZP8b0D@XNE){wlS5j7VvfUm)Iy z)vlH|VxD)NQj9eGwJK_*X32*!^gD8GOmbGL0_c4GD)uPq-~F9fsB;vDu#ND0cp17d zaE`(6F1haW8^_%4%MNh`W%Or6Kn_s5=k^}rcMvFk7q_rg9c*FhMs_sYh@st8bj%#w zzbhhuydJ}51Y8J37_$e&_0s$Sc1{^9iYtq+aa$Z_kQ06L7e-;C1!#w>7xZ9EO~Vn) zRl&pXpgU}?sHmu}sHpUpMnzFvdO-<6WT`pusZ^FRxW6#g)KoUquAhnA2#hq4;00n$ zW7rKB%p%rmD=J-87%r;>Q307}>WT(Ozh@A0O5rqlWE1&|X%00ipKGFpJj^qypShJ1 zOBQ1$dGgN4KKmQg^rSWmH>{W-q>h=%1)e(b4XsI|9;BgEk&kv9$m6c^@?t)ID5)vG z-ddo*xnBpi4pM3gftb~(Ee z;NVMfItU7Fp){E>4QR$fm3>+WrEOn46oZ=x>|0}FKfN^qCWqq2vYvw5lx@OxgF-d3 zn8*H~W@+p3HK&E#+X_1(sWE|0$yRP^3MU~2lF{k5qMiP~>7)iueLioQ zlGk$Pquo)+np!Lm@gs2SjaguNe~L_tW)^7BGKSp9uxMo)O2(xe0}FGi#}#Dq#mGt}ABq5M@WDum1)QW= zm>ECD)m>tu7QK{_$r3RM&tfue{%p}RI7EwM*kW0?2y^utqkl>HG^BN;|KrtvW? zmN{Ybhw~rsuNuyLXnE_W2w_`D?r23a=Z`S6Wp2^J`OI0g>|&peScotrlAN>si4GA- z@J?$MI_{?4HQp6hY8n=+VV_prq~(*ZaeOrxd5)b$)Vh>KxFDN^%^0zijTZ|`^gEH2r|wu>@B3S$|LmiI05qOU5bZgYD-><5+#k zmaz;2den%$yo#Y&q=BR};;Wp7wDXSn?u+tc2Mco$MZv7z$ql$7P-lW@y zxWSbXxhll&|3mbtnQpYuE&d7%#IY@qStOdsMorpVRe<7#`GEnkMGcGf5wXTs5c8*4 zG>`C8S6Q{t^iILO%({09?wID2XSKI$=8|8p|B~(Pg7-4(mXhisf%oj4Z+fdBlv(yx z!Q9!~##z^|TkaGDGs(GV1lawwt+P$ov(ozV$$)kxH*a}YLvU9^XxF|_?!H%ivlTC2 zT}nFk!}PmXevpL+xBv;9y;VJP{L{zrnymUyf0S9~quKkH)0;C)-%V-GF^H)sq4*BI z@vT#7^qP*_sRv9pknCk<6=5S2lp={YzllkHhu^}xzhXy`O?-+VpT;e0YB}9`#wmp3 zNDJmB?bb6-Vg0b@*s1o@;%^e*vvkX)8%+&yi^9LgQ&D2r09Ohw+DpODC=r|R8Qxq+ zFN?oTph{k0zKkO@hQkKx?$)(E6sZc?+M8^8$bS!W3&i9#-H2F@2tTA6{T?1rqi|ks zPkO2G3yrS~ENAQrX6#xpFJq{s zct3gTy`1ue^o1*LXN7VamvfqeIZbzWhjKbX$;W3*PYC{r!4jACNtz}ubt!ds$Wl1p z6ttA!_XAr}AmiqN+19zN+owXdJ$EJ+&VBv*UBlh(?EM@Kq+4s(03EIo= z+4ugKKI{$Ji;QAmc_|MPr&$R_VoGwg_>_%ez#^RqiH2$%bal` zt4^w8cDPq?JWiLi2DLAVx8pY7)uR+&I zD&MM0P#hdS5sL{}QiZez{l<9UiGsKYm)Ejx8PnA=dgB0<$zw#`(VwqJ`wZC|fc)#F zdWwMq)XY4)9-b^=iyUA6_1f0Wadt$G?dw8qjev^3uthG9kkegVts_q9zTvcXf7cnW zj-)VxdS2ePSG4U^Q5@?^$R4z+zM3n{kANp^JVPg)!G^HERcK;ekNCgX_aPs?qgN;G z^=AoOA1;;7G!rdo8Gb94=}LG2EVY?}YruEWh+ChWNBX4($<(l6$oYftnO&~&!qfGJ z%}dJ~3$$UtNfjT7vP)6OptP_FgiYbYntp72T$soW131}q$rErISspjSX;Lc=IN6lS zzYZr&$dpBv-O2>d#H(`h&*JNq9{^AJDAjoe-F)8ydxYG@R`*06buolqDy(|2s^K^y!c zAj1X>M15g1Z*a6T4Tfz71UROrx%wQoQ4H=C%ZC$n==A%E6y9e> z*+`pCPMtB)?%Bp0jW_qt!fa~WO3sciciirnn+)ZY&9nz9fL@pT7O{yy4!ql9jxjU)%HM zp82kYs^8uB_P)g{cXLBkt)aX&Y!Q!7r7W+`&dz!L@7V6x7Md2sP+?;*r%_5TF?}YU z4=m%p60Ybk9P`zZOc*q*CJi-J&> zM&Xy~=@M=a4&w%UY1%O+LhqvaEUV8A+gjK7sOV!;TGJh?JlMtT znLapjjx|xw^p3uZ9?i!BreXle_SYf70AZb@8#OQ1j(EdlIUxh(BFw!|$0gQMw1?6c zrHcxiu{Hhj_~@8Fg|`pKPOLs*Qp&N@S5y=)(qv}X)XADo*dVmEwT6w1+~R)1caVaO z+$ixMC>0uvg^m3aBV#`CYxLm&f&Dcln+yjSbRrv?@9>~cq=|^I*25;*)UlPQCHNA$ zt+%yVJVc>~>Bg!VQd;APx(*+f23aX<>s#zb#D4^Ne7}r`pTREDn)Fi17fRm1sZ-mg zTOR4O_HEOxKT6m?eI#tlpKkpJLy|G$lLAcwn$JrYXS6E`$uC{HacMR!l(3y#x@OL% z26EPlirkp(Hm6|D_qFHWe14%~!4=wFAIz!eU>_vsyu1xVqJ-2>h}*tpTglySw8Hp8V=%ve6ccJJ(4+3)2v zhmu=B4H^1CDD~is<-R3(DPujVparf&)hQPzlwjTx8OW zR;YPAjY;)qQ=^7S^nKMO4@GF^fUbThhU#<=4&;Dh$gfVC2wQbwsYr(%hkErO0~wzq zV*7T)H!>M!7aYdHXQ^@!gc(z1$laKBG}2?K&I>Vjfw+`i>I51VgGd+TUzN!x_1gs{ z9a$S=^T5?nWizPo>I9bhZ7mM2CU1@+Wg0IAfnFp2O!zaa%NQzNq)NgD7bXk4`D7tc zVI+0T;*GOTMidq`fs9kXZh)MF% zKjzDXoitmt>Jk)3dUEQ_Z!jR8c1&pIa@^i{IMg!$Z$cE_zxx;AO^Cpo=uJTVz%^W~ zl182nunvUc@XVoE2SRaYwl2hzal}V(+V)ih#H+LjtG3TrFl-9>fN@t#QU z)dtde<(9H;j*~9r$#^yepS)39+7yNq6@ydp1hA=?Po{3_6U+N#9BC1hTus?@Mp-tn zequ@)5qL6J)le3DSEOA8BwO^AaaXewP35+s2C%KqPgW2;(ks(g^~ZmRxrHo~BW zMQ{Zq-li~b>pn;OP|o2RYe1$a~9?R0rljF6@+Br85;%c@F02 zF>&p*oGfYC`761nwL%^oR>qcq`Oaj%#NL(4m)O-Do;=KD^Exq~OtGCRu75F(T~EZZ z`-u>RPlPCXBE%k*6W42(tN6^I)WSv0dVR-}X}I@^Ig7-B9+VDQlr)R^b*Ns<>slyr zTKDT^^AeSZdaKHOvhvrUdwf zGyR~cp+?uy`QUgd4(UrymYkMI4RD9%i1Fkz8ert-O0zU{Uc0i`n}9>h>QB)=CwCt< z1@~g7kF+UdA2WHOgUtClEer>7RxO72h5>8`VN)Xh9o*Fn-y%T~naB4=8f0Jf;m&3Z z{3-CPgcVDMT#E2uKg!0fHg(whTAJ_Zf@uy6eSH(dgv#)>ub}9nZx$ ztNpoBM-vQ&+}LtVW zrhr4+*?9^$y58*>!ReZ0cLYOfGUnvwND(JxI|`*^;dl0z?(B!%KQo7vY@sMAZj^}g zHE`yJWNNe%mfn$8jWo3pVLW6laR|sMRf^wU;y6Zbvp`|IC6qYY;SmcnGW*+>t2icMF3Xw{IWy+$q|_V8!de$LmQtJLRlu6BOR;(_rjQ7WSDfJib`TYV27esb zriEs{eExzGN7pEADCX9_#6e_iM@^d>%Q3!llsK9c^sBSPAkLu~(eX3b-^tmIZBJ)h zs%$r4`Qn?@s2w7cinXzvC~YG5O~UpLyFSUguAex(v%SSpS5r}gs=bB)Hec@Ajv^83 zlZy*!>5I(0DF=WDnP9I60ZEu7hbq{vq(~?!#y-Z2h%(^uf(Dcj+br5tTFa`nskDx~ zN%NyqFw^OQ4WFNxvCC;zYZ)fP+mFPN+oKb9m-@B@2G;) z6}c1)2bG0pF`3tE_CWZ^9|{YW8Jw9ug5oHpw6UK+Sc;`lliryB;FI2!c!-+x#(b9S zvN_5rGy46=32xRgM7v`CJ?arYhg*z^ZP@C2yb*SY^4VcuL`Ik^Q0|#I=ioby^Ddm- z&^6cf>lfc?lqRp!GSO+ZHyt|>){S2F>q{IL{JLix{z5RrDQudF;%-(^k{bl-v|yF# zb3KP%x8_%2Apq zJK8XkWEH}q4oZvgEsTJ}bwA59$4wFd^H7ZbL z?h!`?*h}`*f~x0k6Xc_uSdYcGZ_(~(=^!t@jVqc1qK>>lKhX5$doIRY7o^gu>!i?h>ez5 z5-oN_?3^1OEQ;U{I$4-wLWPF5Vi-?<~DYmnsP)J zG!o+Nh|PBU@Ij>yQHIl~)ZdH#7Q|z1obwdte+Yjl&Me>0Q6MR_*hm9$0PN45qA%1X zxRicf0w&JyL)+-ZMkVg`qtyS65=Sd-VwPBq%5!O=e-KRoeFSsAz&kCxF&d#IH9DTF zB<~B)F)n&e-Ka-S74T6qhUbn2qz%_95U;-HBD^R(hbqKa0D3BGXmGgdU_z?bP0e;v z8V2BVssDV5q@79HPlV#)C?ugLk0u7mDivb@kdL=OQW3q5No;9l{v}X8-nOxW63V#W zhTcNfmi@XChu_rb7(`#~kAw0HFN}Z}|B$+{45$+R#I81>tE96{aGvNAFaq%JW~@l4 zP&`HX;qT;@R#C!BWale9OjdCC8`EMyR8nfV&fdT+Mx}jDfT9M%&J&Pm#!j{1v7^&v zdkr3t6}sO@onvEBhWICV!>Iz-=(r1PL!fgt;D=?(CkClxWWdSR7sVgb>-%)0)sL{= z?->(+M6Z8NH$UC}D+Som@Mzl=j{ZVFu!rK4(_4N9!eFZMQP%T z6rhLVDBhzdvsB)&MJ80(A`^)YIG`@UjROuCaeT~sr1%j-f%*}9kX~5Z2P0*YFd}Ig zVJmy?A#~w5=Bt<72b008c$s++=6VRxCTii8zetO(fX4Bg<3L}jhZgcPOdPn|E}-%{onS#XMvaKr2B^Sdxp&WhKzfLtUoI#x@X_>8*R5P1YEPO zF9>h8(UEm~?k8m645`uSBXryW&MSa>)|!|7IG>DREBjW}!sIvi|1kB3&L3niRh|eY zoPalcTDg3L)y5qkB__cgDtt{RCds$>1RPr6$p32FeBJwws-Pw3M&eA`%$3h(4XU(^^-)LTmPrh++#hx~+y>W7eJYUZm z0$Xo1e-!cM*4CNkKgYVO2(Xt<-#9(nHCMHiQ}VvO^y6GjZuL(JG+VZZ>~&u<1}f$% z-c0-2jyHEKq%G&xz668p>9!?%-3QxthLR6`se4vS2e$prjypRR)0T@4zLtBtd%9yO z`Ors6DbvSR4Dri`w4fnvDZTBUpCLj1vn1m z2IC1W)|eIK7r)vXOfS4=C{nT!c=oG(-`M$;OTqN=dxi?-^HTcGZ|LWbemy>zUUSb- zt9*~n-cow$Jww?#fe$rt8QVTd(d6!(ZwNYSzU9AL7_2+KwBy9bsc{)6k-0eIDec2F zC3CRwK%Re`mA8~%7tE?hxt8*4gIRU=v$nngE1Z47oc*D!18_T+b?9TGA>$C0?r>D; zqUamAaL31CF|>kxB8d1RK0Jh#wiVVI@9&E@R1FNFhZOu!ntbqcd3Ig7JA@?N};n2^O^CeJN{i zFuoW~G`{p>z0Ouj6evSoL74Uh#GB8|Xdyml1_M{#w`@UC61M(TTFy)b+@a6q;`Zrd z9ETpi_ots^Ym&0zc`+_s5o2gFi}~20B`y{Q^PBIsF6A7%XYcsXo`&WUm;O(Wwrk?+ zKMf}zVy8QQFFB#xVEVxhTensB!-kyhTElU*&GWZ64`c=BUO1E#(?b~$w1G+IqfvE>fZy;+a z=Eym0JJa;kiPKH3J)N{wncTg})kDOERDOVS7_WPus~eT~GB0$Br<6abeSu z)2CZ|j!M60M1jghc6MPsV8VvxCZR2?KXv*%-xKrOlpwi_<0f0AcW_;#kiMx}4fW+> z5kkWTs6gX<=Uf3nV^$x8-4BP27`Po2zX(A5*Z6vp;95bi)oMRB?9v(@pV4Zx2_I@~ zA8O)1)Wm(LN&Zk{{WHzhKhtFWh1oW9^u^CEo3{nc+d}3Y%jR7{^R8ucNzhy}ZCEi` zXU@Oi4NL}2+vn1Qrd{*6l}_t%TF$iL;}nfOaVha^$ksV+d1Ok|CO#_AY7c#4(Q4yn zJZKl%xJP(Se`HC}<~_>G)h0eXs@0gcOzR(+c4{*pozcc=lOAOmwM7q;G*-@RSv!PMi6$Nu#y?%g9l?s9WrivynDCP8r5IJ8$rn>YcUZ|d!sPWb)#&yZ7%or(V!L9h(YU~dFMjKo$jEuX2lvG zFwacQ=COJMnfFL!E|KF3H%e!*lkj#uP7s^x-&EYPm~s1p)}r~gh1@&HMfC!5(YS8n z6;D90AT0FGpA1?X7F&YWL+hd`k3>@yv_g*H(A+E-CFafL1g#|t`k=LH!M)h@_E6Az za9uQ|6k-gxZdqoNXHx?9pfzU}+0UDE&R4t%B5oE>!JK=(>CK^q)Hl5g6N_ixz82iI zD(_0Wam*FWXTHf2R%=HI&7G^9^Ua^1Keiw)R^Ls#r)g)^osV`|wUD_Gw^+G2ez)tM zrh|P36;f~zS;Z}6b=w?@oX_(Sw64M*QTtVqlv8RSREibF%Gzb@N_PjXd)FZ>#w51g z5P8_#M?4zEXT&RNH1}NBCvgVt-j8`oMIWFUox_=O=R;Po=^6&Mu>2Gf)Wx~l`6?W6 z5d(@#69)D4T*iEAY+M{lO3^vj60#Pqi%Wv)p7Vv)E%cI=pqA3h7BUu*m~4 zbq%Cx=Gtru#;(-_Rs>DEq-s%5J#3v6rR)PMwp$i&L_eheB#@m9Bq5L)h?}jP9S4Jd*%L$|gV#Ff98wY90F-$l;I`J+4|qZIPAO5)VhP(_qK{&4Fu2JaNB7)af6=D}d*!Nti?=8-5eDbQT1 z9AwbZZ3RJyTf2;m>_gw$$&)(@?7!H-RU|w`?Ftn|7LATJ1f#$Y) z+9uNgF9vZ9D~(OW&@EbO;lP{wWDFP@Ff50)gr-SLc$%~;&Bq84CO0{vas|ZthpDc=Vu+Xq$-GUhuX=elGKj-}cYp^}q77m^)wDRbxVX-Ynza_*Tw zvVfNZOtWlb^ Date: Wed, 28 Oct 2026 17:19:58 +0000 Subject: [PATCH 06/23] Turn away any header that carries a crit member 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 7 ++++- Proofs/Check.lean | 71 ++++++++++++++++++++++++++++-------------- Proofs/SpecProofs.lean | 15 +++++++-- README.md | 10 +++++- SPEC.md | 12 +++++++ Spec.lean | 24 ++++++++++++++ comparator.json | 2 +- tests/TEST-RESULTS.md | 9 +++--- tests/run_tests.py | 54 ++++++++++++++++++++++++++++++++ 9 files changed, 171 insertions(+), 33 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index b375de0..3b43a60 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -256,7 +256,11 @@ def audOk (claims : Json) (a : List Char) : Bool := /-- Run the checks. Every rule must hold; SPEC.md states them one by one. This checks the token's shape and claims only. It does not verify the signature, and a true answer here is not - a verified token. -/ + a verified token. + + The header may not carry `crit` at all: this slice understands + no extension, and RFC 7515 section 4.1.11 makes a token invalid + when its `crit` names one the reader does not understand. -/ def accept (t : Token) (ctx : Context) : Bool := if ctx.allow.contains "none".toList then false else @@ -264,6 +268,7 @@ def accept (t : Token) (ctx : Context) : Bool := | .obj hkvs, .obj ckvs => namesUnique (hkvs.map Prod.fst) && namesUnique (ckvs.map Prod.fst) && + !(hkvs.map Prod.fst).contains "crit".toList && match lookup "alg".toList t.header with | some (.str alg) => alg != "none".toList && diff --git a/Proofs/Check.lean b/Proofs/Check.lean index c25ace0..7387898 100644 --- a/Proofs/Check.lean +++ b/Proofs/Check.lean @@ -18,48 +18,71 @@ theorem namesUnique_iff (ns : List (List Char)) : namesUnique ns = true ↔ ns.N rw [namesUnique, Bool.and_eq_true, ih, List.nodup_cons, Bool.not_eq_true', Bool.eq_false_iff, ne_eq, List.contains_iff_mem] -/-- An accepted token has a JSON object for its header and for its - claims, and neither object repeats a member name. -/ -theorem accept_names_unique (t : Token) (ctx : Context) (h : accept t ctx = true) : +/-- The shape rules of the check that do not depend on the context: + the header and the claims are objects, neither repeats a member + name, and the header has no `crit` member. -/ +def shapeOk (t : Token) : Bool := + match t.header, t.claims with + | .obj hkvs, .obj ckvs => + namesUnique (hkvs.map Prod.fst) && namesUnique (ckvs.map Prod.fst) && + !(hkvs.map Prod.fst).contains "crit".toList + | _, _ => false + +/-- An accepted token passes the shape rules. -/ +theorem accept_shape (t : Token) (ctx : Context) (h : accept t ctx = true) : ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ - (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup := by + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup ∧ + "crit".toList ∉ hkvs.map Prod.fst := by obtain ⟨hd, cl, sig⟩ := t unfold accept at h split at h · cases h · split at h · rename_i hkvs ckvs hh hc - rw [Bool.and_eq_true, Bool.and_eq_true] at h - exact ⟨hkvs, ckvs, hh, hc, (namesUnique_iff _).mp h.1.1, (namesUnique_iff _).mp h.1.2⟩ + simp only [Bool.and_eq_true, Bool.not_eq_true', Bool.eq_false_iff, ne_eq, + List.contains_iff_mem] at h + exact ⟨hkvs, ckvs, hh, hc, (namesUnique_iff _).mp h.1.1.1, + (namesUnique_iff _).mp h.1.1.2, h.1.2⟩ · cases h -/-- The repeated name test on a token's two objects, as a value: - true when both parts are objects and neither repeats a name. -/ -def namesOk (t : Token) : Bool := - match t.header, t.claims with - | .obj hkvs, .obj ckvs => namesUnique (hkvs.map Prod.fst) && namesUnique (ckvs.map Prod.fst) - | _, _ => false +/-- An accepted token has a JSON object for its header and for its + claims, and neither object repeats a member name. -/ +theorem accept_names_unique (t : Token) (ctx : Context) (h : accept t ctx = true) : + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup := by + obtain ⟨hkvs, ckvs, hh, hc, n1, n2, _⟩ := accept_shape t ctx h + exact ⟨hkvs, ckvs, hh, hc, n1, n2⟩ + +/-- An accepted token's header has no `crit` member. -/ +theorem accept_no_crit (t : Token) (ctx : Context) (h : accept t ctx = true) : + ∃ hkvs, t.header = .obj hkvs ∧ "crit".toList ∉ hkvs.map Prod.fst := by + obtain ⟨hkvs, _, hh, _, _, _, hcrit⟩ := accept_shape t ctx h + exact ⟨hkvs, hh, hcrit⟩ -/-- A token that fails the repeated name test is refused under - every context. -/ -theorem accept_false_of_names (t : Token) (ctx : Context) (h : namesOk t = false) : +/-- A token that fails the shape rules is refused under every + context. -/ +theorem accept_false_of_shape (t : Token) (ctx : Context) (h : shapeOk t = false) : accept t ctx = false := by cases hp : accept t ctx · rfl - · obtain ⟨hkvs, ckvs, hh, hc, n1, n2⟩ := accept_names_unique t ctx hp - unfold namesOk at h + · obtain ⟨hkvs, ckvs, hh, hc, n1, n2, hcrit⟩ := accept_shape t ctx hp + unfold shapeOk at h rw [hh, hc] at h dsimp only at h rw [(namesUnique_iff _).mpr n1, (namesUnique_iff _).mpr n2] at h + have : (hkvs.map Prod.fst).contains "crit".toList = false := by + rw [Bool.eq_false_iff, ne_eq, List.contains_iff_mem] + exact hcrit + rw [this] at h cases h -/-- A text that parses to a token failing the repeated name test: - the token is there, and every context refuses it. The premise - is a closed computation, checked in the kernel for each - concrete text. -/ -theorem refused_of_names (s : List Char) (ctx : Context) +/-- A text that parses to a token failing the shape rules: the + token is there, and every context refuses it. The premise is a + closed computation, checked in the kernel for each concrete + text. -/ +theorem refused_of_shape (s : List Char) (ctx : Context) (h : (match parseToken s with - | some t => !namesOk t + | some t => !shapeOk t | none => false) = true) : ∃ t, parseToken s = some t ∧ accept t ctx = false := by cases hp : parseToken s with @@ -67,7 +90,7 @@ theorem refused_of_names (s : List Char) (ctx : Context) | some t => rw [hp] at h dsimp only at h - refine ⟨t, rfl, accept_false_of_names t ctx ?_⟩ + refine ⟨t, rfl, accept_false_of_shape t ctx ?_⟩ simpa using h end Jws.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index f94cc16..380bf92 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -125,10 +125,21 @@ theorem Spec.rejects_duplicate_alg (ctx : Context) : ∃ t, parseToken "eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList = some t ∧ accept t ctx = false := - Jws.Proofs.refused_of_names _ ctx (by decide +kernel) + Jws.Proofs.refused_of_shape _ ctx (by decide +kernel) theorem Spec.rejects_duplicate_iss (ctx : Context) : ∃ t, parseToken "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImlzcyI6ImV2aWwifQ.AQID".toList = some t ∧ accept t ctx = false := - Jws.Proofs.refused_of_names _ ctx (by decide +kernel) + Jws.Proofs.refused_of_shape _ ctx (by decide +kernel) + +theorem Spec.accept_no_crit (t : Token) (ctx : Context) + (h : accept t ctx = true) : + ∃ hkvs, t.header = .obj hkvs ∧ "crit".toList ∉ hkvs.map Prod.fst := + Jws.Proofs.accept_no_crit t ctx h + +theorem Spec.rejects_crit_b64 (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList + = some t ∧ accept t ctx = false := + Jws.Proofs.refused_of_shape _ ctx (by decide +kernel) diff --git a/README.md b/README.md index 4cde4eb..fe7a4f8 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ where they end in `sorry`) and proved in | 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 round trip rests on the proofs in `Proofs/Token.lean`, where it is @@ -148,7 +150,7 @@ directly, the same functions the theorems are about. - Speed. The code works on lists, which suits proofs, not large tokens. -Two readings worth naming, both stated in `SPEC.md` and +Three readings worth naming, all stated in `SPEC.md` and pinned by tests: - An empty signature part is well formed base64url, but @@ -167,6 +169,12 @@ pinned by tests: 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 `crit` member is turned away whatever + `crit` holds (`SPEC.md` rule 18): this slice + understands no extension, and RFC 7515 section 4.1.11 + makes a token invalid when `crit` names one the reader + does not understand. `Spec.accept_no_crit` proves it + for every accepted token. ## What you must trust diff --git a/SPEC.md b/SPEC.md index 18eff0d..e0c256c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -133,6 +133,16 @@ The token is accepted only when every rule holds: value of a private claim that is itself an object) are not checked for repeats: no rule reads a member of a nested object. +18. The header has no `crit` member, whatever its value (a list + of names, an empty list, or anything else). `crit` lists the + extensions a reader must understand, and RFC 7515 section + 4.1.11 makes the JWS invalid when it names one the reader + does not. This slice understands no extension, so every + `crit` header is turned away. That includes RFC 7797's + `"b64":false`, which changes what the signature covers. + Other header parameters besides `alg` (`typ`, `kid`, `b64` + without `crit`, and any other) are not read, as RFC 7515 + section 4 asks for parameters a reader does not understand. When no current time is supplied, rules 10 and 11 are skipped; every other rule, including the type rule 9, still applies. @@ -222,3 +232,5 @@ and `Jws.accept` the command line tool calls; the tests in | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | +| An accepted token's header has no `crit` member (rule 18) | `Spec.accept_no_crit` | +| The token with header `{"alg":"HS256","b64":false,"crit":["b64"]}` parses and is refused under every context (rule 18) | `Spec.rejects_crit_b64` | diff --git a/Spec.lean b/Spec.lean index f027c8c..c4a4c0a 100644 --- a/Spec.lean +++ b/Spec.lean @@ -192,3 +192,27 @@ theorem Spec.rejects_duplicate_iss (ctx : Context) : ∃ t, parseToken "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImlzcyI6ImV2aWwifQ.AQID".toList = some t ∧ accept t ctx = false := sorry + +/-! ## The `crit` header parameter + +RFC 7515 section 4.1.11: when `crit` lists an extension the +reader does not understand, the JWS is invalid. This slice +understands no extension, so it turns away every header that has +a `crit` member, whatever its value. -/ + +/-- An accepted token's header is an object with no `crit` + member. -/ +theorem Spec.accept_no_crit (t : Token) (ctx : Context) + (h : accept t ctx = true) : + ∃ hkvs, t.header = .obj hkvs ∧ "crit".toList ∉ hkvs.map Prod.fst := sorry + +/-- The token whose header is + `{"alg":"HS256","b64":false,"crit":["b64"]}` (RFC 7797's + unencoded payload option, which changes what the signature + covers), with claims `{"exp":2000000000}` and signature bytes + 01 02 03, parses and is refused under every context. The + harness case is "crit naming b64: refused". -/ +theorem Spec.rejects_crit_b64 (ctx : Context) : + ∃ t, parseToken + "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList + = some t ∧ accept t ctx = false := sorry diff --git a/comparator.json b/comparator.json index f9c86f8..c8eb6f3 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 2e33acc..c951e70 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 438 +- Inputs: 448 - Failures: 0 ## The oracle and PyJWT @@ -14,9 +14,9 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 438 -- Agreements with the oracle: 438 -- Of the verdict cases: 52 accepted, 376 rejected, 10 malformed requests answered error +- Total check and request inputs: 448 +- Agreements with the oracle: 448 +- Of the verdict cases: 54 accepted, 384 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases @@ -43,6 +43,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from the 2026-10-27 review: an empty signature part is now always turned away (SPEC rule 2). RFC 7515 never produces an empty signature for a signing algorithm, and alg none is turned away on its own, so the none case never reaches acceptance. The earlier reading accepted an empty signature part for a signing algorithm. The fixed case that pinned that reading now expects rejection, a new fixed case empties the A.1 token's signature part under an otherwise passing context and expects rejection, and generated corpus tokens with empty signature parts follow the same new rule in the oracle. - Named change from review 1 item 1: a header or claims object that repeats a member name is turned away (SPEC rule 17; RFC 7515 section 4 and RFC 7519 section 4 allow only that or reading the last copy). The earlier reading took the first copy; the fixed cases that pinned it (duplicate iss, duplicate alg) now expect refusal, and new cases cover duplicate exp, aud, kid, a name that is never read, and a name spelled with a JSON escape. Names inside nested objects are not checked, since the slice reads no member of a nested object; a fixed case pins that. +- Named change from review 1 item 2: a header with a `crit` member, of any value, is turned away (SPEC rule 18). The slice understands no extension, and RFC 7515 section 4.1.11 makes a token invalid when `crit` names one the reader does not understand. Fixed cases cover b64, an unknown name, a URI name, an empty list, a string, null, and an escaped spelling; `crit` as a claim name is not a header member and passes. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/run_tests.py b/tests/run_tests.py index 67c308c..143337b 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -272,6 +272,11 @@ def oracle_check(token_text, now, allow, required, issuer, audience): # A repeated member name in either object turns the token # away (SPEC rule 17; RFC 7515 section 4, RFC 7519 section 4). return None + if any(k == "crit" for (k, _) in header[1]): + # The slice understands no extension, so a crit member of + # any value turns the token away (SPEC rule 18; RFC 7515 + # section 4.1.11). + return None alg = lookup("alg", header) if alg is None or alg[0] != "str": return None @@ -642,6 +647,46 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): add_check("repeated name inside a nested object, not read: accepted", claims_token(b'{"exp":2000000000,"ctx":{"a":1,"a":2}}'), NOW, ["HS256"], [], None, None) + +# The crit header parameter: any crit member is refused, since the +# slice understands no extension (SPEC rule 18). Review 1 item 2. +add_check("crit naming b64: refused", + "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19." + "eyJleHAiOjIwMDAwMDAwMDB9.AQID", + NOW, ["HS256"], [], None, None) +add_check("crit naming b64, longer signature: refused", + hand_token(b'{"alg":"HS256","b64":false,"crit":["b64"]}', + GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit naming an unknown extension: refused", + hand_token(b'{"alg":"HS256","crit":["x-unknown"],"x-unknown":1}', + GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit naming a URI extension (RFC 7515 E shape): refused", + hand_token(b'{"alg":"HS256","crit":["http://example.invalid/' + b'UNDEFINED"],"http://example.invalid/UNDEFINED":true}', + GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit as an empty list: refused", + hand_token(b'{"alg":"HS256","crit":[]}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit not a list: refused", + hand_token(b'{"alg":"HS256","crit":"exp"}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit null: refused", + hand_token(b'{"alg":"HS256","crit":null}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit spelled with an escape: refused", + hand_token(b'{"alg":"HS256","\\u0063rit":["b64"],"b64":false}', + GOOD_P, SIG), + NOW, ["HS256"], [], None, None) +add_check("crit as a claim name, not a header name: accepted", + claims_token(b'{"crit":["b64"],"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("b64 false without crit (not understood, not critical):" + " accepted", + hand_token(b'{"alg":"HS256","b64":false}', GOOD_P, SIG), + NOW, ["HS256"], [], None, None) add_check("payload whitespace and key order kept in the reprint", claims_token(b'{ "exp" : 2000000000 , "iss" : "joe" }'), NOW, ["HS256"], [], "joe", None) @@ -935,6 +980,15 @@ def main(): " escape. Names inside nested objects are not" " checked, since the slice reads no member of a" " nested object; a fixed case pins that.") + lines_out.append("- Named change from review 1 item 2: a header with" + " a `crit` member, of any value, is turned away" + " (SPEC rule 18). The slice understands no" + " extension, and RFC 7515 section 4.1.11 makes a" + " token invalid when `crit` names one the reader" + " does not understand. Fixed cases cover b64, an" + " unknown name, a URI name, an empty list, a" + " string, null, and an escaped spelling; `crit` as" + " a claim name is not a header member and passes.") lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") lines_out.append("- Date claims are judged by value, not by spelling:" From f1bb383fea5b0a6c58d0cb4cb698e3e4b7ec9d9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:23:13 +0000 Subject: [PATCH 07/23] Remove a stray Python bytecode file and ignore __pycache__ 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- .gitignore | 1 + tests/__pycache__/run_tests.cpython-313.pyc | Bin 49172 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 tests/__pycache__/run_tests.cpython-313.pyc diff --git a/.gitignore b/.gitignore index da49d2d..7b035d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .lake/ venv/ +__pycache__/ diff --git a/tests/__pycache__/run_tests.cpython-313.pyc b/tests/__pycache__/run_tests.cpython-313.pyc deleted file mode 100644 index 6a3b3319dd875d6cfcccdd6fd86f9d55a5df4e03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49172 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�M(vjYNU&BO4e{Xw3i?d0r8?Wg1KHb;UclJ{6 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 Date: Wed, 28 Oct 2026 17:23:29 +0000 Subject: [PATCH 08/23] Keep the received parts; answer with the received token; prove print(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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 44 +++++++++++---- Main.lean | 9 ++- Proofs/SpecProofs.lean | 22 ++++++-- Proofs/Token.lean | 122 +++++++++++++++++++++++++++++++++-------- README.md | 47 ++++++++++------ SPEC.md | 60 +++++++++++++------- Spec.lean | 40 +++++++++++--- bar.toml | 8 ++- comparator.json | 2 +- tests/TEST-RESULTS.md | 1 + tests/run_tests.py | 99 ++++++++++++++++----------------- 11 files changed, 314 insertions(+), 140 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index 3b43a60..8626d84 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -44,25 +44,47 @@ def joinSep (sep : Char) : List (List Char) → List Char | [p] => p | p :: q :: ps => p ++ sep :: joinSep sep (q :: ps) -/-- A parsed token: the header value, the claims value, and the - signature bytes, returned unchanged. -/ +/-- A token. `headerSeg` and `payloadSeg` are the first two parts + as received, base64url text, kept exactly: with the dot between + them they are the JWS Signing Input (RFC 7515 section 5.2 step + 8), which a signature verifier needs byte for byte. `header` and + `claims` are the JSON values those two parts decode to, and + `sig` is the signature bytes, returned unchanged. A token comes + from `parseToken` (a received text) or `makeToken` (values to + send); the theorems in `Spec.lean` say how each fills the + fields. -/ structure Token where + headerSeg : List Char + payloadSeg : List Char header : Json claims : Json sig : List UInt8 -/-- Print a token: each JSON part in the canonical printer's - form, UTF-8 bytes, base64url without padding, the parts - joined by dots, the signature last. -/ +/-- Print a token: the two received parts as they are held, then + the signature bytes in base64url without padding, joined by + dots. For a parsed token this gives back the received text + exactly (`Spec.printToken_parse`); it never re-encodes the + JSON values. -/ def printToken (t : Token) : List Char := - joinSep '.' [Base64url.encode (Utf8.encode (Json.print t.header)), - Base64url.encode (Utf8.encode (Json.print t.claims)), - Base64url.encode t.sig] + joinSep '.' [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] + +/-- The token for given header and claims values and signature + bytes, with each JSON part written in the canonical printer's + form (no spaces, pairs in the order held), as UTF-8, in + base64url without padding: the parts a sender using this + printer would send. -/ +def makeToken (header claims : Json) (sig : List UInt8) : Token := + { headerSeg := Base64url.encode (Utf8.encode (Json.print header)) + payloadSeg := Base64url.encode (Utf8.encode (Json.print claims)) + header := header + claims := claims + sig := sig } /-- Parse the compact form. Framing first: exactly three parts. Each part must decode as strict base64url, the first two must - then decode as UTF-8 and parse as JSON. Object shape and the - claim rules are the checker's work, not the parser's. -/ + then decode as UTF-8 and parse as JSON. The first two parts are + kept as received. Object shape and the claim rules are the + checker's work, not the parser's. -/ def parseToken (cs : List Char) : Option Token := match splitSep '.' cs with | [h, p, s] => @@ -71,7 +93,7 @@ def parseToken (cs : List Char) : Option Token := match Utf8.decode hb, Utf8.decode pb with | some hc, some pc => match Json.parse hc, Json.parse pc with - | some hv, some pv => some ⟨hv, pv, sb⟩ + | some hv, some pv => some ⟨h, p, hv, pv, sb⟩ | _, _ => none | _, _ => none | _, _, _ => none diff --git a/Main.lean b/Main.lean index 09da9f2..78c33a9 100644 --- a/Main.lean +++ b/Main.lean @@ -18,9 +18,12 @@ import Jws Field 5: the expected issuer, or empty. Field 6: the expected audience, or empty. - The answer is `ok:` and the codes of the canonical reprinted - token when the token is accepted, `none` when it is not, and - `error` when the request itself is malformed. + The answer is `ok:` and the codes of the token text as + received when the token is accepted (`Jws.printToken` of the + parsed token, which `Spec.printToken_parse` proves is the + received text, so its signing input still matches its + signature), `none` when it is not, and `error` when the request + itself is malformed. Full statement of the format and the rules: SPEC.md. -/ diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index 380bf92..274cf78 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -15,10 +15,24 @@ import Proofs.Check open Jws -theorem Spec.parseToken_print (t : Token) (hh : Json.WF t.header) - (hc : Json.WF t.claims) : - parseToken (printToken t) = some t := - Jws.Proofs.parseToken_print t hh hc +theorem Spec.printToken_parse (s : List Char) (t : Token) + (h : parseToken s = some t) : + printToken t = s := + Jws.Proofs.printToken_parse s t h + +theorem Spec.parseToken_reads (s : List Char) (t : Token) + (h : parseToken s = some t) : + splitSep '.' s = [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] ∧ + (∃ hb hcs, Base64url.decode t.headerSeg = some hb ∧ + Utf8.decode hb = some hcs ∧ Json.parse hcs = some t.header) ∧ + (∃ pb pcs, Base64url.decode t.payloadSeg = some pb ∧ + Utf8.decode pb = some pcs ∧ Json.parse pcs = some t.claims) := + Jws.Proofs.parseToken_reads s t h + +theorem Spec.parseToken_print (h c : Json.Json) (sig : List UInt8) + (hh : Json.WF h) (hc : Json.WF c) : + parseToken (printToken (makeToken h c sig)) = some (makeToken h c sig) := + Jws.Proofs.parseToken_print h c sig hh hc theorem Spec.decode_encode (bs : List UInt8) : Base64url.decode (Base64url.encode bs) = some bs := diff --git a/Proofs/Token.lean b/Proofs/Token.lean index 1abdee7..18ec8c6 100644 --- a/Proofs/Token.lean +++ b/Proofs/Token.lean @@ -82,33 +82,109 @@ theorem encode_ne_dot : ∀ (bs : List UInt8) (c : Char), intro c h cases h -/-- The printed token splits into its three printed parts. -/ -theorem splitSep_print (t : Token) : - splitSep '.' (printToken t) = - [Base64url.encode (Utf8.encode (Json.print t.header)), - Base64url.encode (Utf8.encode (Json.print t.claims)), - Base64url.encode t.sig] := by - have n1 : ∀ c ∈ Base64url.encode (Utf8.encode (Json.print t.header)), c ≠ '.' := - fun c hc => encode_ne_dot _ c hc - have n2 : ∀ c ∈ Base64url.encode (Utf8.encode (Json.print t.claims)), c ≠ '.' := - fun c hc => encode_ne_dot _ c hc - have n3 : ∀ c ∈ Base64url.encode t.sig, c ≠ '.' := - fun c hc => encode_ne_dot _ c hc - simp only [printToken, joinSep] +/-- A token made from values prints to the three canonical parts, + and splits back into them. -/ +theorem splitSep_print_make (h c : Json.Json) (sig : List UInt8) : + splitSep '.' (printToken (makeToken h c sig)) = + [Base64url.encode (Utf8.encode (Json.print h)), + Base64url.encode (Utf8.encode (Json.print c)), + Base64url.encode sig] := by + have n1 : ∀ x ∈ Base64url.encode (Utf8.encode (Json.print h)), x ≠ '.' := + fun x hx => encode_ne_dot _ x hx + have n2 : ∀ x ∈ Base64url.encode (Utf8.encode (Json.print c)), x ≠ '.' := + fun x hx => encode_ne_dot _ x hx + have n3 : ∀ x ∈ Base64url.encode sig, x ≠ '.' := + fun x hx => encode_ne_dot _ x hx + simp only [printToken, makeToken, joinSep] rw [splitSep_append _ _ _ (fun hm => n1 _ hm rfl), splitSep_append _ _ _ (fun hm => n2 _ hm rfl), splitSep_of_not_mem _ _ (fun hm => n3 _ hm rfl)] -/-- The plan's main claim: parsing a printed token gives the - token back, for tokens whose JSON parts are well formed in - the json slice's sense. The proof is a chain of the three - round trips under it: the JSON text round trip, the UTF-8 - round trip, and the base64url round trip, with the split and - join of the three parts read back by the two lemmas above. -/ -theorem parseToken_print (t : Token) (hh : Json.WF t.header) (hc : Json.WF t.claims) : - parseToken (printToken t) = some t := by - obtain ⟨h, c, s⟩ := t - simp only [parseToken, splitSep_print, Base64url.Proofs.decode_encode, +/-- Parsing the printed form of a token made from well formed JSON + values gives that token back. The proof is a chain of the three + round trips under it: the JSON text round trip, the UTF-8 round + trip, and the base64url round trip, with the split and join of + the three parts read back by the lemmas above. -/ +theorem parseToken_print (h c : Json.Json) (sig : List UInt8) (hh : Json.WF h) + (hc : Json.WF c) : + parseToken (printToken (makeToken h c sig)) = some (makeToken h c sig) := by + simp only [parseToken, splitSep_print_make, Base64url.Proofs.decode_encode, Utf8.Proofs.decode_encode, Json.Proofs.parse_print hh, Json.Proofs.parse_print hc] + rfl + +/-- Splitting never gives the empty list of parts. -/ +theorem splitSep_ne_nil (sep : Char) (cs : List Char) : splitSep sep cs ≠ [] := by + cases cs with + | nil => simp [splitSep] + | cons c rest => + rw [splitSep] + split + · simp + · split <;> simp + +/-- Joining the parts of a split gives the text back. -/ +theorem joinSep_splitSep (sep : Char) : ∀ cs : List Char, + joinSep sep (splitSep sep cs) = cs + | [] => rfl + | c :: rest => by + have ih := joinSep_splitSep sep rest + rw [splitSep] + split + · rename_i hc + subst hc + cases hs : splitSep c rest with + | nil => exact absurd hs (splitSep_ne_nil _ _) + | cons q qs => + rw [hs] at ih + rw [joinSep, List.nil_append, ih] + · split + · rename_i p ps hs + rw [hs] at ih + cases ps with + | nil => rw [joinSep] at ih ⊢; rw [ih] + | cons q qs => rw [joinSep] at ih ⊢; rw [List.cons_append, ih] + · rename_i hs + rw [hs] at ih + rw [joinSep] + rw [joinSep] at ih + rw [← ih] + +/-- What the parser reads: the text splits at its dots into the two + kept parts and the signature part, which is the base64url form + of the signature bytes; the header and the claims are what the + two kept parts decode to, through strict base64url, strict UTF-8 + and the JSON parser. -/ +theorem parseToken_reads (s : List Char) (t : Token) (h : parseToken s = some t) : + splitSep '.' s = [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] ∧ + (∃ hb hcs, Base64url.decode t.headerSeg = some hb ∧ Utf8.decode hb = some hcs ∧ + Json.parse hcs = some t.header) ∧ + (∃ pb pcs, Base64url.decode t.payloadSeg = some pb ∧ Utf8.decode pb = some pcs ∧ + Json.parse pcs = some t.claims) := by + unfold parseToken at h + split at h + · rename_i hs ps ss hsplit + split at h + · rename_i hb pb sb e1 e2 e3 + split at h + · rename_i hcs pcs u1 u2 + split at h + · rename_i hv pv j1 j2 + cases h + refine ⟨?_, ⟨hb, hcs, e1, u1, j1⟩, ⟨pb, pcs, e2, u2, j2⟩⟩ + rw [hsplit, Base64url.Proofs.encode_decode _ _ e3] + · cases h + · cases h + · cases h + · cases h + +/-- Printing a parsed token gives the received text back, byte for + byte: the two kept parts are the received ones, and the + signature part is the canonical base64url of the signature + bytes, which the strict decoder only accepts when it is the + received part. -/ +theorem printToken_parse (s : List Char) (t : Token) (h : parseToken s = some t) : + printToken t = s := by + have hsplit := (parseToken_reads s t h).1 + rw [printToken, ← hsplit, joinSep_splitSep] end Jws.Proofs diff --git a/README.md b/README.md index fe7a4f8..04ebdf2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,10 @@ Status: in progress A parser and checker for the JWS compact form (RFC 7515) in Lean. The parser reads the three base64url parts, -decodes the header and payload as JSON, and holds the -signature bytes unchanged. The checker runs the shape and +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`: the algorithm against a caller allowlist, the required claims, and the `exp`, `nbf`, `iss`, and `aud` claims against a context the caller @@ -40,7 +42,7 @@ 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. It answers `ok:` and the codes of -the canonical reprinted token when the token is accepted, +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. @@ -75,7 +77,9 @@ where they end in `sorry`) and proved in | Claim | Theorem | |-------|---------| -| Parsing the printed form of a token gives the token back, for tokens whose JSON parts are well formed in the json slice's sense. | `Spec.parseToken_print` | +| Printing a parsed token gives back the text that was parsed, byte for byte. The command line tool answers an accepted token with this print, so the answer is the input and its signature still matches its signing input. | `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 (strict base64url, strict UTF-8, then the JSON parser), and signature bytes whose base64url is the third part; the text has exactly those three parts. | `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` | | 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` | @@ -92,10 +96,14 @@ where they end in `sorry`) and proved in | 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 round trip rests on the proofs in -`Proofs/Token.lean`, where it is -`Jws.Proofs.parseToken_print`: a chain of the three round -trips under it, the JSON text round trip +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 (`Jws.Utf8.Proofs.decode_encode`, in `Proofs/Utf8.lean`), @@ -109,11 +117,9 @@ one a named fixed case in the test harness, run through the same `Jws.parseToken` and `Jws.accept` the command line tool calls. -The token and UTF-8 round trips check against the -standard three axioms only (`propext`, `Classical.choice`, -`Quot.sound`), and the base64url round trips against -`propext` and `Quot.sound` only, which a reader can -confirm with `#print axioms` on each name. +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` @@ -134,10 +140,17 @@ directly, the same functions the theorems are about. keys, signing inputs, or any algorithm. A caller that reads claims from this slice without verifying the signature has not checked authenticity. -- The reverse printing direction at the token level: - printing a parsed token is how the round trip is - stated. The base64url and JSON layers each prove their - own reverse directions. +- 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_print` is stated for tokens made from + values with `Jws.makeToken`. +- That a token value built by hand, not by `parseToken` + or `makeToken`, has parts and values that match: the + `Token` structure does not enforce it, and the theorems + cover only those two ways of making one. - Anything about the numeric meaning of claims beyond the exact decimal reading the checker itself uses (whole seconds, comparisons). The reading code is diff --git a/SPEC.md b/SPEC.md index e0c256c..92a8845 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2,7 +2,7 @@ This is the first slice of the jws repo: parse a JWS in compact form (RFC 7515 section 5.2), run a set of shape and claims -checks on it, and print it back in a canonical form. +checks on it, and give the received token back unchanged. **This slice does not verify the signature.** A token that passes every check below is well formed and its claims are @@ -154,22 +154,41 @@ every pair as written; only the check turns repeats away. ## Model and printing -A token value holds the header JSON value, the claims JSON -value, and the signature bytes. Printing a token prints each -JSON part with the json slice's canonical printer (no spaces, -object pairs in the order held, numbers kept as their source -text, strings escaped in that printer's fixed way), encodes the -text as UTF-8, base64url-encodes without padding, and joins the -three parts with dots, the signature last. - -The main claim, proved in this repo: for a token whose JSON -parts are well formed in the json slice's sense (its `WF`), -parsing the printed token gives the token back -(`Jws.Proofs.parseToken_print`). Under it, the base64url round -trips (both directions, for arbitrary bytes and for every text -the decoder accepts) and the UTF-8 decode-after-encode round -trip are also proved, and the json slice's own parse-after-print -theorem is carried in with its code. +A token value holds the first two parts exactly as received +(the header part and the payload part, base64url text), the +header JSON value and the claims JSON value they decode to, and +the signature bytes. Keeping the two parts matters: with the dot +between them they are the JWS Signing Input of RFC 7515 section +5.2 step 8, which a signature verifier needs byte for byte, and +the JSON printer below does not give back the text it parsed +(it drops whitespace, for one; RFC 7515 A.1's header has a CR LF +and a space in it). + +Printing a token writes the two held parts, then the signature +bytes in base64url without padding, joined by dots. For a parsed +token this gives back the received text, byte for byte +(`Spec.printToken_parse`): the parser keeps the first two parts, +and the strict base64url decoder accepts a signature part only +when it is the canonical encoding of its bytes. What the parser +reads is stated too (`Spec.parseToken_reads`): the text splits at +its dots into exactly those three parts, and the header and +claims values are what the two held parts decode to through +strict base64url, strict UTF-8, and the JSON parser. + +A token can also be made from values (`Jws.makeToken`): each JSON +part is written with the json slice's canonical printer (no +spaces, object pairs in the order held, numbers kept as their +source text, strings escaped in that printer's fixed way), +encoded as UTF-8, and base64url-encoded without padding. For +values that are well formed in the json slice's sense (its `WF`), +parsing the printed form of such a token gives the same token back +(`Spec.parseToken_print`). Under it, the base64url round trips +(both directions, for arbitrary bytes and for every text the +decoder accepts) and the UTF-8 decode-after-encode round trip are +proved, and the json slice's own parse-after-print theorem is +carried in with its code. The json slice proves no +print-after-parse direction, and none holds, which is why the +token keeps the received parts. ## The command line front end @@ -199,7 +218,8 @@ separated decimal form as the payload: - Field 6: the expected audience. Empty means none. The answer is `ok:` followed by the character codes of the -canonical reprinted token when the token is accepted, `none` +token text as received (the print of the parsed token, proved +equal to the input) when the token is accepted, `none` when it is not accepted, and `error` when the request itself is malformed (wrong field count, a field that is not a code list, a code that is not a Unicode scalar value, a current @@ -218,7 +238,9 @@ and `Jws.accept` the command line tool calls; the tests in | Property | Theorem | |----------|---------| -| Parsing the printed form of a token gives the token back, for well formed JSON parts | `Spec.parseToken_print` | +| Printing a parsed token gives the received text back, byte for byte | `Spec.printToken_parse` | +| A parsed token holds the received first two parts, the values they decode to, and the signature bytes whose base64url is the third part | `Spec.parseToken_reads` | +| Parsing the printed form of a token made from well formed JSON values gives that token back | `Spec.parseToken_print` | | Decoding a base64url encoding gives the bytes back | `Spec.decode_encode` | | The RFC 7515 A.1 token is accepted under its allowlist with no time checks and the issuer checked | `Spec.accepts_rfc_a1` | | The harness's hand built token is accepted under its base context | `Spec.accepts_hand_built` | diff --git a/Spec.lean b/Spec.lean index c4a4c0a..9408207 100644 --- a/Spec.lean +++ b/Spec.lean @@ -24,13 +24,39 @@ open Jws The statements the slice stands on. Each one is the statement of the proved theorem of the same shape in `Proofs`. -/ -/-- Parsing the printed form of a token gives the token back, - for tokens whose JSON parts are well formed in the JSON - slice's sense. So a printer that wrote nothing, or wrote - something shorter, fails this statement. -/ -theorem Spec.parseToken_print (t : Token) (hh : Json.WF t.header) - (hc : Json.WF t.claims) : - parseToken (printToken t) = some t := sorry +/-- Printing a parsed token gives the received text back, byte + for byte. The two first parts are kept as received, so the + JWS Signing Input a verifier needs is never rebuilt from the + JSON values; the signature part is the base64url of the + signature bytes. The command line tool answers an accepted + token with this print. -/ +theorem Spec.printToken_parse (s : List Char) (t : Token) + (h : parseToken s = some t) : + printToken t = s := sorry + +/-- What the parser reads: the received text splits at its dots + into exactly three parts, the kept header part, the kept + payload part, and the base64url of the signature bytes; the + header and claims values are what the two kept parts decode + to through strict base64url, strict UTF-8, and the JSON + parser. So a parser that kept the right parts but made up the + values fails this statement. -/ +theorem Spec.parseToken_reads (s : List Char) (t : Token) + (h : parseToken s = some t) : + splitSep '.' s = [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] ∧ + (∃ hb hcs, Base64url.decode t.headerSeg = some hb ∧ + Utf8.decode hb = some hcs ∧ Json.parse hcs = some t.header) ∧ + (∃ pb pcs, Base64url.decode t.payloadSeg = some pb ∧ + Utf8.decode pb = some pcs ∧ Json.parse pcs = some t.claims) := sorry + +/-- Parsing the printed form of a token made from header and + claims values (each written in the JSON printer's canonical + form) gives that token back, for values that are well formed + in the JSON slice's sense. So a printer that wrote nothing, or + wrote something shorter, fails this statement. -/ +theorem Spec.parseToken_print (h c : Json.Json) (sig : List UInt8) + (hh : Json.WF h) (hc : Json.WF c) : + parseToken (printToken (makeToken h c sig)) = some (makeToken h c sig) := sorry /-- Decoding the base64url encoding of any bytes gives the bytes back. -/ diff --git a/bar.toml b/bar.toml index f410417..1cbbe7a 100644 --- a/bar.toml +++ b/bar.toml @@ -16,10 +16,12 @@ files = ["SPEC.md", "Spec.lean"] # The code under proof: the functions the statements are about. code = [ # The token value, its constructor and fields. - "Jws.Token", "Jws.Token.mk", "Jws.Token.header", "Jws.Token.claims", - "Jws.Token.sig", - # The framing: split and join at the dots, the printer, the parser. + "Jws.Token", "Jws.Token.mk", "Jws.Token.headerSeg", "Jws.Token.payloadSeg", + "Jws.Token.header", "Jws.Token.claims", "Jws.Token.sig", + # The framing: split and join at the dots, the printer, the parser, + # and the token made from values. "Jws.splitSep", "Jws.joinSep", "Jws.printToken", "Jws.parseToken", + "Jws.makeToken", # The exact decimal reading of claim numbers, which the check uses. "Jws.NumVal", "Jws.digitsVal", "Jws.strip", "Jws.mkNumVal", "Jws.numVal", "Jws.NumVal.isInt", "Jws.NumVal.ofInt", diff --git a/comparator.json b/comparator.json index c8eb6f3..0e7bc61 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index c951e70..09fc597 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -44,6 +44,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from the 2026-10-27 review: an empty signature part is now always turned away (SPEC rule 2). RFC 7515 never produces an empty signature for a signing algorithm, and alg none is turned away on its own, so the none case never reaches acceptance. The earlier reading accepted an empty signature part for a signing algorithm. The fixed case that pinned that reading now expects rejection, a new fixed case empties the A.1 token's signature part under an otherwise passing context and expects rejection, and generated corpus tokens with empty signature parts follow the same new rule in the oracle. - Named change from review 1 item 1: a header or claims object that repeats a member name is turned away (SPEC rule 17; RFC 7515 section 4 and RFC 7519 section 4 allow only that or reading the last copy). The earlier reading took the first copy; the fixed cases that pinned it (duplicate iss, duplicate alg) now expect refusal, and new cases cover duplicate exp, aud, kid, a name that is never read, and a name spelled with a JSON escape. Names inside nested objects are not checked, since the slice reads no member of a nested object; a fixed case pins that. - Named change from review 1 item 2: a header with a `crit` member, of any value, is turned away (SPEC rule 18). The slice understands no extension, and RFC 7515 section 4.1.11 makes a token invalid when `crit` names one the reader does not understand. Fixed cases cover b64, an unknown name, a URI name, an empty list, a string, null, and an escaped spelling; `crit` as a claim name is not a header member and passes. +- Named change from review 1 item 3: an accepted token is answered with the token text as received, not a canonical reprint, so its signature still matches its signing input. The RFC 7515 A.1 HMAC (with the RFC's key) was checked on the tool's answer for 3 A.1 accept cases, and on the A.1 input itself: verifies. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/run_tests.py b/tests/run_tests.py index 143337b..cb97e86 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -40,6 +40,8 @@ """ import base64 +import hashlib +import hmac import json import random import re @@ -145,49 +147,6 @@ def conv(v): return out -HEX = "0123456789abcdef" - - -def json_print(v): - tag = v[0] - if tag == "null": - return "null" - if tag == "bool": - return "true" if v[1] else "false" - if tag == "num": - return v[1] - if tag == "str": - out = ['"'] - for ch in v[1]: - n = ord(ch) - if ch == '"': - out.append('\\"') - elif ch == "\\": - out.append("\\\\") - elif n == 8: - out.append("\\b") - elif n == 9: - out.append("\\t") - elif n == 10: - out.append("\\n") - elif n == 12: - out.append("\\f") - elif n == 13: - out.append("\\r") - elif n < 32: - out.append("\\u00" + HEX[n // 16] + HEX[n % 16]) - else: - out.append(ch) - out.append('"') - return "".join(out) - if tag == "arr": - return "[" + ",".join(json_print(x) for x in v[1]) + "]" - if tag == "obj": - return "{" + ",".join(json_print(("str", k)) + ":" + json_print(x) - for (k, x) in v[1]) + "}" - raise BadJson(tag) - - def lookup(name, obj): """The member of that name. The oracle reads names only from objects it has found to repeat no name (names_unique below), as @@ -240,7 +199,10 @@ def shape_ok(claims): def oracle_check(token_text, now, allow, required, issuer, audience): - """The canonical reprinted token when accepted, else None. + """The token text as received when accepted, else None. The + tool answers an accepted token with Jws.printToken of the parsed + token, which keeps the two first parts as received + (Spec.printToken_parse), so the answer is the input itself. now is None for no time comparisons. allow, required are lists of names. issuer, audience are strings or None.""" @@ -313,12 +275,7 @@ def oracle_check(token_text, now, allow, required, issuer, audience): return None else: return None - canon = ".".join([ - b64url_encode(json_print(header).encode("utf-8")), - b64url_encode(json_print(claims).encode("utf-8")), - b64url_encode(raws[2]), - ]) - return canon + return token_text # --------------------------------------------------------------- @@ -363,7 +320,7 @@ def decode_answer(ans): # --------------------------------------------------------------- # Case collection. Each entry: (name, request line, expected) -# where expected is a token text (accepted, canonical), None +# where expected is a token text (accepted: the text as received), None # (rejected), or "error" (malformed request). # --------------------------------------------------------------- @@ -687,7 +644,8 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): " accepted", hand_token(b'{"alg":"HS256","b64":false}', GOOD_P, SIG), NOW, ["HS256"], [], None, None) -add_check("payload whitespace and key order kept in the reprint", +add_check("payload whitespace and key order: the answer is the" + " received text", claims_token(b'{ "exp" : 2000000000 , "iss" : "joe" }'), NOW, ["HS256"], [], "joe", None) @@ -805,6 +763,22 @@ def plain(v): # Random differential corpus (oracle builds and settles all). # --------------------------------------------------------------- +A1_KEY = base64.urlsafe_b64decode( + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcg" + "UuTwjAzZr1Z9CAow==") + + +def a1_signature_ok(token_text): + """HMAC-SHA256 with the RFC 7515 A.1 key over the token's first + two parts, as received, equals its signature bytes.""" + parts = token_text.split(".") + if len(parts) != 3: + return False + signing_input = (parts[0] + "." + parts[1]).encode("ascii") + mac = hmac.new(A1_KEY, signing_input, hashlib.sha256).digest() + return hmac.compare_digest(mac, b64url_decode_strict(parts[2]) or b"") + + def build_random_cases(count=240): rng = random.Random(SEED) algs = ["HS256", "HS384", "RS256", "ES256", "PS256", "EdDSA", "none", @@ -894,6 +868,18 @@ def main(): if not ok: failures.append((name, got, want)) + # The A.1 token's signature must still verify on the token the + # tool answers with: the answer keeps the received signing input + # (review 1 item 3). The key is the RFC 7515 A.1 HMAC key. + hmac_checked = 0 + for (name, _, want), ans in zip(CASES, answers): + if not (name.startswith("A.1 accept") and isinstance(want, str)): + continue + got = decode_answer(ans) + hmac_checked += 1 + if not (isinstance(got, str) and a1_signature_ok(got)): + failures.append((name + " (A.1 HMAC on the answer)", got, want)) + fixed = [c for c in CASES if c[0].startswith("A.")] fixed_fail = [f for f in failures if f[0].startswith("A.")] @@ -989,6 +975,15 @@ def main(): " unknown name, a URI name, an empty list, a" " string, null, and an escaped spelling; `crit` as" " a claim name is not a header member and passes.") + lines_out.append("- Named change from review 1 item 3: an accepted" + " token is answered with the token text as" + " received, not a canonical reprint, so its" + " signature still matches its signing input. The" + " RFC 7515 A.1 HMAC (with the RFC's key) was" + " checked on the tool's answer for %d A.1 accept" + " cases, and on the A.1 input itself: %s." + % (hmac_checked, "verifies" if a1_signature_ok(A1) + else "DOES NOT VERIFY")) lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") lines_out.append("- Date claims are judged by value, not by spelling:" From d5fd3cc433a03da934d4de3b548531d79913997c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:25:01 +0000 Subject: [PATCH 09/23] Turn away a token that carries aud when no audience is expected 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 5 +++-- Proofs/SpecProofs.lean | 18 ++++++++++++++++++ README.md | 10 +++++++++- SPEC.md | 8 +++++++- Spec.lean | 34 ++++++++++++++++++++++++++++++++++ comparator.json | 2 +- tests/TEST-RESULTS.md | 9 +++++---- tests/run_tests.py | 36 +++++++++++++++++++++++++++++++++++- 8 files changed, 112 insertions(+), 10 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index 8626d84..9e57ff5 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -239,7 +239,8 @@ def dateClaim (name : List Char) (claims : Json) : ClaimDate := the current time in seconds (none means the time comparisons are skipped; the type rules still hold), the claims that must be present, the expected issuer if any, the expected audience - if any. -/ + if any. With no expected audience, a token that carries `aud` + is turned away (RFC 7519 section 4.1.3). -/ structure Context where allow : List (List Char) now : Option Int @@ -320,7 +321,7 @@ def accept (t : Token) (ctx : Context) : Bool := | some (.str s) => s == i | _ => false) && (match ctx.audience with - | none => true + | none => (lookup "aud".toList t.claims).isNone | some a => audOk t.claims a) | _ => false | _, _ => false diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index 274cf78..c9e449a 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -157,3 +157,21 @@ theorem Spec.rejects_crit_b64 (ctx : Context) : "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList = some t ∧ accept t ctx = false := Jws.Proofs.refused_of_shape _ ctx (by decide +kernel) + +theorem Spec.rejects_aud_without_audience : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 1500000000, required := [], + issuer := some "joe".toList, audience := none }) + = some false := by + decide +kernel + +theorem Spec.accepts_aud_with_audience : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 1500000000, required := [], + issuer := some "joe".toList, audience := some "other-tenant".toList }) + = some true := by + decide +kernel diff --git a/README.md b/README.md index 04ebdf2..834ab25 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ where they end in `sorry`) and proved in | 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` | +| 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` | | 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` | @@ -163,7 +165,7 @@ directly, the same functions the theorems are about. - Speed. The code works on lists, which suits proofs, not large tokens. -Three readings worth naming, all stated in `SPEC.md` and +Four readings worth naming, all stated in `SPEC.md` and pinned by tests: - An empty signature part is well formed base64url, but @@ -188,6 +190,12 @@ pinned by tests: makes a token invalid when `crit` names one the reader does not understand. `Spec.accept_no_crit` proves it for every accepted token. +- With no expected audience, a token that carries `aud` + is turned away (`SPEC.md` rule 16): RFC 7519 section + 4.1.3 asks a reader that does not identify itself with + a value in `aud` to reject the token. PyJWT reads it + the same way; some other libraries accept such a + token. ## What you must trust diff --git a/SPEC.md b/SPEC.md index 92a8845..9cc55e8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -120,7 +120,11 @@ The token is accepted only when every rule holds: and exactly equal to it. A missing `iss` rejects. 16. When an expected audience is given, `aud` must be that string, or an array holding that string as a member. A - missing `aud` rejects. + missing `aud` rejects. When no expected audience is given, + a token that carries `aud` at all is turned away: RFC 7519 + section 4.1.3 asks a reader that does not identify itself + with a value in `aud` to reject the token, and a caller + that gives no audience identifies itself with none. 17. Neither the header object nor the claims object repeats a member name. Names are compared after JSON unescaping, so `"alg"` and `"\u0061lg"` are the same name. RFC 7515 section 4 @@ -254,5 +258,7 @@ and `Jws.accept` the command line tool calls; the tests in | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | +| A token carrying `aud` is refused when no audience is expected (rule 16) | `Spec.rejects_aud_without_audience` | +| The same token is accepted when its audience is the expected one | `Spec.accepts_aud_with_audience` | | An accepted token's header has no `crit` member (rule 18) | `Spec.accept_no_crit` | | The token with header `{"alg":"HS256","b64":false,"crit":["b64"]}` parses and is refused under every context (rule 18) | `Spec.rejects_crit_b64` | diff --git a/Spec.lean b/Spec.lean index 9408207..2c790d8 100644 --- a/Spec.lean +++ b/Spec.lean @@ -242,3 +242,37 @@ theorem Spec.rejects_crit_b64 (ctx : Context) : ∃ t, parseToken "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.AQID".toList = some t ∧ accept t ctx = false := sorry + +/-! ## The audience claim when no audience is expected + +RFC 7519 section 4.1.3: when `aud` is present and the reader does +not identify itself with a value in it, the token must be +rejected. A caller that gives no expected audience identifies +itself with none, so such a token is turned away. -/ + +/-- The token with claims + `{"iss":"joe","exp":2000000000,"aud":"other-tenant"}`, header + `{"alg":"HS256"}` and signature bytes 01 02 03 parses and is + refused when HS256 is allowed, the current time is 1500000000, + the issuer must be `joe`, and no audience is expected. The + harness case is "aud present, no audience expected: + refused". -/ +theorem Spec.rejects_aud_without_audience : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 1500000000, required := [], + issuer := some "joe".toList, audience := none }) + = some false := sorry + +/-- The same token is accepted under the same context when the + expected audience is `other-tenant`, so the refusal above + comes from the audience rule alone. The harness case is "aud + present, the same audience expected: accepted". -/ +theorem Spec.accepts_aud_with_audience : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 1500000000, required := [], + issuer := some "joe".toList, audience := some "other-tenant".toList }) + = some true := sorry diff --git a/comparator.json b/comparator.json index 0e7bc61..2b56a14 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 09fc597..3c46926 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 448 +- Inputs: 454 - Failures: 0 ## The oracle and PyJWT @@ -14,9 +14,9 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 448 -- Agreements with the oracle: 448 -- Of the verdict cases: 54 accepted, 384 rejected, 10 malformed requests answered error +- Total check and request inputs: 454 +- Agreements with the oracle: 454 +- Of the verdict cases: 38 accepted, 406 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases @@ -45,6 +45,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from review 1 item 1: a header or claims object that repeats a member name is turned away (SPEC rule 17; RFC 7515 section 4 and RFC 7519 section 4 allow only that or reading the last copy). The earlier reading took the first copy; the fixed cases that pinned it (duplicate iss, duplicate alg) now expect refusal, and new cases cover duplicate exp, aud, kid, a name that is never read, and a name spelled with a JSON escape. Names inside nested objects are not checked, since the slice reads no member of a nested object; a fixed case pins that. - Named change from review 1 item 2: a header with a `crit` member, of any value, is turned away (SPEC rule 18). The slice understands no extension, and RFC 7515 section 4.1.11 makes a token invalid when `crit` names one the reader does not understand. Fixed cases cover b64, an unknown name, a URI name, an empty list, a string, null, and an escaped spelling; `crit` as a claim name is not a header member and passes. - Named change from review 1 item 3: an accepted token is answered with the token text as received, not a canonical reprint, so its signature still matches its signing input. The RFC 7515 A.1 HMAC (with the RFC's key) was checked on the tool's answer for 3 A.1 accept cases, and on the A.1 input itself: verifies. +- Named change from review 1 item 6: with no expected audience, a token that carries `aud` is turned away (SPEC rule 16). RFC 7519 section 4.1.3 asks a reader that does not identify itself with a value in `aud` to reject the token. PyJWT reads it the same way; some other libraries accept such a token. Generated tokens with `aud` under a context with no audience now expect refusal in the oracle. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/run_tests.py b/tests/run_tests.py index cb97e86..4651de0 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -263,7 +263,12 @@ def oracle_check(token_text, now, allow, required, issuer, audience): iss = lookup("iss", claims) if iss is None or iss[0] != "str" or iss[1] != issuer: return None - if audience is not None: + if audience is None: + # With no expected audience, a token that carries aud is + # turned away (SPEC rule 16; RFC 7519 section 4.1.3). + if lookup("aud", claims) is not None: + return None + else: aud = lookup("aud", claims) if aud is None: return None @@ -546,6 +551,26 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): add_check("aud string equal to the expected value (accept)", claims_token(b'{"aud":"api","exp":2000000000}'), NOW, ["HS256"], [], None, "api") +# aud when no audience is expected: refused (SPEC rule 16, RFC 7519 +# section 4.1.3). Review 1 item 6. +AUD_TOKEN = ("eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImF1" + "ZCI6Im90aGVyLXRlbmFudCJ9.AQID") +add_check("aud present, no audience expected: refused", AUD_TOKEN, + NOW, ["HS256"], [], "joe", None) +add_check("aud present, the same audience expected: accepted", AUD_TOKEN, + NOW, ["HS256"], [], "joe", "other-tenant") +add_check("aud array present, no audience expected: refused", + claims_token(b'{"aud":["api","web"],"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("aud empty array, no audience expected: refused", + claims_token(b'{"aud":[],"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("aud empty array, an audience expected: refused", + claims_token(b'{"aud":[],"exp":2000000000}'), + NOW, ["HS256"], [], None, "api") +add_check("aud present, no audience expected, no time checks: refused", + claims_token(b'{"aud":"api"}'), + None, ["HS256"], [], None, None) add_check("mutation: expected audience, aud missing (reject)", claims_token(b'{"exp":2000000000}'), NOW, ["HS256"], [], None, "api") @@ -984,6 +1009,15 @@ def main(): " cases, and on the A.1 input itself: %s." % (hmac_checked, "verifies" if a1_signature_ok(A1) else "DOES NOT VERIFY")) + lines_out.append("- Named change from review 1 item 6: with no" + " expected audience, a token that carries `aud` is" + " turned away (SPEC rule 16). RFC 7519 section" + " 4.1.3 asks a reader that does not identify" + " itself with a value in `aud` to reject the" + " token. PyJWT reads it the same way; some other" + " libraries accept such a token. Generated tokens" + " with `aud` under a context with no audience now" + " expect refusal in the oracle.") lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") lines_out.append("- Date claims are judged by value, not by spelling:" From 2ca32e6c2026ee5805a3b4489b9d51603e514778 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:26:49 +0000 Subject: [PATCH 10/23] Accept fractional NumericDates and compare them exactly 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 12 +++----- Proofs/SpecProofs.lean | 18 ++++++++++++ README.md | 21 ++++++++++---- SPEC.md | 17 ++++++----- Spec.lean | 29 +++++++++++++++++++ bar.toml | 2 +- comparator.json | 2 +- tests/TEST-RESULTS.md | 11 +++---- tests/run_tests.py | 65 +++++++++++++++++++++++++++++++++++------- 9 files changed, 139 insertions(+), 38 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index 9e57ff5..9f35051 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -160,10 +160,6 @@ where some (mkNumVal neg (digitsVal (ids ++ fds)) (sgn * (digitsVal eds : Int) - (fds.length : Int))) -/-- Whether a number value is a whole number of seconds. -/ -def NumVal.isInt (v : NumVal) : Bool := - v.sig = 0 || 0 ≤ v.exp - /-- A whole number as a number value. -/ def NumVal.ofInt (n : Int) : NumVal := mkNumVal (n < 0) n.natAbs 0 @@ -218,9 +214,9 @@ def namesUnique : List (List Char) → Bool | n :: ns => !ns.contains n && namesUnique ns /-- The state of one date claim (exp, nbf, iat) in a claims set: - absent, present but not a whole number of seconds (a - non-number, or a number with a fraction), or present with a - whole number value. -/ + absent, present but not a JSON number, or present with its + exact value. A NumericDate need not be a whole number (RFC 7519 + section 2), so a fraction is kept and compared exactly. -/ inductive ClaimDate where | absent | bad @@ -231,7 +227,7 @@ def dateClaim (name : List Char) (claims : Json) : ClaimDate := | none => .absent | some (.num cs) => match numVal cs with - | some v => if v.isInt then .ok v else .bad + | some v => .ok v | none => .bad | some _ => .bad diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index c9e449a..e4ae48a 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -175,3 +175,21 @@ theorem Spec.accepts_aud_with_audience : issuer := some "joe".toList, audience := some "other-tenant".toList }) = some true := by decide +kernel + +theorem Spec.accepts_fractional_exp_before : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 2000000000, required := [], + issuer := none, audience := none }) + = some true := by + decide +kernel + +theorem Spec.rejects_fractional_exp_after : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 2000000001, required := [], + issuer := none, audience := none }) + = some false := by + decide +kernel diff --git a/README.md b/README.md index 834ab25..54025b1 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ where they end in `sorry`) and proved in | 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` | +| 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` | | 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` | | When the check accepts a token, under any context, its header is a JSON object with no `crit` member. | `Spec.accept_no_crit` | @@ -153,11 +155,14 @@ directly, the same functions the theorems are about. or `makeToken`, has parts and values that match: the `Token` structure does not enforce it, and the theorems cover only those two ways of making one. -- Anything about the numeric meaning of claims beyond - the exact decimal reading the checker itself uses - (whole seconds, comparisons). The reading code is - exercised by the tests, including exponent spellings - and very large exponents. +- That the checker's exact decimal reading of a date + claim (`Jws.numVal`) and its comparison + (`Jws.NumVal.cmp`) agree with the decimal value of the + number text. The theorems state the date rules in the + checker's own terms; the agreement rests on the tests, + which compare it with Python's `Decimal`, including + 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 @@ -165,7 +170,7 @@ directly, the same functions the theorems are about. - Speed. The code works on lists, which suits proofs, not large tokens. -Four readings worth naming, all stated in `SPEC.md` and +Five readings worth naming, all stated in `SPEC.md` and pinned by tests: - An empty signature part is well formed base64url, but @@ -196,6 +201,10 @@ pinned by tests: a value in `aud` to reject the token. PyJWT reads it the same way; some other libraries accept such a token. +- `exp`, `nbf`, and `iat` may 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.md` rule 9). ## What you must trust diff --git a/SPEC.md b/SPEC.md index 9cc55e8..cd0d24f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -98,13 +98,14 @@ The token is accepted only when every rule holds: is all this rule asks; the value's type does not matter for this rule. 9. When `exp`, `nbf`, or `iat` is present, its value must be a - JSON number whose value is a whole number of seconds. A - non-number, or a number with a fractional part (for example - `2000000000.5`), turns the token away. This type rule holds - even when no current time is supplied. The value is judged - as an exact decimal, not by its spelling: `2.0e9` is the - whole number 2000000000, and a value like `1e999999999` is - compared exactly, without ever being written out in full. + JSON number. A non-number turns the token away. This type + rule holds even when no current time is supplied. The value + need not be a whole number: RFC 7519 section 2 lets a + NumericDate be a non-integer, so `2000000000.5` is half a + second after 2000000000. The value is judged as an exact + decimal, not by its spelling: `2.0e9` is 2000000000, and a + value like `1e999999999` or `1e-999999999` is compared + exactly, without ever being written out in full. 10. When `exp` is present and a current time is supplied, the current time must be before `exp`. A current time equal to `exp` rejects (the token is expired at that second). @@ -258,6 +259,8 @@ and `Jws.accept` the command line tool calls; the tests in | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | +| A token with `exp` 2000000000.5 is accepted at 2000000000 (rules 9, 10) | `Spec.accepts_fractional_exp_before` | +| The same token is refused at 2000000001 (rules 9, 10) | `Spec.rejects_fractional_exp_after` | | A token carrying `aud` is refused when no audience is expected (rule 16) | `Spec.rejects_aud_without_audience` | | The same token is accepted when its audience is the expected one | `Spec.accepts_aud_with_audience` | | An accepted token's header has no `crit` member (rule 18) | `Spec.accept_no_crit` | diff --git a/Spec.lean b/Spec.lean index 2c790d8..28075fa 100644 --- a/Spec.lean +++ b/Spec.lean @@ -276,3 +276,32 @@ theorem Spec.accepts_aud_with_audience : { allow := ["HS256".toList], now := some 1500000000, required := [], issuer := some "joe".toList, audience := some "other-tenant".toList }) = some true := sorry + +/-! ## Fractional NumericDates + +RFC 7519 section 2 lets a NumericDate be a non-integer. The check +compares such a value exactly with the current time. -/ + +/-- The token with claims `{"exp":2000000000.5}`, header + `{"alg":"HS256"}` and signature bytes 01 02 03 parses and is + accepted at the current time 2000000000, half a second before + it expires, with HS256 allowed. The harness case is + "fractional exp, now half a second before: accepted". -/ +theorem Spec.accepts_fractional_exp_before : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 2000000000, required := [], + issuer := none, audience := none }) + = some true := sorry + +/-- The same token is refused one second later, at 2000000001, + after it has expired. The harness case is "fractional exp, now + half a second after: refused". -/ +theorem Spec.rejects_fractional_exp_after : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 2000000001, required := [], + issuer := none, audience := none }) + = some false := sorry diff --git a/bar.toml b/bar.toml index 1cbbe7a..4a4850e 100644 --- a/bar.toml +++ b/bar.toml @@ -24,7 +24,7 @@ code = [ "Jws.makeToken", # The exact decimal reading of claim numbers, which the check uses. "Jws.NumVal", "Jws.digitsVal", "Jws.strip", "Jws.mkNumVal", - "Jws.numVal", "Jws.NumVal.isInt", "Jws.NumVal.ofInt", + "Jws.numVal", "Jws.numVal.finish", "Jws.numVal.withExp", "Jws.NumVal.ofInt", "Jws.numDigits", "Jws.magCmp", "Jws.NumVal.cmp", # The check: the context, the claim reading, the accept function. "Jws.Context", "Jws.Context.mk", "Jws.Context.allow", "Jws.Context.now", diff --git a/comparator.json b/comparator.json index 2b56a14..1b5c5a7 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 3c46926..bb772f5 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 454 +- Inputs: 466 - Failures: 0 ## The oracle and PyJWT @@ -14,9 +14,9 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 454 -- Agreements with the oracle: 454 -- Of the verdict cases: 38 accepted, 406 rejected, 10 malformed requests answered error +- Total check and request inputs: 466 +- Agreements with the oracle: 466 +- Of the verdict cases: 47 accepted, 409 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases @@ -47,6 +47,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from review 1 item 3: an accepted token is answered with the token text as received, not a canonical reprint, so its signature still matches its signing input. The RFC 7515 A.1 HMAC (with the RFC's key) was checked on the tool's answer for 3 A.1 accept cases, and on the A.1 input itself: verifies. - Named change from review 1 item 6: with no expected audience, a token that carries `aud` is turned away (SPEC rule 16). RFC 7519 section 4.1.3 asks a reader that does not identify itself with a value in `aud` to reject the token. PyJWT reads it the same way; some other libraries accept such a token. Generated tokens with `aud` under a context with no audience now expect refusal in the oracle. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. -- Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the whole number 2000000000, and `1e999999999` compares without ever being written out. +- Named change from review 1 item 7: `exp`, `nbf` and `iat` may be non-integers (RFC 7519 section 2) and are compared exactly with the current time (SPEC rule 9). The earlier reading turned every fractional date away; the three fixed cases that pinned it now expect acceptance, and new cases cover fractions a millionth either side of now, equality, `nbf`, and exponents of -999999999. +- Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/run_tests.py b/tests/run_tests.py index 4651de0..c0b6609 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -25,7 +25,7 @@ outside the alphabet, two or four parts, a header that is not an object, alg missing, alg none with an empty signature, exp equal to now, nbf equal to now, audience arrays that do - and do not hold the expected value, a fractional exp, and + and do not hold the expected value, fractional dates, and more; plus request lines that must be answered with `error`. The oracle is the authority for verdicts. PyJWT never decides a @@ -171,16 +171,15 @@ def names_unique(obj): def date_claim(claims, name): - """'absent', 'bad', or a Decimal integer value.""" + """'absent', 'bad', or the exact Decimal value. A NumericDate + may be a non-integer (RFC 7519 section 2; SPEC rule 9), and + Decimal compares exactly.""" v = lookup(name, claims) if v is None: return "absent" if v[0] != "num" or not NUM_RE.match(v[1]): return "bad" - d = Decimal(v[1]) - if d != d.to_integral_value(): - return "bad" - return d + return Decimal(v[1]) def shape_ok(claims): @@ -500,12 +499,50 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): add_check("mutation: nbf one second after now (reject)", claims_token(b'{"nbf":1500000001,"exp":2000000000}'), NOW, ["HS256"], [], None, None) -add_check("mutation: fractional exp (reject)", +# Fractional NumericDates are allowed (RFC 7519 section 2) and +# compared exactly (SPEC rule 9). These first two cases pinned the +# old whole-seconds reading as rejects; review 1 item 7. +add_check("fractional exp, far after now (accepted)", claims_token(b'{"exp":2000000000.5}'), NOW, ["HS256"], [], None, None) -add_check("mutation: fractional exp with no time checks (reject)", +add_check("fractional exp with no time checks (accepted)", claims_token(b'{"exp":2000000000.5}'), None, ["HS256"], [], None, None) +FRAC_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.AQID" +add_check("fractional exp, now half a second before: accepted", + FRAC_TOKEN, 2000000000, ["HS256"], [], None, None) +add_check("fractional exp, now half a second after: refused", + FRAC_TOKEN, 2000000001, ["HS256"], [], None, None) +add_check("exp 1500000000.0 equals now: refused", + claims_token(b'{"exp":1500000000.0}'), + NOW, ["HS256"], [], None, None) +add_check("exp a millionth after now: accepted", + claims_token(b'{"exp":1500000000.000001}'), + NOW, ["HS256"], [], None, None) +add_check("exp a millionth before now: refused", + claims_token(b'{"exp":1499999999.999999}'), + NOW, ["HS256"], [], None, None) +add_check("nbf half a second after now: refused", + claims_token(b'{"nbf":1500000000.5,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("nbf a tenth before now: accepted", + claims_token(b'{"nbf":1499999999.9,"exp":2000000000}'), + NOW, ["HS256"], [], None, None) +add_check("exp as a scaled fraction 15000000005e-1 (accepted)", + claims_token(b'{"exp":15000000005e-1}'), + NOW, ["HS256"], [], None, None) +add_check("exp a tiny positive 1e-999999999, now zero: accepted", + claims_token(b'{"exp":1e-999999999}'), + 0, ["HS256"], [], None, None) +add_check("exp a tiny negative -1e-999999999, now zero: refused", + claims_token(b'{"exp":-1e-999999999}'), + 0, ["HS256"], [], None, None) +add_check("nbf a tiny positive 1e-999999999, now zero: refused", + claims_token(b'{"nbf":1e-999999999}'), + 0, ["HS256"], [], None, None) +add_check("exp a negative fraction -0.5, now -1: accepted", + claims_token(b'{"exp":-0.5}'), + -1, ["HS256"], [], None, None) add_check("mutation: exp as a string (reject)", claims_token(b'{"exp":"2000000000"}'), NOW, ["HS256"], [], None, None) @@ -524,7 +561,7 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): add_check("negative now before a small exp (accept)", claims_token(b'{"exp":100}'), -5, ["HS256"], [], None, None) -add_check("mutation: fractional iat (reject)", +add_check("fractional iat (accepted; pinned the old reading as a reject)", claims_token(b'{"iat":1.5,"exp":2000000000}'), NOW, ["HS256"], [], None, None) add_check("iat whole in exponent form (accept)", @@ -1020,8 +1057,16 @@ def main(): " expect refusal in the oracle.") lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") + lines_out.append("- Named change from review 1 item 7: `exp`, `nbf`" + " and `iat` may be non-integers (RFC 7519 section 2)" + " and are compared exactly with the current time" + " (SPEC rule 9). The earlier reading turned every" + " fractional date away; the three fixed cases that" + " pinned it now expect acceptance, and new cases" + " cover fractions a millionth either side of now," + " equality, `nbf`, and exponents of -999999999.") lines_out.append("- Date claims are judged by value, not by spelling:" - " `2.0e9` and `20000000000e-1` are the whole number" + " `2.0e9` and `20000000000e-1` are the number" " 2000000000, and `1e999999999` compares without" " ever being written out.") lines_out.append("- PyJWT decode differences (its parser against the" From b216c0efa4bd6bff5de901663e7e6d9f16439baa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:30:02 +0000 Subject: [PATCH 11/23] State and prove what acceptance implies (Spec.accept_sound) 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/Check.lean | 201 +++++++++++++++++++++++++++++++++++++++++ Proofs/SpecProofs.lean | 24 +++++ README.md | 12 ++- SPEC.md | 5 + Spec.lean | 43 +++++++++ comparator.json | 2 +- 6 files changed, 281 insertions(+), 6 deletions(-) diff --git a/Proofs/Check.lean b/Proofs/Check.lean index 7387898..c60bb47 100644 --- a/Proofs/Check.lean +++ b/Proofs/Check.lean @@ -93,4 +93,205 @@ theorem refused_of_shape (s : List Char) (ctx : Context) refine ⟨t, rfl, accept_false_of_shape t ctx ?_⟩ simpa using h +/-- A pair the lookup finds is a member of the list. -/ +theorem mem_of_lookup {β : Type} : ∀ {kvs : List (List Char × β)} {k : List Char} {v : β}, + kvs.lookup k = some v → (k, v) ∈ kvs + | [], _, _, h => by simp [List.lookup] at h + | (k', b) :: es, k, v, h => by + by_cases hk : k = k' + · subst hk + simp only [List.lookup, beq_self_eq_true, Option.some.injEq] at h + subst h + exact List.mem_cons_self + · have : (k == k') = false := by simpa using hk + simp only [List.lookup, this] at h + exact List.mem_cons_of_mem _ (mem_of_lookup h) + +/-- In a list with no key twice, a member pair is what the lookup + finds. -/ +theorem lookup_of_mem {β : Type} : ∀ {kvs : List (List Char × β)} {k : List Char} {v : β}, + (kvs.map Prod.fst).Nodup → (k, v) ∈ kvs → kvs.lookup k = some v + | [], _, _, _, hm => by cases hm + | (k', b) :: es, k, v, hnd, hm => by + rw [List.map_cons, List.nodup_cons] at hnd + rcases List.mem_cons.mp hm with he | he + · cases he + simp [List.lookup] + · have hk : k ≠ k' := by + intro e + subst e + exact hnd.1 (List.mem_map.mpr ⟨(k, v), he, rfl⟩) + have : (k == k') = false := by simpa using hk + simp only [List.lookup, this] + exact lookup_of_mem hnd.2 he + +/-- The lookup finds something exactly when the key is a member + name. -/ +theorem lookup_isSome_iff {β : Type} : ∀ {kvs : List (List Char × β)} {k : List Char}, + (kvs.lookup k).isSome = true ↔ k ∈ kvs.map Prod.fst + | [], _ => by simp [List.lookup] + | (k', b) :: es, k => by + by_cases hk : k = k' + · subst hk + simp [List.lookup] + · have : (k == k') = false := by simpa using hk + simp only [List.lookup, this, List.map_cons, List.mem_cons, hk, false_or] + exact lookup_isSome_iff + +/-- The date claim reading of a member the lookup finds. -/ +theorem dateClaim_eq {ckvs : List (List Char × Json.Json)} {name : List Char} {v : Json.Json} + (hl : ckvs.lookup name = some v) : + dateClaim name (.obj ckvs) = match v with + | .num cs => (match numVal cs with | some e => .ok e | none => .bad) + | _ => .bad := by + simp only [dateClaim, lookup, hl] + cases v <;> rfl + +/-- A date claim that is not `bad` is a number with an exact + value. -/ +theorem dateClaim_num {ckvs : List (List Char × Json.Json)} {name : List Char} {v : Json.Json} + (hl : ckvs.lookup name = some v) (hb : dateClaim name (.obj ckvs) ≠ .bad) : + ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ dateClaim name (.obj ckvs) = .ok e := by + rw [dateClaim_eq hl] at hb ⊢ + cases v with + | num cs => + dsimp only at hb ⊢ + cases hn : numVal cs with + | none => rw [hn] at hb; exact absurd rfl hb + | some e => exact ⟨cs, e, rfl, hn, rfl⟩ + | _ => dsimp only at hb; exact absurd rfl hb + +/-- What an accepted token satisfies: every rule of the check, + stated over the members of the header and claims objects. With + no repeated names, a member pair is the one value of its name. + The time comparisons are stated with the checker's own exact + decimal reading (`numVal`) and comparison (`NumVal.cmp`). -/ +theorem accept_sound (t : Token) (ctx : Context) (h : accept t ctx = true) : + "none".toList ∉ ctx.allow ∧ t.sig ≠ [] ∧ + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup ∧ + "crit".toList ∉ hkvs.map Prod.fst ∧ + (∃ alg, ("alg".toList, Json.Json.str alg) ∈ hkvs ∧ + alg ≠ "none".toList ∧ alg ∈ ctx.allow) ∧ + (∀ n ∈ ctx.required, n ∈ ckvs.map Prod.fst) ∧ + (∀ v, ("exp".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e = .lt) ∧ + (∀ v, ("nbf".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e ≠ .lt) ∧ + (∀ v, ("iat".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e) ∧ + (∀ v, ("iss".toList, v) ∈ ckvs → ∃ s, v = .str s) ∧ + (∀ v, ("aud".toList, v) ∈ ckvs → + (∃ s, v = .str s) ∨ ∃ vs, v = .arr vs ∧ ∀ x ∈ vs, ∃ s, x = .str s) ∧ + (∀ i, ctx.issuer = some i → ("iss".toList, Json.Json.str i) ∈ ckvs) ∧ + (ctx.audience = none → "aud".toList ∉ ckvs.map Prod.fst) ∧ + (∀ a, ctx.audience = some a → + ("aud".toList, Json.Json.str a) ∈ ckvs ∨ + ∃ vs, ("aud".toList, Json.Json.arr vs) ∈ ckvs ∧ Json.Json.str a ∈ vs) := by + obtain ⟨hs, ps, hd, cl, sig⟩ := t + unfold accept at h + split at h + · cases h + · rename_i hnone + split at h + · rename_i hkvs ckvs hh hc + dsimp only at hh hc + subst hh hc + simp only [Bool.and_eq_true] at h + obtain ⟨⟨⟨hu1, hu2⟩, hcrit⟩, h⟩ := h + split at h + · rename_i alg halg + simp only [Bool.and_eq_true, and_assoc] at h + obtain ⟨hne, hsig, hallow, hreq, hexp, hnbf, hiat, hiss, haud, hissuer, haudience⟩ := h + have hnd1 := (namesUnique_iff _).mp hu1 + have hnd2 := (namesUnique_iff _).mp hu2 + -- a date claim that passed is a number with a value + have date : ∀ (name : List Char) (v : Json.Json), + (name, v) ∈ ckvs → dateClaim name (.obj ckvs) ≠ .bad → + ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + dateClaim name (.obj ckvs) = .ok e := fun name v hm hb => + dateClaim_num (lookup_of_mem hnd2 hm) hb + refine ⟨?_, ?_, hkvs, ckvs, rfl, rfl, hnd1, hnd2, ?_, ?_, ?_, ?_, ?_, ?_, ?_, ?_, ?_, + ?_, ?_⟩ + · rw [← List.contains_iff_mem]; exact hnone + · intro e; subst e; cases hsig + · rw [← List.contains_iff_mem]; simpa using hcrit + · refine ⟨alg, mem_of_lookup halg, ?_, List.contains_iff_mem.mp hallow⟩ + simpa using hne + · intro n hn + rw [List.all_eq_true] at hreq + exact lookup_isSome_iff.mp (hreq n hn) + · intro v hm + have hb : dateClaim "exp".toList (.obj ckvs) ≠ .bad := by + intro e; rw [e] at hexp; cases hexp + obtain ⟨cs, e, hv, hn, hd⟩ := date _ v hm hb + refine ⟨cs, e, hv, hn, fun now hnow => ?_⟩ + rw [hd, hnow] at hexp + simpa using hexp + · intro v hm + have hb : dateClaim "nbf".toList (.obj ckvs) ≠ .bad := by + intro e; rw [e] at hnbf; cases hnbf + obtain ⟨cs, e, hv, hn, hd⟩ := date _ v hm hb + refine ⟨cs, e, hv, hn, fun now hnow => ?_⟩ + rw [hd, hnow] at hnbf + simpa using hnbf + · intro v hm + have hb : dateClaim "iat".toList (.obj ckvs) ≠ .bad := by + intro e; rw [e] at hiat; cases hiat + obtain ⟨cs, e, hv, hn, _⟩ := date _ v hm hb + exact ⟨cs, e, hv, hn⟩ + · intro v hm + have hl := lookup_of_mem hnd2 hm + simp only [issShapeOk, lookup, hl] at hiss + cases v <;> simp_all + · intro v hm + have hl := lookup_of_mem hnd2 hm + simp only [audShapeOk, lookup, hl] at haud + cases v with + | str s => exact Or.inl ⟨s, rfl⟩ + | arr vs => + refine Or.inr ⟨vs, rfl, fun x hx => ?_⟩ + rw [List.all_eq_true] at haud + have := haud x hx + cases x <;> simp_all + | _ => cases haud + · intro i hi + rw [hi] at hissuer + dsimp only at hissuer + split at hissuer + · rename_i s hl + have : s = i := by simpa using hissuer + subst this + exact mem_of_lookup hl + · cases hissuer + · intro hn hm + rw [hn] at haudience + dsimp only at haudience + have := lookup_isSome_iff.mpr hm + simp only [lookup] at haudience + rw [Option.isNone_iff_eq_none] at haudience + rw [haudience] at this + cases this + · intro a ha + rw [ha] at haudience + dsimp only at haudience + unfold audOk at haudience + split at haudience + · rename_i s hl + have : s = a := by simpa using haudience + subst this + exact Or.inl (mem_of_lookup hl) + · rename_i vs hl + refine Or.inr ⟨vs, mem_of_lookup hl, ?_⟩ + rw [List.any_eq_true] at haudience + obtain ⟨x, hx, hxa⟩ := haudience + cases x with + | str s => + have : s = a := by simpa using hxa + subst this + exact hx + | _ => cases hxa + · cases haudience + · cases h + · cases h + end Jws.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index e4ae48a..15cb764 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -193,3 +193,27 @@ theorem Spec.rejects_fractional_exp_after : issuer := none, audience := none }) = some false := by decide +kernel + +theorem Spec.accept_sound (t : Token) (ctx : Context) + (h : accept t ctx = true) : + "none".toList ∉ ctx.allow ∧ t.sig ≠ [] ∧ + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup ∧ + "crit".toList ∉ hkvs.map Prod.fst ∧ + (∃ alg, ("alg".toList, Json.Json.str alg) ∈ hkvs ∧ + alg ≠ "none".toList ∧ alg ∈ ctx.allow) ∧ + (∀ n ∈ ctx.required, n ∈ ckvs.map Prod.fst) ∧ + (∀ v, ("exp".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e = .lt) ∧ + (∀ v, ("nbf".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e ≠ .lt) ∧ + (∀ v, ("iat".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e) ∧ + (∀ v, ("iss".toList, v) ∈ ckvs → ∃ s, v = .str s) ∧ + (∀ v, ("aud".toList, v) ∈ ckvs → + (∃ s, v = .str s) ∨ ∃ vs, v = .arr vs ∧ ∀ x ∈ vs, ∃ s, x = .str s) ∧ + (∀ i, ctx.issuer = some i → ("iss".toList, Json.Json.str i) ∈ ckvs) ∧ + (ctx.audience = none → "aud".toList ∉ ckvs.map Prod.fst) ∧ + (∀ a, ctx.audience = some a → + ("aud".toList, Json.Json.str a) ∈ ckvs ∨ + ∃ vs, ("aud".toList, Json.Json.arr vs) ∈ ckvs ∧ Json.Json.str a ∈ vs) := + Jws.Proofs.accept_sound t ctx h diff --git a/README.md b/README.md index 54025b1..4e33612 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ where they end in `sorry`) and proved in | The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). | `Spec.rejects_alg_outside_allowlist` | | A token whose first part carries a padding character does not parse: `=` is not in the base64url alphabet. | `Spec.rejects_part_not_base64url` | | A token whose header part decodes to a JSON array, not an object, is refused. | `Spec.rejects_header_not_object` | +| 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 exact decimal comparison (`Jws.NumVal.cmp` on `Jws.numVal`) orders them; `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` | @@ -134,11 +135,12 @@ directly, the same functions the theorems are about. - 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 exactly the tokens `SPEC.md` - says it should, beyond the single inputs the theorems - above pin. The acceptance rules live in one function - (`Jws.accept`), and their completeness rests on the - tests: the RFC examples, the mutation cases, and the +- That the checker accepts every token `SPEC.md` says it + should, beyond the single inputs the theorems above + pin. `Spec.accept_sound` proves 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 diff --git a/SPEC.md b/SPEC.md index cd0d24f..a775a2e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -240,6 +240,10 @@ theorems are single inputs, one for each named fixed case in the test harness, run through the same `Jws.parseToken` and `Jws.accept` the command line tool calls; the tests in `tests/` cover each rule on many more inputs. +`Spec.accept_sound` covers every token and every context in +the one direction: what acceptance implies. That every token +meeting the rules is accepted is shown only by the single +input theorems and the tests. | Property | Theorem | |----------|---------| @@ -256,6 +260,7 @@ and `Jws.accept` the command line tool calls; the tests in | A token whose algorithm is outside the allowlist is refused (rule 7) | `Spec.rejects_alg_outside_allowlist` | | A token with a part that is not strict base64url does not parse | `Spec.rejects_part_not_base64url` | | A token whose header is not a JSON object is refused (rule 3) | `Spec.rejects_header_not_object` | +| An accepted token meets rules 2 to 18, the date comparisons as `Jws.NumVal.cmp` makes them | `Spec.accept_sound` | | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | diff --git a/Spec.lean b/Spec.lean index 28075fa..daf70a4 100644 --- a/Spec.lean +++ b/Spec.lean @@ -305,3 +305,46 @@ theorem Spec.rejects_fractional_exp_after : { allow := ["HS256".toList], now := some 2000000001, required := [], issuer := none, audience := none }) = some false := sorry + +/-! ## What acceptance means + +The plan's main claim: a token is accepted only when its header +`alg` is on the allowlist and its registered claims pass the +checks. One statement collects every rule the check applies. -/ + +/-- 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` that is on the allowlist; + every required claim is present; `exp`, `nbf` and `iat`, when + present, are numbers the checker reads exactly, with the + current time before `exp` and not before `nbf` when a time is + given; `iss`, when present, is a string; `aud`, when present, + is a string or an array of strings; the issuer is the expected + one when one is given; and `aud` is absent when no audience is + expected, or names the expected audience when one is. A + program that accepts nothing meets this statement; the + accepting statements above are what rule it out. -/ +theorem Spec.accept_sound (t : Token) (ctx : Context) + (h : accept t ctx = true) : + "none".toList ∉ ctx.allow ∧ t.sig ≠ [] ∧ + ∃ hkvs ckvs, t.header = .obj hkvs ∧ t.claims = .obj ckvs ∧ + (hkvs.map Prod.fst).Nodup ∧ (ckvs.map Prod.fst).Nodup ∧ + "crit".toList ∉ hkvs.map Prod.fst ∧ + (∃ alg, ("alg".toList, Json.Json.str alg) ∈ hkvs ∧ + alg ≠ "none".toList ∧ alg ∈ ctx.allow) ∧ + (∀ n ∈ ctx.required, n ∈ ckvs.map Prod.fst) ∧ + (∀ v, ("exp".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e = .lt) ∧ + (∀ v, ("nbf".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e ∧ + ∀ now, ctx.now = some now → NumVal.cmp (NumVal.ofInt now) e ≠ .lt) ∧ + (∀ v, ("iat".toList, v) ∈ ckvs → ∃ cs e, v = .num cs ∧ numVal cs = some e) ∧ + (∀ v, ("iss".toList, v) ∈ ckvs → ∃ s, v = .str s) ∧ + (∀ v, ("aud".toList, v) ∈ ckvs → + (∃ s, v = .str s) ∨ ∃ vs, v = .arr vs ∧ ∀ x ∈ vs, ∃ s, x = .str s) ∧ + (∀ i, ctx.issuer = some i → ("iss".toList, Json.Json.str i) ∈ ckvs) ∧ + (ctx.audience = none → "aud".toList ∉ ckvs.map Prod.fst) ∧ + (∀ a, ctx.audience = some a → + ("aud".toList, Json.Json.str a) ∈ ckvs ∨ + ∃ vs, ("aud".toList, Json.Json.arr vs) ∈ ckvs ∧ Json.Json.str a ∈ vs) := sorry diff --git a/comparator.json b/comparator.json index 1b5c5a7..dde8987 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after","Spec.accept_sound"]} \ No newline at end of file From c335d7dfad25b7bd01ac22c6b8955464074caa15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:31:13 +0000 Subject: [PATCH 12/23] Call the slice a JWT checker, name RFC 7515 A.4 as left out, warn on 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 36 +++++++++++++++++++++++++++--------- SPEC.md | 28 +++++++++++++++++++++------- tests/TEST-RESULTS.md | 12 +++++++----- tests/run_tests.py | 25 ++++++++++++++++++++++++- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4e33612..c5576cc 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ -# jws: a JWS compact parser and checker, with a proved round trip +# jws: a JWT parser and claims checker for the JWS compact form Status: in progress -A parser and checker for the JWS compact form (RFC 7515) -in Lean. 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`: the algorithm against a caller +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`: 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 @@ -32,6 +36,14 @@ 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. + ## How to run it `lake build` builds the libraries, the proofs, and the @@ -172,7 +184,7 @@ directly, the same functions the theorems are about. - Speed. The code works on lists, which suits proofs, not large tokens. -Five readings worth naming, all stated in `SPEC.md` and +Six readings worth naming, all stated in `SPEC.md` and pinned by tests: - An empty signature part is well formed base64url, but @@ -207,6 +219,12 @@ pinned by tests: section 2 lets a NumericDate be a non-integer), and are compared exactly with the current time, a whole number of seconds (`SPEC.md` rule 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. ## What you must trust diff --git a/SPEC.md b/SPEC.md index a775a2e..59b527b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,8 +1,14 @@ -# JWS compact form: parser and checker — specification - -This is the first slice of the jws repo: parse a JWS in compact -form (RFC 7515 section 5.2), run a set of shape and claims -checks on it, and give the received token back unchanged. +# JWT in the JWS compact form: parser and claims checker — specification + +This is the first slice of the jws repo: parse a JSON Web Token +(RFC 7519) in the JWS compact form (RFC 7515 section 5.2), run a +set of shape and claims checks on it, and give the received +token back unchanged. The payload must be a JSON object, a JWT +claims set. RFC 7515 lets a JWS carry any payload bytes; this +slice reads JWTs only, so a JWS whose payload is not JSON does +not parse. RFC 7515 Appendix A.4 (ES512, payload `Payload`) is +such a token, and is left out of the examples this slice +accepts. **This slice does not verify the signature.** A token that passes every check below is well formed and its claims are @@ -13,6 +19,13 @@ Signature verification is the second slice and is out of scope here. Do not treat a passing answer from this code as a verified token. +The algorithm allowlist does not stop key and algorithm +confusion (an RSA public key used as an HMAC secret, for +example). A 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, not HMAC and public +key algorithms together. + ## Framing A compact token is three base64url parts joined by dots: @@ -23,7 +36,8 @@ part1 "." part2 "." part3 - Part 1 is the header: UTF-8 JSON text, encoded in base64url. - Part 2 is the payload, the claims set: UTF-8 JSON text, - encoded in base64url. + encoded in base64url. (RFC 7515 allows any bytes here; a JWT + payload is a JSON claims set, and this slice reads JWTs.) - Part 3 is the signature: raw bytes, encoded in base64url. Exactly three parts. Two parts or four parts are turned away. @@ -216,7 +230,7 @@ separated decimal form as the payload: time (rules 10 and 11 skipped). - Field 3: the allowlist. The field text is the algorithm names joined by commas inside the text itself, for example the text - `HS256,RS256`. Empty means no algorithms are allowed. + `HS256,HS384`. Empty means no algorithms are allowed. - Field 4: the required claims, in the same comma joined style. Empty means no claims are required. - Field 5: the expected issuer. Empty means none. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index bb772f5..dd8abc9 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 466 +- Inputs: 467 - Failures: 0 ## The oracle and PyJWT @@ -14,13 +14,13 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 466 -- Agreements with the oracle: 466 -- Of the verdict cases: 47 accepted, 409 rejected, 10 malformed requests answered error +- Total check and request inputs: 467 +- Agreements with the oracle: 467 +- Of the verdict cases: 47 accepted, 410 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases -The A.5 example is the RFC's unsecured (alg none) compact example; in the final RFC text A.6 and A.7 are JSON Serialization examples, not compact tokens, so the none cases use A.5. The RFC 7519 section 3.1 example token is byte for byte the A.1 token, so it is covered by the A.1 cases. +The A.5 example is the RFC's unsecured (alg none) compact example; in the final RFC text A.6 and A.7 are JSON Serialization examples, not compact tokens, so the none cases use A.5. The RFC 7519 section 3.1 example token is byte for byte the A.1 token, so it is covered by the A.1 cases. A.4 (ES512) signs the payload `Payload`, which is not JSON: this slice reads JWTs, whose payload is a JSON claims set, so A.4 is a named departure from RFC 7515 and its fixed case expects refusal. - [ok] A.1 accept (HS256 allowed, now before exp, issuer joe) - [ok] A.1 reject (alg not allowed) @@ -35,6 +35,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - [ok] A.2 reject (alg not allowed) - [ok] A.3 accept (ES256 allowed, now before exp) - [ok] A.3 reject (now equal to exp) +- [ok] A.4 reject (ES512 allowed; payload "Payload" is not JSON, a JWS this JWT checker leaves out) - [ok] A.5 reject (alg none, HS256 allowed) - [ok] A.5 reject (alg none, allowlist names none) - [ok] A.5 reject (alg none, allowlist names none and HS256) @@ -46,6 +47,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from review 1 item 2: a header with a `crit` member, of any value, is turned away (SPEC rule 18). The slice understands no extension, and RFC 7515 section 4.1.11 makes a token invalid when `crit` names one the reader does not understand. Fixed cases cover b64, an unknown name, a URI name, an empty list, a string, null, and an escaped spelling; `crit` as a claim name is not a header member and passes. - Named change from review 1 item 3: an accepted token is answered with the token text as received, not a canonical reprint, so its signature still matches its signing input. The RFC 7515 A.1 HMAC (with the RFC's key) was checked on the tool's answer for 3 A.1 accept cases, and on the A.1 input itself: verifies. - Named change from review 1 item 6: with no expected audience, a token that carries `aud` is turned away (SPEC rule 16). RFC 7519 section 4.1.3 asks a reader that does not identify itself with a value in `aud` to reject the token. PyJWT reads it the same way; some other libraries accept such a token. Generated tokens with `aud` under a context with no audience now expect refusal in the oracle. +- From review 1 item 9: the allowlist does not stop key and algorithm confusion. The page warns that a 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. The generated contexts here mix families only as test data for the allowlist rule. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. - Named change from review 1 item 7: `exp`, `nbf` and `iat` may be non-integers (RFC 7519 section 2) and are compared exactly with the current time (SPEC rule 9). The earlier reading turned every fractional date away; the three fixed cases that pinned it now expect acceptance, and new cases cover fractions a millionth either side of now, equality, `nbf`, and exponents of -999999999. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the number 2000000000, and `1e999999999` compares without ever being written out. diff --git a/tests/run_tests.py b/tests/run_tests.py index c0b6609..ec47c69 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -357,6 +357,14 @@ def add_raw(name, line, want): ".DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQ" "xfKTUJqPP3-Kg6NU1Q") A5 = "eyJhbGciOiJub25lIn0." + PAYLOAD_B64 + "." +# A.4 (ES512) signs the payload "Payload", which is not JSON. The +# slice reads JWTs, whose payload is a JSON claims set, so A.4 does +# not parse: a named departure from RFC 7515's arbitrary payload +# (review 1 item 8). +A4 = ("eyJhbGciOiJFUzUxMiJ9.UGF5bG9hZA." + "AdwMgeerwtHoh-l192l60hp9wAHZFVJbLfD_UxMi70cwnZOYaRI1bKPWROc-mZZq" + "wqT2SI-KGDKB34XO0aw_7XdtAG8GaSwFKdCAPZgoXD2YBJZCPEX3xKpRwcdOO8Kp" + "EHwJjyqOgzDO7iKvU8vcnwNrmxYbSW9ERBXukOXolLzeO_Jn") add_check("A.1 accept (HS256 allowed, now before exp, issuer joe)", A1, 1300819000, ["HS256"], [], "joe", None) @@ -386,6 +394,9 @@ def add_raw(name, line, want): ["ES256"], [], None, None) add_check("A.3 reject (now equal to exp)", A3, 1300819380, ["ES256"], [], None, None) +add_check("A.4 reject (ES512 allowed; payload \"Payload\" is not JSON," + " a JWS this JWT checker leaves out)", A4, None, ["ES512"], [], + None, None) add_check("A.5 reject (alg none, HS256 allowed)", A5, 1300819000, ["HS256"], [], None, None) add_check("A.5 reject (alg none, allowlist names none)", A5, 1300819000, @@ -995,7 +1006,12 @@ def main(): " are JSON Serialization examples, not compact" " tokens, so the none cases use A.5. The RFC 7519" " section 3.1 example token is byte for byte the" - " A.1 token, so it is covered by the A.1 cases.") + " A.1 token, so it is covered by the A.1 cases." + " A.4 (ES512) signs the payload `Payload`, which is" + " not JSON: this slice reads JWTs, whose payload is" + " a JSON claims set, so A.4 is a named departure" + " from RFC 7515 and its fixed case expects" + " refusal.") lines_out.append("") for (name, _, _) in fixed: mark = "FAIL" if any(f[0] == name for f in fixed_fail) else "ok" @@ -1055,6 +1071,13 @@ def main(): " libraries accept such a token. Generated tokens" " with `aud` under a context with no audience now" " expect refusal in the oracle.") + lines_out.append("- From review 1 item 9: the allowlist does not stop" + " key and algorithm confusion. The page warns that a" + " 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. The" + " generated contexts here mix families only as test" + " data for the allowlist rule.") lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") lines_out.append("- Named change from review 1 item 7: `exp`, `nbf`" From 510ce065fc57829a14d276ccbd04e8b3d0b6132f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:34:28 +0000 Subject: [PATCH 13/23] Tests: PyJWT verdicts on probe tokens, and a seeded corpus of the new 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- tests/TEST-RESULTS.md | 22 +- tests/make_pyjwt_corpus.py | 160 +++++++++++- tests/pyjwt_corpus.json | 483 ++++++++++++++++++++++++++++++++++++- tests/run_tests.py | 224 +++++++++++++++++ 4 files changed, 875 insertions(+), 14 deletions(-) diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index dd8abc9..e15dc62 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 467 +- Inputs: 743 - Failures: 0 ## The oracle and PyJWT @@ -12,11 +12,25 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil Verdicts come from the oracle, which reimplements the SPEC.md rules in Python. PyJWT 2.15.1 built 20 tokens (HS256, RS256, ES256, and none algorithms), kept in tests/pyjwt_corpus.json with PyJWT's own unverified decode of each. The harness reads that file, so it needs no PyJWT to run; to rebuild the corpus, make a virtual environment from tests/requirements.txt and run tests/make_pyjwt_corpus.py (README, How to run it). PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. +## PyJWT verdicts on the probe tokens + +36 HS256 probe tokens, signed with a known key, were run through PyJWT's `jwt.decode` (with that key and each probe's algorithms, issuer, audience and required claims, at the time 1793208734) when the corpus was built, and through the tool at the same time. The expected verdicts are the oracle's; PyJWT's are compared beside them. 28 agree. Each disagreement is named below with its reason; an unnamed one, or a named one that no longer differs, is a failure. + +- duplicate alg, none then HS256: this slice rejects, PyJWT accepts. PyJWT reads the last copy of a repeated name, which RFC 7515 section 4 and RFC 7519 section 4 allow; this slice turns any repeat away, which they also allow (SPEC rule 17). +- duplicate alg spelled with an escape: this slice rejects, PyJWT accepts. PyJWT reads the last copy of a repeated name, which RFC 7515 section 4 and RFC 7519 section 4 allow; this slice turns any repeat away, which they also allow (SPEC rule 17). +- duplicate iss, joe then evil, issuer evil: this slice rejects, PyJWT accepts. PyJWT reads the last copy of a repeated name, which RFC 7515 section 4 and RFC 7519 section 4 allow; this slice turns any repeat away, which they also allow (SPEC rule 17). +- duplicate exp, expired then live: this slice rejects, PyJWT accepts. PyJWT reads the last copy of a repeated name, which RFC 7515 section 4 and RFC 7519 section 4 allow; this slice turns any repeat away, which they also allow (SPEC rule 17). +- duplicate name nobody reads: this slice rejects, PyJWT accepts. PyJWT reads the last copy of a repeated name, which RFC 7515 section 4 and RFC 7519 section 4 allow; this slice turns any repeat away, which they also allow (SPEC rule 17). +- b64 false without crit: this slice accepts, PyJWT rejects (DecodeError). PyJWT implements RFC 7797 and reads the payload as unencoded; this slice understands no extension and, as RFC 7515 section 4 asks of a parameter a reader does not understand, ignores b64 when crit does not list it (SPEC rule 18). +- iat in the future: this slice accepts, PyJWT rejects (ImmatureSignatureError). PyJWT refuses an iat after the current time; RFC 7519 section 4.1.6 sets no such check, and this slice reads iat for its type only (SPEC rule 12). +- exp a string: this slice rejects, PyJWT accepts. PyJWT converts the string to a number; RFC 7519 makes exp a NumericDate, a JSON number, and this slice turns a string away (SPEC rule 9). + ## Counts -- Total check and request inputs: 467 -- Agreements with the oracle: 467 -- Of the verdict cases: 47 accepted, 410 rejected, 10 malformed requests answered error +- Total check and request inputs: 743 +- Of these: 20 PyJWT-built tokens under 5 contexts each, 36 PyJWT verdict probes, 240 random corpus cases (seed 20261026), 240 shape corpus cases (seed 20261027), and 127 fixed and mutation cases +- Agreements with the oracle: 743 +- Of the verdict cases: 105 accepted, 628 rejected, 10 malformed requests answered error ## RFC 7515 fixed cases diff --git a/tests/make_pyjwt_corpus.py b/tests/make_pyjwt_corpus.py index 29a8682..3e48925 100644 --- a/tests/make_pyjwt_corpus.py +++ b/tests/make_pyjwt_corpus.py @@ -11,15 +11,29 @@ venv/bin/pip install -r tests/requirements.txt venv/bin/python tests/make_pyjwt_corpus.py -The corpus holds, for each token PyJWT built: the algorithm, the -token text, and PyJWT's own unverified decode of the header and -the claims. The RS256 and ES256 keys are made fresh on each -rebuild, so their signatures differ between rebuilds; nothing in -the harness depends on signature bytes beyond their being present. +The corpus holds two lists. + +- "tokens": for each token PyJWT built, the algorithm, the token + text, and PyJWT's own unverified decode of the header and the + claims. The RS256 and ES256 keys are made fresh on each rebuild, + so their signatures differ between rebuilds; nothing in the + harness depends on signature bytes beyond their being present. +- "probes": HS256 tokens built here byte by byte (so a header can + repeat a name or carry crit) and signed with PROBE_KEY, each with + a check context and PyJWT's verdict on it: jwt.decode with that + key, the context's algorithms, issuer, audience and required + claims, at the time recorded in "probe_now". The harness runs the + same token and context, with the current time set to "probe_now", + and every verdict that differs from PyJWT's must be a named + difference with its reason. """ +import base64 +import hashlib +import hmac import json import sys +import time from pathlib import Path import cryptography @@ -28,6 +42,124 @@ OUT = Path(__file__).resolve().parent / "pyjwt_corpus.json" +# The HMAC key for the probes: 32 bytes, so PyJWT raises no key +# length warning. +PROBE_KEY = "jws-slice-probe-key-0123456789ab" + +FAR = 2000000000 # an expiry well after any probe_now +PAST = 1400000000 # a not-before well before any probe_now + +# (name, header JSON text, claims JSON text, signature kind, context) +# The signature kind is "hmac" (a correct HS256 signature over the +# parts as written) or "empty". The context gives allow, issuer, +# audience and required, as the harness's check context does. +BASE_CTX = {"allow": ["HS256"], "issuer": None, "audience": None, + "required": []} +PROBES = [ + ("plain", '{"alg":"HS256","typ":"JWT"}', + '{"iss":"joe","exp":%d}' % FAR, "hmac", BASE_CTX), + ("CR LF and spaces in both parts, as in A.1", + '{"typ":"JWT",\r\n "alg":"HS256"}', + '{"iss":"joe",\r\n "exp":%d,\r\n "nbf":%d}' % (FAR, PAST), "hmac", + dict(BASE_CTX, issuer="joe")), + ("escaped characters in names and values", + '{"\\u0061lg":"HS256"}', + '{"\\u0069ss":"j\\u00e9","exp":%d}' % FAR, "hmac", + dict(BASE_CTX, issuer="j\u00e9")), + ("duplicate alg, HS256 then none", '{"alg":"HS256","alg":"none"}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("duplicate alg, none then HS256", '{"alg":"none","alg":"HS256"}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("duplicate alg spelled with an escape", + '{"alg":"none","\\u0061lg":"HS256"}', '{"exp":%d}' % FAR, "hmac", + BASE_CTX), + ("duplicate iss, joe then evil, issuer evil", '{"alg":"HS256"}', + '{"iss":"joe","exp":%d,"iss":"evil"}' % FAR, "hmac", + dict(BASE_CTX, issuer="evil")), + ("duplicate exp, expired then live", '{"alg":"HS256"}', + '{"exp":1000000000,"exp":%d}' % FAR, "hmac", BASE_CTX), + ("duplicate name nobody reads", '{"alg":"HS256"}', + '{"foo":1,"exp":%d,"foo":2}' % FAR, "hmac", BASE_CTX), + ("crit naming b64", '{"alg":"HS256","b64":false,"crit":["b64"]}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("crit naming an unknown extension", + '{"alg":"HS256","crit":["x-unknown"],"x-unknown":1}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("crit an empty list", '{"alg":"HS256","crit":[]}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("crit not a list", '{"alg":"HS256","crit":"exp"}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("b64 false without crit", '{"alg":"HS256","b64":false}', + '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("fractional exp", '{"alg":"HS256"}', '{"exp":%d.5}' % FAR, "hmac", + BASE_CTX), + ("fractional iat and nbf", '{"alg":"HS256"}', + '{"exp":%d,"iat":%d.25,"nbf":%d.75}' % (FAR, PAST, PAST), "hmac", + BASE_CTX), + ("exp in exponent form", '{"alg":"HS256"}', '{"exp":2.0e9}', "hmac", + BASE_CTX), + ("expired", '{"alg":"HS256"}', '{"exp":%d}' % PAST, "hmac", BASE_CTX), + ("not yet valid", '{"alg":"HS256"}', + '{"exp":%d,"nbf":%d}' % (FAR, FAR - 1), "hmac", BASE_CTX), + ("iat in the future", '{"alg":"HS256"}', + '{"exp":%d,"iat":%d}' % (FAR, FAR - 1), "hmac", BASE_CTX), + ("exp a string", '{"alg":"HS256"}', '{"exp":"%d"}' % FAR, "hmac", + BASE_CTX), + ("aud present, no audience expected", '{"alg":"HS256"}', + '{"exp":%d,"aud":"other-tenant"}' % FAR, "hmac", BASE_CTX), + ("aud present, the same audience expected", '{"alg":"HS256"}', + '{"exp":%d,"aud":"other-tenant"}' % FAR, "hmac", + dict(BASE_CTX, audience="other-tenant")), + ("aud array holding the expected audience", '{"alg":"HS256"}', + '{"exp":%d,"aud":["api","web"]}' % FAR, "hmac", + dict(BASE_CTX, audience="web")), + ("aud array without the expected audience", '{"alg":"HS256"}', + '{"exp":%d,"aud":["api","web"]}' % FAR, "hmac", + dict(BASE_CTX, audience="mobile")), + ("issuer expected, iss missing", '{"alg":"HS256"}', + '{"exp":%d}' % FAR, "hmac", dict(BASE_CTX, issuer="joe")), + ("issuer expected, iss wrong", '{"alg":"HS256"}', + '{"iss":"bob","exp":%d}' % FAR, "hmac", dict(BASE_CTX, issuer="joe")), + ("required claim missing", '{"alg":"HS256"}', '{"exp":%d}' % FAR, + "hmac", dict(BASE_CTX, required=["sub"])), + ("required claim present", '{"alg":"HS256"}', + '{"exp":%d,"sub":"u"}' % FAR, "hmac", dict(BASE_CTX, required=["sub"])), + ("alg outside the allowlist", '{"alg":"HS256"}', '{"exp":%d}' % FAR, + "hmac", dict(BASE_CTX, allow=["HS384"])), + ("alg none", '{"alg":"none"}', '{"exp":%d}' % FAR, "empty", BASE_CTX), + ("empty signature", '{"alg":"HS256"}', '{"exp":%d}' % FAR, "empty", + BASE_CTX), + ("payload not JSON", '{"alg":"HS256"}', 'Payload', "hmac", BASE_CTX), + ("payload a JSON array", '{"alg":"HS256"}', '[1,2]', "hmac", BASE_CTX), + ("header an array", '["HS256"]', '{"exp":%d}' % FAR, "hmac", BASE_CTX), + ("repeat inside a nested object", '{"alg":"HS256"}', + '{"exp":%d,"ctx":{"a":1,"a":2}}' % FAR, "hmac", BASE_CTX), +] + + +def b64url(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def probe_token(header_text, claims_text, sig_kind): + signing_input = (b64url(header_text.encode("utf-8")) + "." + + b64url(claims_text.encode("utf-8"))) + if sig_kind == "empty": + return signing_input + "." + mac = hmac.new(PROBE_KEY.encode(), signing_input.encode("ascii"), + hashlib.sha256).digest() + return signing_input + "." + b64url(mac) + + +def pyjwt_verdict(token, ctx): + try: + jwt.decode(token, PROBE_KEY, algorithms=ctx["allow"], + issuer=ctx["issuer"], audience=ctx["audience"], + options={"require": ctx["required"]}) + return "accept" + except Exception as e: # every PyJWT refusal is an exception + return "reject (%s)" % type(e).__name__ + def main(): rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -68,13 +200,29 @@ def main(): "claims": jwt.decode(tok, options={"verify_signature": False}), }) + probe_now = int(time.time()) + probes = [] + for name, header_text, claims_text, sig_kind, ctx in PROBES: + tok = probe_token(header_text, claims_text, sig_kind) + probes.append({ + "name": name, + "token": tok, + "context": ctx, + "pyjwt": pyjwt_verdict(tok, ctx), + }) + if int(time.time()) - probe_now > 60: + raise RuntimeError("the probes took too long to stay at one time") + out = { "pyjwt": jwt.__version__, "cryptography": cryptography.__version__, + "probe_now": probe_now, "tokens": tokens, + "probes": probes, } OUT.write_text(json.dumps(out, indent=1, ensure_ascii=False) + "\n") - print("wrote %d tokens to %s" % (len(tokens), OUT)) + print("wrote %d tokens and %d probes to %s" + % (len(tokens), len(probes), OUT)) return 0 diff --git a/tests/pyjwt_corpus.json b/tests/pyjwt_corpus.json index c54827c..06c4f45 100644 --- a/tests/pyjwt_corpus.json +++ b/tests/pyjwt_corpus.json @@ -1,6 +1,7 @@ { "pyjwt": "2.15.1", "cryptography": "46.0.3", + "probe_now": 1793208734, "tokens": [ { "alg": "HS256", @@ -238,7 +239,7 @@ }, { "alg": "RS256", - "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.Py8YuPr5CNw74vDDCP5SwvIsQvGC8Kg5rO59w-epWLvta8eZ0-1ChzAiNqqueKgrbmCy315uABwICz0RAvU-5aIXcSSmls5p4TA8r3j_jMuD0RlgGmcP5KWyYdgDl_iacctTKNjKYSuBALkMghcDhRugTRr2W19uvVJ5nMOMzxkAo-vmesz27gho8Xz1mXmpwCAwDGJjA3M7nNUzdOCuxt0YSA-dTrnXyTe7QtQ8_y3tmOEKperpUsc787zhymxqHsol-rM2WqbZ5CCwf0n_KRXQ3xzi0ZaUlLY-_0yNqMtTY8fFK884QFj5lkJ8iKfxMEbhGPSNmHCKKZHgxIq7qQ", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.eHh3JESuqTsiAGnM9Pynl-EleWdijlxqaMMHdCnH4X8i6pljHNzNonGPbTX1UBhIqHmrfrYPOqkuZVK7VlkDuWjaX3eWV64NBTD87uxARFx9AV-mZHRZUxZuZxQ7Oh9E4frUOc5-W8xTkCbuHTNgh_0g4HT8bC9oYO3rKLBUmrTiuklEMH3ZjLIRvy1ho7501k5aqAqqStB9mbyh5iqSW7tg1GS5gJU5CIBr0KX3zHWsOwM9nAXsg-58RqHK1Nb3jIlL7Wn0pTxwzEcq6Lfghyl98JAzUd9bf1Gf27pZ0_Dl_C9bx0JF9D6C2Kgba9PdcsjMUzZ2_Mw1uxCowqkmKg", "header": { "alg": "RS256", "typ": "JWT" @@ -254,7 +255,7 @@ }, { "alg": "RS256", - "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImV4cCI6MjAwMDAwMDAwMCwiYXVkIjpbImFwaSIsIndlYiJdfQ.O2TviK2bH-Wy4AyBZFPAMcfXlSm3leI3eV6XAytjpF9P8b6xEBCWmV4HYB7SSehg17yR2RCvJvPLgviHjL0WOPpCIOtnoDud8PYmPiHdzzO3EUJDG1jvfFnxDfQTQkmxFEjkfba5McLlFrVW9UVKc3wEw31UCkaRHveI6xkKUJJ0l7kOZaeo_ftSCGpWJZTiZL6Y5WRDaqWovMM44n0cHgB6NxnkDVM34k5qRL1VTG4hwphqVCppZcDEOS6qIB2_DSXbR1CCtmMfCTHaNSPIqZMqpFWCQyD9L-7lPNTc6uXeAgWnouWWrjsUsHvNjIUB8bJlXAy7LzXE1EORvQimqQ", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJpc3N1ZXIuZXhhbXBsZSIsImV4cCI6MjAwMDAwMDAwMCwiYXVkIjpbImFwaSIsIndlYiJdfQ.miB1x7FMvVyARLAsHsV05E2-UOZn9chayNOnMNZryhU_5-mFUx7JHZbwn8N7qKEfaeY0kocjdKyB7sVmrILeZTE134PPLFZPSShlEaTFGwns4jq1OEiHXXEpZPviURkubghxv6fiWtG32biiYlkh_hSIurLTFCXozy-ih0H_no-VHM79qpJFhVSScQK1l_TkqyACBnsax7Cfg22Uz_PFayiuAAIfUpwO9sM5pbeRgqphBwH1iINw4m-qhW3wclm1kb_yFpB19MNEIlslX9L851dptvZHPDYZkjZLOfcQJZd7xU5BYsb2W4KE7fZp_C6s3eweI_XBvby0ZeXwIUIIuw", "header": { "alg": "RS256", "typ": "JWT" @@ -270,7 +271,7 @@ }, { "alg": "ES256", - "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.CLtfjBvU_DGQgEGJzFOWFki-Cg3FTaNNO6vEg79mH0XoiUFwX1XpKqrfghVc6iGHTpqmXosiqtIGQI2aGdD3qw", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTQwMDAwMDAwMCwiaWF0IjoxNDAwMDAwMDAwLCJhdWQiOiJhcGkiLCJzdWIiOiJ1c2VyLTEifQ.JpwT4nFb2E1YBNyPr_a97qcfg6_mJQ5lJtasSGL4HQWYDvkLGR9YQIXL3Jj4ruOi750esyclJZPzCIxof_Z-kQ", "header": { "alg": "ES256", "typ": "JWT" @@ -286,7 +287,7 @@ }, { "alg": "ES256", - "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjk5OTk5OTk5OTksImF1ZCI6WyJhIiwiYiIsImMiXSwiYWRtaW4iOnRydWV9.idtR-M4ID4n_AwjQqUleLw4NGXHxewPmnsfDVG82qJMvhPZGhG2jxrdgiRWOWZ3T9LMkiM8c5Cpf66Qb2TdAvw", + "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjk5OTk5OTk5OTksImF1ZCI6WyJhIiwiYiIsImMiXSwiYWRtaW4iOnRydWV9.UD-j2oZLgtZdPea9_zrDbXfkRP39SIyCNSEGeDvzPDrtlLZcH-Jlnin2TdM-CT8eyZSRs61A7MLtofAvJid3tA", "header": { "alg": "ES256", "typ": "JWT" @@ -302,5 +303,479 @@ "admin": true } } + ], + "probes": [ + { + "name": "plain", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDB9.Dt_RmuAlYFTBusgynL4IRPoc75FZeamwciZmJNXUwZg", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "CR LF and spaces in both parts, as in A.1", + "token": "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjIwMDAwMDAwMDAsDQogIm5iZiI6MTQwMDAwMDAwMH0.RdETpN2f7dWYq2lnHWbiAvtT183OfRNQyXq8qIAtmHE", + "context": { + "allow": [ + "HS256" + ], + "issuer": "joe", + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "escaped characters in names and values", + "token": "eyJcdTAwNjFsZyI6IkhTMjU2In0.eyJcdTAwNjlzcyI6ImpcdTAwZTkiLCJleHAiOjIwMDAwMDAwMDB9._LgGj9tAQtNzLE-EDzOEqO6DICWmJx94dq9fGjUIlcw", + "context": { + "allow": [ + "HS256" + ], + "issuer": "jé", + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "duplicate alg, HS256 then none", + "token": "eyJhbGciOiJIUzI1NiIsImFsZyI6Im5vbmUifQ.eyJleHAiOjIwMDAwMDAwMDB9.RnUdW2kgsB1FTcImw8PXOKE_6f4h7ZXBU7Hq36dWaG4", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidAlgorithmError)" + }, + { + "name": "duplicate alg, none then HS256", + "token": "eyJhbGciOiJub25lIiwiYWxnIjoiSFMyNTYifQ.eyJleHAiOjIwMDAwMDAwMDB9.HqieASm9jz33gZvgMKKHYbMSR-SlMhBxzdF1hc1xGLs", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "duplicate alg spelled with an escape", + "token": "eyJhbGciOiJub25lIiwiXHUwMDYxbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDB9.YcTtVSxUawh6KYLry5H2NJvYO-Z35oJ1mZ_QPu_zIkM", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "duplicate iss, joe then evil, issuer evil", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjIwMDAwMDAwMDAsImlzcyI6ImV2aWwifQ.d0KO09LX648S7ozKajS0K08JsjBI1mw1jYvZeKDS_2s", + "context": { + "allow": [ + "HS256" + ], + "issuer": "evil", + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "duplicate exp, expired then live", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEwMDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.BtDjYDl_OED-X8ztljI26TiJnyTZfE9m07b0nwl2ygI", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "duplicate name nobody reads", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJmb28iOjEsImV4cCI6MjAwMDAwMDAwMCwiZm9vIjoyfQ.bQrIF7zN_AeW-cn5-YsjodVO5qJ-ffJp7VtD3wYK5lo", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "crit naming b64", + "token": "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19.eyJleHAiOjIwMDAwMDAwMDB9.A8DWDRA5kEa3whsdI71dXkwB67fAqydJouM176bhYiY", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (DecodeError)" + }, + { + "name": "crit naming an unknown extension", + "token": "eyJhbGciOiJIUzI1NiIsImNyaXQiOlsieC11bmtub3duIl0sIngtdW5rbm93biI6MX0.eyJleHAiOjIwMDAwMDAwMDB9.yNRTehy4J4DTwlsTPv7oGezui2gjEF43-5pGFvMs-N4", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidTokenError)" + }, + { + "name": "crit an empty list", + "token": "eyJhbGciOiJIUzI1NiIsImNyaXQiOltdfQ.eyJleHAiOjIwMDAwMDAwMDB9.cZyFAIXLq0pBewTZi_aNlEj7eA8QbQ8WhkKgtjbx5b8", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidTokenError)" + }, + { + "name": "crit not a list", + "token": "eyJhbGciOiJIUzI1NiIsImNyaXQiOiJleHAifQ.eyJleHAiOjIwMDAwMDAwMDB9.qXjW_bSQRLSj8P0j419CaIdfXn6ln_qxYXAqKN5BLEY", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidTokenError)" + }, + { + "name": "b64 false without crit", + "token": "eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2V9.eyJleHAiOjIwMDAwMDAwMDB9.myUKnfGKeYEdTbsrHJFOzH-rrYV89FQZuMxWO4yzPvc", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (DecodeError)" + }, + { + "name": "fractional exp", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAuNX0.B2m48jt3huo2OwqAB5ibr35KN8470Vlnqn6avEzW-T0", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "fractional iat and nbf", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTQwMDAwMDAwMC4yNSwibmJmIjoxNDAwMDAwMDAwLjc1fQ.MEld2lVeL0GSeow9AnVun3ZTwu1qkjbBqlhN3oDfd4Y", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "exp in exponent form", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIuMGU5fQ.bIoPVKk_qSJgORB2_B557Jsl1xNj9NGxMDcjOQprw7c", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "expired", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE0MDAwMDAwMDB9.XY1CF7sN5ifCsN5TpNj_aMENFNSP9ytnbPiB5YsxSvw", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (ExpiredSignatureError)" + }, + { + "name": "not yet valid", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsIm5iZiI6MTk5OTk5OTk5OX0.4SMXeYW1lplK_ETLlhnzZL84Vj4yARhcGvHi9AWse50", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (ImmatureSignatureError)" + }, + { + "name": "iat in the future", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTk5OTk5OTk5OX0.eO1C8mwxi_QgsGe6HzUw4FjEj4skzChhblQW9p5NqFA", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (ImmatureSignatureError)" + }, + { + "name": "exp a string", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOiIyMDAwMDAwMDAwIn0.tWaU8M3_Lqa-jiua1y3oG-u-xcIqHDuRaZa9PsbrDzs", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "aud present, no audience expected", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.PkiGPcwWluHdmVF-v68uByDbMGIq-mfqtRsHNx-7Jk8", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidAudienceError)" + }, + { + "name": "aud present, the same audience expected", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImF1ZCI6Im90aGVyLXRlbmFudCJ9.PkiGPcwWluHdmVF-v68uByDbMGIq-mfqtRsHNx-7Jk8", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": "other-tenant", + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "aud array holding the expected audience", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImF1ZCI6WyJhcGkiLCJ3ZWIiXX0.srJzr9KPj60MqeZhQ5MzSkk9ydx9Hh5MC2CCm07jcc8", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": "web", + "required": [] + }, + "pyjwt": "accept" + }, + { + "name": "aud array without the expected audience", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImF1ZCI6WyJhcGkiLCJ3ZWIiXX0.srJzr9KPj60MqeZhQ5MzSkk9ydx9Hh5MC2CCm07jcc8", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": "mobile", + "required": [] + }, + "pyjwt": "reject (InvalidAudienceError)" + }, + { + "name": "issuer expected, iss missing", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDB9.S5nIpKib63xhZGXpmWZKfWRbhCwQKiDGB8EvWWd3aIs", + "context": { + "allow": [ + "HS256" + ], + "issuer": "joe", + "audience": null, + "required": [] + }, + "pyjwt": "reject (MissingRequiredClaimError)" + }, + { + "name": "issuer expected, iss wrong", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJib2IiLCJleHAiOjIwMDAwMDAwMDB9.OZczHeAW__3oJNmhPGWq6BEmS8ceCKxStX1lm_svMxg", + "context": { + "allow": [ + "HS256" + ], + "issuer": "joe", + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidIssuerError)" + }, + { + "name": "required claim missing", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDB9.S5nIpKib63xhZGXpmWZKfWRbhCwQKiDGB8EvWWd3aIs", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [ + "sub" + ] + }, + "pyjwt": "reject (MissingRequiredClaimError)" + }, + { + "name": "required claim present", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsInN1YiI6InUifQ.O4b-ozoslJYAyU_0S2_1q-OnrW3HEc2Oaf_6GHBoZdg", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [ + "sub" + ] + }, + "pyjwt": "accept" + }, + { + "name": "alg outside the allowlist", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDB9.S5nIpKib63xhZGXpmWZKfWRbhCwQKiDGB8EvWWd3aIs", + "context": { + "allow": [ + "HS384" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidAlgorithmError)" + }, + { + "name": "alg none", + "token": "eyJhbGciOiJub25lIn0.eyJleHAiOjIwMDAwMDAwMDB9.", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidAlgorithmError)" + }, + { + "name": "empty signature", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDB9.", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (InvalidSignatureError)" + }, + { + "name": "payload not JSON", + "token": "eyJhbGciOiJIUzI1NiJ9.UGF5bG9hZA.fhR7UeQoJw9Yp7oHqQayOPD9J4PYGYTgPSrqXjkjtA4", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (DecodeError)" + }, + { + "name": "payload a JSON array", + "token": "eyJhbGciOiJIUzI1NiJ9.WzEsMl0.n2S-vxy54E2BWTxxZsmm4JwpIVuZFN5rl1BM2S7xES4", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (DecodeError)" + }, + { + "name": "header an array", + "token": "WyJIUzI1NiJd.eyJleHAiOjIwMDAwMDAwMDB9.FH3JEdEGAjaDTqPz9xIbckZrmGI4j8aE6l50FD5Gleo", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "reject (DecodeError)" + }, + { + "name": "repeat inside a nested object", + "token": "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMDAwMDAwMDAsImN0eCI6eyJhIjoxLCJhIjoyfX0.rzox9_bowiomb1LSMA3rktUc2Uu6UQpyW85Z9p6UAmU", + "context": { + "allow": [ + "HS256" + ], + "issuer": null, + "audience": null, + "required": [] + }, + "pyjwt": "accept" + } ] } diff --git a/tests/run_tests.py b/tests/run_tests.py index ec47c69..ac1555a 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -832,6 +832,79 @@ def plain(v): tok, now, allow, required, issuer, audience) +# --------------------------------------------------------------- +# PyJWT verdict probes: HS256 tokens signed with a known key, with +# PyJWT's own verdict (jwt.decode with the key and the context), +# recorded in the corpus. The expected verdict is still the +# oracle's; PyJWT's is compared beside it, and every disagreement +# must be one of the named differences below, with its reason. +# --------------------------------------------------------------- + +PYJWT_LAST_COPY = ("PyJWT reads the last copy of a repeated name, which" + " RFC 7515 section 4 and RFC 7519 section 4 allow;" + " this slice turns any repeat away, which they also" + " allow (SPEC rule 17).") +PYJWT_NAMED = { + "duplicate alg, none then HS256": PYJWT_LAST_COPY, + "duplicate alg spelled with an escape": PYJWT_LAST_COPY, + "duplicate iss, joe then evil, issuer evil": PYJWT_LAST_COPY, + "duplicate exp, expired then live": PYJWT_LAST_COPY, + "duplicate name nobody reads": PYJWT_LAST_COPY, + "b64 false without crit": ( + "PyJWT implements RFC 7797 and reads the payload as unencoded;" + " this slice understands no extension and, as RFC 7515 section 4" + " asks of a parameter a reader does not understand, ignores b64" + " when crit does not list it (SPEC rule 18)."), + "iat in the future": ( + "PyJWT refuses an iat after the current time; RFC 7519 section" + " 4.1.6 sets no such check, and this slice reads iat for its" + " type only (SPEC rule 12)."), + "exp a string": ( + "PyJWT converts the string to a number; RFC 7519 makes exp a" + " NumericDate, a JSON number, and this slice turns a string away" + " (SPEC rule 9)."), +} +PROBE_INFO = {"count": 0, "agree": 0, "named": [], "unnamed": [], + "stale": []} +PROBE_CASES = [] + + +def build_probe_cases(): + data = json.loads(CORPUS.read_text(encoding="utf-8")) + now = data["probe_now"] + PROBE_INFO["now"] = now + for item in data["probes"]: + ctx = item["context"] + name = "pyjwt probe: " + item["name"] + add_check(name, item["token"], now, ctx["allow"], ctx["required"], + ctx["issuer"], ctx["audience"]) + PROBE_CASES.append((name, item["name"], item["pyjwt"])) + + +def compare_probes(results): + """results maps case name to the tool's verdict, 'accept' or + 'reject'. Returns the failures: unnamed disagreements and named + differences that no longer differ.""" + failures = [] + for name, probe, pyjwt in PROBE_CASES: + ours = results[name] + theirs = "accept" if pyjwt == "accept" else "reject" + PROBE_INFO["count"] += 1 + if ours == theirs: + PROBE_INFO["agree"] += 1 + if probe in PYJWT_NAMED: + PROBE_INFO["stale"].append(probe) + failures.append((name + " (named difference no longer" + " differs)", ours, pyjwt)) + elif probe in PYJWT_NAMED: + PROBE_INFO["named"].append((probe, ours, pyjwt)) + else: + PROBE_INFO["unnamed"].append((probe, ours, pyjwt)) + failures.append((name + " (differs from PyJWT, unnamed)", ours, + pyjwt)) + return failures + + # --------------------------------------------------------------- # Random differential corpus (oracle builds and settles all). # --------------------------------------------------------------- @@ -903,6 +976,116 @@ def rand_claims(): required, issuer, audience) +def build_shape_cases(count=240): + """Seeded corpus of the shapes the rules turn on: repeated names + in the header and the claims (also spelled with JSON escapes), + crit members of several kinds, b64, whitespace and CR LF between + the JSON tokens, fractional and exponent date values, aud with + and without an expected audience, and repeats inside nested + objects. The JSON text is written by hand so that names can + repeat. Seed SEED + 1, so the random corpus above is unchanged.""" + rng = random.Random(SEED + 1) + + def name_text(n): + if rng.random() < 0.15: + i = rng.randrange(len(n)) + return '"%s\\u%04x%s"' % (n[:i], ord(n[i]), n[i + 1:]) + return json.dumps(n) + + def obj_text(members): + style = rng.choice(["compact", "compact", "spaced", "crlf"]) + if style == "compact": + sep, colon, open_, close = ",", ":", "{", "}" + elif style == "spaced": + sep, colon, open_, close = ", ", " : ", "{ ", " }" + else: + sep, colon, open_, close = ",\r\n ", ":", "{", "\r\n}" + return open_ + sep.join(name_text(k) + colon + v + for (k, v) in members) + close + + def date_value(base): + form = rng.random() + if form < 0.5: + return str(base) + if form < 0.7: + return "%d.%d" % (base, rng.randrange(1, 10)) + if form < 0.8: + return "%de0" % base + if form < 0.9: + return "%d0e-1" % base + return "%d.000001" % (base - 1) + + for i in range(count): + alg = rng.choice(["HS256", "HS256", "RS256", "none", "ES256"]) + header = [("alg", json.dumps(alg))] + if rng.random() < 0.4: + header.append(("typ", '"JWT"')) + if rng.random() < 0.2: + header.append(("kid", '"k%d"' % rng.randint(1, 3))) + if rng.random() < 0.2: + header.append(("crit", rng.choice( + ['["b64"]', '[]', '["x-ext"]', '"exp"', 'null', '1']))) + if rng.random() < 0.5: + header.append(("b64", "false")) + elif rng.random() < 0.1: + header.append(("b64", "false")) + if rng.random() < 0.15: + k, v = rng.choice(header) + header.insert(rng.randrange(len(header) + 1), + (k, rng.choice([v, '"none"', '"HS256"', '1']))) + claims = [] + iss = aud = None + if rng.random() < 0.8: + iss = rng.choice(["joe", "bob"]) + claims.append(("iss", json.dumps(iss))) + if rng.random() < 0.85: + claims.append(("exp", date_value(rng.choice( + [1499999999, 1500000000, 1500000001, 2000000000])))) + if rng.random() < 0.4: + claims.append(("nbf", date_value(rng.choice( + [1400000000, 1500000000, 1500000001])))) + if rng.random() < 0.3: + claims.append(("iat", date_value(1450000000))) + if rng.random() < 0.4: + aud = rng.choice(['"api"', '"web"', '["api","web"]', '[]', + '["web",7]']) + claims.append(("aud", aud)) + if rng.random() < 0.15: + claims.append(("ctx", rng.choice( + ['{"a":1,"a":2}', '{"a":{"b":1,"b":2}}', '{"a":1}']))) + if claims and rng.random() < 0.2: + k, _ = rng.choice(claims) + claims.insert(rng.randrange(len(claims) + 1), + (k, rng.choice(['"joe"', '2000000000', '1']))) + rng.shuffle(claims) + sig = bytes(rng.randrange(256) + for _ in range(0 if rng.random() < 0.1 else 32)) + tok = hand_token(obj_text(header).encode("utf-8"), + obj_text(claims).encode("utf-8"), sig) + if rng.random() < 0.7: + # A context made for this token, so that the shape alone + # decides: its alg allowed, its issuer and an audience it + # names expected, only claims it has required. + now = rng.choice([None, 1500000000]) + allow = [alg] if alg != "none" else ["HS256"] + required = rng.choice([[], [k for (k, _) in claims][:1]]) + issuer = rng.choice([None, iss]) + audience = None + if aud is not None: + names = [x for x in json.loads(aud) if isinstance(x, str)] \ + if aud.startswith("[") else [json.loads(aud)] + audience = rng.choice(names) if names else "api" + else: + now = rng.choice([None, 1500000000, 1450000000]) + allow = rng.choice([["HS256"], ["HS256", "RS256"], ["ES256"], + ["RS256"], ["HS256", "none"]]) + required = rng.choice([[], [], ["iss"], ["exp", "iss"], ["sub"]]) + issuer = rng.choice([None, None, "joe", "bob"]) + audience = rng.choice([None, None, "api", "web"]) + add_check("shape %d" % i, tok, now, allow, required, issuer, + audience) + + # --------------------------------------------------------------- # Run everything and write the record. # --------------------------------------------------------------- @@ -912,7 +1095,9 @@ def main(): print("executable missing; run lake build first", file=sys.stderr) return 1 build_pyjwt_cases() + build_probe_cases() build_random_cases() + build_shape_cases() lines = [req for (_, req, _) in CASES] answers = run_lean(lines) if len(answers) != len(CASES): @@ -923,12 +1108,14 @@ def main(): accepted = 0 rejected = 0 errors = 0 + verdicts = {} for (name, _, want), ans in zip(CASES, answers): try: got = decode_answer(ans) except ValueError: failures.append((name, "unreadable answer " + ans[:60], want)) continue + verdicts[name] = "reject" if got is None else "accept" if want == "error": errors += 1 ok = got == "error" @@ -953,6 +1140,8 @@ def main(): if not (isinstance(got, str) and a1_signature_ok(got)): failures.append((name + " (A.1 HMAC on the answer)", got, want)) + failures += compare_probes(verdicts) + fixed = [c for c in CASES if c[0].startswith("A.")] fixed_fail = [f for f in failures if f[0].startswith("A.")] @@ -990,9 +1179,44 @@ def main(): PYJWT_INFO["decode_agreements"], len(PYJWT_INFO["decode_differences"]))) lines_out.append("") + lines_out.append("## PyJWT verdicts on the probe tokens") + lines_out.append("") + lines_out.append("%d HS256 probe tokens, signed with a known key, were" + " run through PyJWT's `jwt.decode` (with that key and" + " each probe's algorithms, issuer, audience and" + " required claims, at the time %d) when the corpus" + " was built, and through the tool at the same time." + " The expected verdicts are the oracle's; PyJWT's" + " are compared beside them. %d agree. Each" + " disagreement is named below with its reason; an" + " unnamed one, or a named one that no longer" + " differs, is a failure." + % (PROBE_INFO["count"], PROBE_INFO["now"], + PROBE_INFO["agree"])) + lines_out.append("") + for probe, ours, pyjwt in PROBE_INFO["named"]: + lines_out.append("- %s: this slice %ss, PyJWT %s. %s" + % (probe, ours, pyjwt.replace("reject", "rejects") + .replace("accept", "accepts"), + PYJWT_NAMED[probe])) + for probe, ours, pyjwt in PROBE_INFO["unnamed"]: + lines_out.append("- UNNAMED %s: this slice %ss, PyJWT %s." + % (probe, ours, pyjwt)) + lines_out.append("") lines_out.append("## Counts") lines_out.append("") lines_out.append("- Total check and request inputs: %d" % len(CASES)) + lines_out.append("- Of these: %d PyJWT-built tokens under %d contexts" + " each, %d PyJWT verdict probes, %d random corpus" + " cases (seed %d), %d shape corpus cases (seed %d)," + " and %d fixed and mutation cases" + % (PYJWT_INFO["tokens"], 5, PROBE_INFO["count"], + sum(1 for c in CASES if c[0].startswith("random ")), + SEED, + sum(1 for c in CASES if c[0].startswith("shape ")), + SEED + 1, + sum(1 for c in CASES if not c[0].startswith( + ("random ", "shape ", "pyjwt "))))) lines_out.append("- Agreements with the oracle: %d" % (len(CASES) - len(failures))) lines_out.append("- Of the verdict cases: %d accepted, %d rejected," From b26906437daa8310b69a631eb2aeef3e152d30bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:36:25 +0000 Subject: [PATCH 14/23] Page: bring the intro, test, trust and re-check text in line with the 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Token.lean | 13 +++++---- README.md | 72 ++++++++++++++++++++++++++++++-------------------- SPEC.md | 4 +-- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/Jws/Token.lean b/Jws/Token.lean index 9f35051..f714eb2 100644 --- a/Jws/Token.lean +++ b/Jws/Token.lean @@ -9,11 +9,14 @@ import Jws.Utf8 Part three is the signature bytes. This slice parses the compact form and runs the plan's checks: - the algorithm against a caller allowlist, the required claims, - and the exp, nbf, iss, and aud claims against a context the - caller supplies. It does not verify the signature; a token that - passes these checks is not thereby verified. Signature - verification belongs to the second slice. + 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 payload must be JSON: this + slice reads JWTs (RFC 7519), whose payload is a claims set. It + does not verify the signature; a token that passes these checks + is not thereby verified. Signature verification belongs to the + second slice. The JSON code is the json repo's, carried in unchanged (the repos in this org do not import each other): `Json.parse` reads diff --git a/README.md b/README.md index c5576cc..6047143 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,15 @@ 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`: the algorithm against a caller +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 +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 @@ -65,11 +67,16 @@ 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, and the differential corpus against an -oracle implementing the same rules, and writes the -counts to `tests/TEST-RESULTS.md`. It needs only the -Python standard library. The tokens PyJWT built for the -corpus are committed in `tests/pyjwt_corpus.json`, so +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: @@ -106,12 +113,12 @@ where they end in `sorry`) and proved in | 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` | | 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` | -| 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 theorems rest on the proofs in `Proofs/Token.lean`. The print of a parsed token is the @@ -128,11 +135,16 @@ and the base64url round trip. The base64url layer proves both directions: `Spec.decode_encode` above, and the reverse, encoding a decoding gives the text back (`Jws.Base64url.Proofs.encode_decode`, in -`Proofs/B64.lean`). The single token theorems are single -inputs checked in the kernel with `decide +kernel`, each -one a named fixed case in the test harness, run through +`Proofs/B64.lean`). The theorems about acceptance are +proved in `Proofs/Check.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. +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`): @@ -242,18 +254,19 @@ pinned by tests: puts in place of some definitions the proofs reason about. - The operating system the program runs on. -- That `SPEC.md` states the reading of RFC 7515 you - intend. The proofs are about the parser and checker in +- That `SPEC.md` states 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 in `SPEC.md` where the RFC leaves a choice open. - The oracle in `tests/run_tests.py`, written from `SPEC.md`, and PyJWT and cryptography (pinned in - `tests/requirements.txt`), which built the tokens in - `tests/pyjwt_corpus.json`. Both are evidence, not proof: - PyJWT builds well formed tokens and cross checks - decoding only, and verdicts never come from PyJWT. The - results file names the versions used. + `tests/requirements.txt`), which built the tokens and + gave the probe verdicts in `tests/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 @@ -272,12 +285,13 @@ pinned by tests: accepted by Lean's kernel and every other kernel shipped with Lean. 2. `python3 tests/run_tests.py`: the fixed RFC cases, - the mutation cases, the malformed request cases, and - the differential corpus, with counts and any failures - written to `tests/TEST-RESULTS.md`. The harness reads - the PyJWT tokens from `tests/pyjwt_corpus.json` and + the mutation cases, the malformed request cases, the + two seeded corpora, and the PyJWT tokens and probe + verdicts, with counts and any failures written to + `tests/TEST-RESULTS.md`. The harness reads the PyJWT + tokens and verdicts from `tests/pyjwt_corpus.json` and needs no library beyond Python's own. 3. `lake build`: the libraries, the proofs, and the tool build clean. -4. Read `SPEC.md` against RFC 7515 and check the pinned - readings are the ones you want. +4. Read `SPEC.md` against RFC 7515 and RFC 7519 and check + the pinned readings are the ones you want. diff --git a/SPEC.md b/SPEC.md index 59b527b..d448872 100644 --- a/SPEC.md +++ b/SPEC.md @@ -278,9 +278,9 @@ input theorems and the tests. | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | +| An accepted token's header has no `crit` member (rule 18) | `Spec.accept_no_crit` | +| The token with header `{"alg":"HS256","b64":false,"crit":["b64"]}` parses and is refused under every context (rule 18) | `Spec.rejects_crit_b64` | | A token with `exp` 2000000000.5 is accepted at 2000000000 (rules 9, 10) | `Spec.accepts_fractional_exp_before` | | The same token is refused at 2000000001 (rules 9, 10) | `Spec.rejects_fractional_exp_after` | | A token carrying `aud` is refused when no audience is expected (rule 16) | `Spec.rejects_aud_without_audience` | | The same token is accepted when its audience is the expected one | `Spec.accepts_aud_with_audience` | -| An accepted token's header has no `crit` member (rule 18) | `Spec.accept_no_crit` | -| The token with header `{"alg":"HS256","b64":false,"crit":["b64"]}` parses and is refused under every context (rule 18) | `Spec.rejects_crit_b64` | From 3de5914880841e336bb7f05e9065beaaba042ace Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:18:15 +0000 Subject: [PATCH 15/23] Prove that the date rules compare exact decimal values 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs.lean | 1 + Proofs/Num.lean | 468 +++++++++++++++++++++++++++++++++++++++++ Proofs/SpecProofs.lean | 48 ++++- README.md | 33 +-- SPEC.md | 17 +- Spec.lean | 91 +++++++- Spec/Defs.lean | 47 +++++ bar.toml | 5 +- comparator.json | 2 +- 9 files changed, 684 insertions(+), 28 deletions(-) create mode 100644 Proofs/Num.lean create mode 100644 Spec/Defs.lean diff --git a/Proofs.lean b/Proofs.lean index 546592f..a44889f 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -3,4 +3,5 @@ import Proofs.B64 import Proofs.Utf8 import Proofs.Token import Proofs.Check +import Proofs.Num import Proofs.SpecProofs diff --git a/Proofs/Num.lean b/Proofs/Num.lean new file mode 100644 index 0000000..900fcc2 --- /dev/null +++ b/Proofs/Num.lean @@ -0,0 +1,468 @@ +import Jws.Token +import Spec.Defs +import Proofs.RoundTrip + +/- + Proofs about the checker's exact decimal reading of claim numbers: + `NumVal.cmp` compares exact values, `numVal` reads a number text + of the JSON grammar as its decimal value, and the comparison the + date rules make between the current time and a claim is the exact + comparison of the two values. +-/ + +namespace Jws.Proofs.Num + +open Jws Spec + +/-! ## Comparisons -/ + +theorem compare_of_lt {a b : Nat} (h : a < b) : compare a b = .lt := Nat.compare_eq_lt.mpr h +theorem compare_of_gt {a b : Nat} (h : b < a) : compare a b = .gt := Nat.compare_eq_gt.mpr h +theorem compare_of_eq {a b : Nat} (h : a = b) : compare a b = .eq := Nat.compare_eq_eq.mpr h + +/-- Multiplying both sides by the same positive number keeps the + comparison. -/ +theorem compare_mul_right (a b c : Nat) (hc : 0 < c) : + compare (a * c) (b * c) = compare a b := by + rcases Nat.lt_trichotomy a b with h | h | h + · rw [compare_of_lt h, compare_of_lt (Nat.mul_lt_mul_of_pos_right h hc)] + · rw [h, compare_of_eq rfl, compare_of_eq rfl] + · rw [compare_of_gt h, compare_of_gt (Nat.mul_lt_mul_of_pos_right h hc)] + +/-- Comparing whole numbers as integers is comparing them as + naturals. -/ +theorem compare_natCast (a b : Nat) : compare (a : Int) (b : Int) = compare a b := by + rcases Nat.lt_trichotomy a b with h | h | h + · rw [compare_of_lt h, Int.compare_eq_lt.mpr (by omega)] + · rw [h, compare_of_eq rfl, Int.compare_eq_eq.mpr rfl] + · rw [compare_of_gt h, Int.compare_eq_gt.mpr (by omega)] + +/-- Comparing two negated whole numbers swaps the comparison. -/ +theorem compare_neg_natCast (a b : Nat) : + compare (-(a : Int)) (-(b : Int)) = compare b a := by + rcases Nat.lt_trichotomy a b with h | h | h + · rw [compare_of_gt h, Int.compare_eq_gt.mpr (by omega)] + · rw [h, compare_of_eq rfl, Int.compare_eq_eq.mpr rfl] + · rw [compare_of_lt h, Int.compare_eq_lt.mpr (by omega)] + +/-! ## Digit counts -/ + +theorem pow10_pos (k : Nat) : 0 < 10 ^ k := Nat.pow_pos_iff.mpr (Or.inl (by decide)) + +theorem numDigits_pos (n : Nat) : 1 ≤ numDigits n := by + rw [numDigits.eq_1]; split <;> omega + +theorem lt_pow_numDigits (n : Nat) : n < 10 ^ numDigits n := by + induction n using Nat.strongRecOn with + | _ n ih => + rw [numDigits.eq_1] + split + · rename_i h; rw [Nat.pow_one]; exact h + · have := ih (n / 10) (by omega) + rw [Nat.pow_add, Nat.pow_one] + omega + +theorem pow_numDigits_le (n : Nat) (hn : 1 ≤ n) : 10 ^ (numDigits n - 1) ≤ n := by + induction n using Nat.strongRecOn with + | _ n ih => + rw [numDigits.eq_1] + split + · simpa using hn + · rename_i h + have h1 := ih (n / 10) (by omega) (by omega) + have h2 := numDigits_pos (n / 10) + have : 1 + numDigits (n / 10) - 1 = (numDigits (n / 10) - 1) + 1 := by omega + rw [this, Nat.pow_succ] + omega + +/-! ## The magnitude comparison -/ + +/-- The value of a nonzero significand times `10^k` lies in + `[10^(d-1+k), 10^(d+k))`, where `d` is its digit count. -/ +theorem mag_bounds (s k : Nat) (hs : s ≠ 0) : + 10 ^ (numDigits s - 1 + k) ≤ s * 10 ^ k ∧ s * 10 ^ k < 10 ^ (numDigits s + k) := by + have h1 := pow_numDigits_le s (by omega) + have h2 := lt_pow_numDigits s + have hp : 0 < 10 ^ k := pow10_pos k + refine ⟨?_, ?_⟩ + · rw [Nat.pow_add]; exact Nat.mul_le_mul_right _ h1 + · rw [Nat.pow_add]; exact Nat.mul_lt_mul_of_pos_right h2 hp + +/-- `magCmp` compares the magnitudes exactly: for a base `m` at most + both exponents, as the significands scaled up to `10^m`. -/ +theorem magCmp_eq (a b : NumVal) (m : Int) (ha : m ≤ a.exp) (hb : m ≤ b.exp) + (hA : a.sig ≠ 0) (hB : b.sig ≠ 0) : + magCmp a b = compare (a.sig * 10 ^ (a.exp - m).toNat) (b.sig * 10 ^ (b.exp - m).toNat) := by + obtain ⟨an, A, ea⟩ := a + obtain ⟨bn, B, eb⟩ := b + dsimp only at * + have hka : (((ea - m).toNat : Nat) : Int) = ea - m := Int.toNat_of_nonneg (by omega) + have hkb : (((eb - m).toNat : Nat) : Int) = eb - m := Int.toNat_of_nonneg (by omega) + generalize (ea - m).toNat = ka at hka ⊢ + generalize (eb - m).toNat = kb at hkb ⊢ + obtain ⟨la, ua⟩ := mag_bounds A ka hA + obtain ⟨lb, ub⟩ := mag_bounds B kb hB + have pa := numDigits_pos A + have pb := numDigits_pos B + unfold magCmp + dsimp only + generalize numDigits A = na at * + generalize numDigits B = nb at * + by_cases h1 : (na : Int) + ea < (nb : Int) + eb + · rw [ite_eq_left h1] + have hle : na + ka ≤ nb - 1 + kb := by omega + have := Nat.pow_le_pow_right (by decide : 0 < 10) hle + exact (compare_of_lt (by omega)).symm + · rw [ite_eq_right h1] + by_cases h2 : (na : Int) + ea > (nb : Int) + eb + · rw [ite_eq_left h2] + have hle : nb + kb ≤ na - 1 + ka := by omega + have := Nat.pow_le_pow_right (by decide : 0 < 10) hle + exact (compare_of_gt (by omega)).symm + · rw [ite_eq_right h2] + have hp : 0 < 10 ^ ka := pow10_pos ka + have hq : 0 < 10 ^ kb := pow10_pos kb + by_cases h3 : nb ≤ na + · rw [ite_eq_left h3] + have hk : kb = (na - nb) + ka := by omega + rw [hk, Nat.pow_add, ← Nat.mul_assoc, compare_mul_right _ _ _ hp] + · rw [ite_eq_right h3] + have hk : ka = (nb - na) + kb := by omega + rw [hk, Nat.pow_add, ← Nat.mul_assoc, compare_mul_right _ _ _ hq] + +/-- `NumVal.cmp` compares exact values: over any base `10^m` with + `m` at most both exponents, as the signed whole numbers. -/ +theorem cmp_exact (a b : NumVal) (m : Int) (ha : m ≤ a.exp) (hb : m ≤ b.exp) : + NumVal.cmp a b = compare (atBase a.neg a.sig a.exp m) (atBase b.neg b.sig b.exp m) := by + have hmag := fun hA hB => magCmp_eq a b m ha hb hA hB + have hmag' := fun hB hA => magCmp_eq b a m hb ha hB hA + obtain ⟨an, A, ea⟩ := a + obtain ⟨bn, B, eb⟩ := b + dsimp only at * + unfold NumVal.cmp atBase + dsimp only + have hpa : 0 < 10 ^ (ea - m).toNat := pow10_pos _ + have hpb : 0 < 10 ^ (eb - m).toNat := pow10_pos _ + generalize hX : A * 10 ^ (ea - m).toNat = X at * + generalize hY : B * 10 ^ (eb - m).toNat = Y at * + have hX0 : A = 0 → X = 0 := fun h => by rw [← hX, h, Nat.zero_mul] + have hXp : A ≠ 0 → 0 < X := fun h => by rw [← hX]; exact Nat.mul_pos (by omega) hpa + have hY0 : B = 0 → Y = 0 := fun h => by rw [← hY, h, Nat.zero_mul] + have hYp : B ≠ 0 → 0 < Y := fun h => by rw [← hY]; exact Nat.mul_pos (by omega) hpb + by_cases hA : A = 0 <;> by_cases hB : B = 0 + · rw [hX0 hA, hY0 hB]; cases an <;> cases bn <;> simp [hA, hB] + · have := hYp hB; rw [hX0 hA] + cases an <;> cases bn <;> simp [hA, hB] <;> + first + | exact (Int.compare_eq_gt.mpr (by omega)).symm + | exact (Int.compare_eq_lt.mpr (by omega)).symm + · have := hXp hA; rw [hY0 hB] + cases an <;> cases bn <;> simp [hA, hB] <;> + first + | exact (Int.compare_eq_gt.mpr (by omega)).symm + | exact (Int.compare_eq_lt.mpr (by omega)).symm + · have := hXp hA; have := hYp hB + cases an <;> cases bn <;> simp [hA, hB] + · rw [hmag hA hB, compare_natCast] + · exact (Int.compare_eq_gt.mpr (by omega)).symm + · exact (Int.compare_eq_lt.mpr (by omega)).symm + · rw [hmag' hB hA] + exact (compare_neg_natCast X Y).symm + +/-! ## Digit runs -/ + +/-- The checker's left fold over digits is the positional value. -/ +theorem foldl_digits (acc : Nat) (ds : List Char) : + ds.foldl (fun a c => a * 10 + (c.toNat - 48)) acc = acc * 10 ^ ds.length + digitsValue ds := by + induction ds generalizing acc with + | nil => simp [digitsValue] + | cons c cs ih => + rw [List.foldl_cons, ih, digitsValue, List.length_cons, Nat.pow_succ] + have h0 : '0'.toNat = 48 := rfl + rw [h0, Nat.add_mul, Nat.mul_assoc, Nat.mul_comm 10 (10 ^ cs.length), Nat.add_assoc] + +theorem digitsVal_eq (ds : List Char) : digitsVal ds = digitsValue ds := by + unfold digitsVal; rw [foldl_digits]; simp + +theorem isDigit_of_IsDigit {c : Char} (h : Json.IsDigit c) : c.isDigit = true := by + obtain ⟨h1, h2⟩ := h + simp only [Char.isDigit, Bool.and_eq_true, decide_eq_true_eq] + exact ⟨h1, h2⟩ + +/-- `span` over a run of characters that pass, followed by a list + that is empty or starts with one that fails. -/ +theorem span_loop_append (p : Char → Bool) (xs ys acc : List Char) + (hx : ∀ x ∈ xs, p x = true) (hy : ∀ y r, ys = y :: r → p y = false) : + List.span.loop p (xs ++ ys) acc = (acc.reverse ++ xs, ys) := by + induction xs generalizing acc with + | nil => + cases ys with + | nil => simp [List.span.loop] + | cons y r => simp [List.span.loop, hy y r rfl] + | cons x xs ih => + rw [List.cons_append, List.span.loop, hx x List.mem_cons_self] + dsimp only + rw [ih _ (fun z hz => hx z (List.mem_cons_of_mem _ hz))] + simp + +theorem span_append (p : Char → Bool) (xs ys : List Char) + (hx : ∀ x ∈ xs, p x = true) (hy : ∀ y r, ys = y :: r → p y = false) : + (xs ++ ys).span p = (xs, ys) := by + unfold List.span + rw [span_loop_append p xs ys [] hx hy] + simp + +/-! ## Normal form -/ + +/-- Dividing out a factor of ten and raising the exponent keeps the + value over any base. -/ +theorem strip_value (neg : Bool) : ∀ (fuel s : Nat) (e m : Int), m ≤ e → + (strip neg fuel s e).neg = neg ∧ e ≤ (strip neg fuel s e).exp ∧ + atBase neg (strip neg fuel s e).sig (strip neg fuel s e).exp m = atBase neg s e m + | 0, s, e, m, _ => by simp [strip] + | fuel + 1, s, e, m, hm => by + rw [strip] + split + · rename_i h + obtain ⟨h1, h2, h3⟩ := strip_value neg fuel (s / 10) (e + 1) m (by omega) + refine ⟨h1, by omega, ?_⟩ + rw [h3] + unfold atBase + have hk : (e + 1 - m).toNat = (e - m).toNat + 1 := by omega + rw [hk, Nat.pow_succ, Nat.mul_comm (10 ^ (e - m).toNat) 10, ← Nat.mul_assoc, + Nat.div_mul_cancel (Nat.dvd_of_mod_eq_zero h.1)] + · exact ⟨rfl, Int.le_refl _, rfl⟩ + +/-- The normal form has the value it was built from. Its exponent is + at least the one given, unless the value is zero. -/ +theorem mkNumVal_value (neg : Bool) (s : Nat) (e m : Int) (hm : m ≤ e) : + (s = 0 ∨ e ≤ (mkNumVal neg s e).exp) ∧ + atBase (mkNumVal neg s e).neg (mkNumVal neg s e).sig (mkNumVal neg s e).exp m = + atBase neg s e m := by + unfold mkNumVal + split + · rename_i h; subst h; simp [atBase] + · obtain ⟨h1, h2, h3⟩ := strip_value neg s s e m hm + rw [h1] + exact ⟨Or.inr h2, h3⟩ + +theorem mkNumVal_exp (neg : Bool) (s : Nat) (e : Int) : min 0 e ≤ (mkNumVal neg s e).exp := by + unfold mkNumVal + split + · simp; omega + · have := (strip_value neg s s e e (Int.le_refl _)).2.1; omega + +/-! ## Reading a number text -/ + +/-- A digit is none of the characters the number grammar puts + around digit runs. -/ +theorem ne_of_dig {c : Char} (h : Json.IsDigit c) : c ≠ '-' ∧ c ≠ '+' ∧ c ≠ '.' ∧ c ≠ 'e' ∧ c ≠ 'E' := by + refine ⟨?_, ?_, ?_, ?_, ?_⟩ <;> exact Json.Proofs.IsDigit.ne_of_toNat h (by decide) + +/-- `span` takes a whole run of digits. -/ +theorem span_digits (d : Char) (ds : List Char) (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : + (d :: ds).span (·.isDigit) = (d :: ds, []) := by + have := span_append (·.isDigit) (d :: ds) [] (by + intro x hx + rcases List.mem_cons.mp hx with rfl | hx + · exact isDigit_of_IsDigit hd + · exact isDigit_of_IsDigit (hds x hx)) (by simp) + simpa using this + +/-- An unsigned exponent denotes its digits' value. -/ +theorem expValue_plain (ec d : Char) (ds : List Char) (n1 : d ≠ '-') (n2 : d ≠ '+') : + expValue (ec :: d :: ds) = (digitsValue (d :: ds) : Int) := by + unfold expValue + split + · rename_i heq; simp at heq + · rename_i heq; simp at heq; exact absurd heq.2.1 n1 + · rename_i heq; simp at heq; exact absurd heq.2.1 n2 + · rename_i heq; simp at heq; rw [heq.2] + +/-- `numVal`'s exponent step on an exponent part of the grammar. -/ +theorem withExp_eq (neg : Bool) (ids fds : List Char) (ec : Char) (sgn : List Char) (d : Char) + (ds : List Char) (hs : sgn = [] ∨ sgn = ['-'] ∨ sgn = ['+']) + (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : + numVal.withExp neg ids fds (sgn ++ d :: ds) = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: (sgn ++ d :: ds)) - fds.length)) := by + obtain ⟨n1, n2, -, -, -⟩ := ne_of_dig hd + have hsp := span_digits d ds hd hds + unfold numVal.withExp + split + rename_i heq + rcases hs with rfl | rfl | rfl + · split at heq + · rename_i hc; simp at hc; exact absurd hc.1 n1 + · rename_i hc; simp at hc; exact absurd hc.1 n2 + · simp only [List.nil_append, Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [hsp] + simp [expValue_plain ec d ds n1 n2, digitsVal_eq] + · split at heq + · rename_i hc; simp at hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [← hc, hsp] + simp [expValue, digitsVal_eq] + · rename_i hc; simp at hc + · rename_i hc1 _; exact absurd (hc1 (d :: ds) rfl) (by simp) + · split at heq + · rename_i hc; simp at hc + · rename_i hc; simp at hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [← hc, hsp] + simp [expValue, digitsVal_eq] + · rename_i _ hc2; exact absurd (hc2 (d :: ds) rfl) (by simp) + +/-- `numVal`'s last step on an exponent part of the grammar, or none. -/ +theorem finish_eq (neg : Bool) (ids fds ex : List Char) (hx : Json.ExpOk ex) : + numVal.finish neg ids fds ex = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue ex - fds.length)) := by + rcases hx with rfl | ⟨ec, sgn, d, ds, rfl, hec, hs, hd, hds⟩ + · simp [numVal.finish, expValue] + · rcases hec with rfl | rfl + · rw [numVal.finish]; exact withExp_eq neg ids fds 'e' sgn d ds hs hd hds + · rw [numVal.finish]; exact withExp_eq neg ids fds 'E' sgn d ds hs hd hds + +/-- What may follow a run of digits: nothing, or an exponent letter. -/ +theorem expOk_head {ex : List Char} (hx : Json.ExpOk ex) : + ∀ y r, ex = y :: r → y.isDigit = false ∧ y ≠ '.' := by + intro y r h + rcases hx with rfl | ⟨ec, sgn, d, ds, rfl, hec, -, -, -⟩ + · cases h + · simp only [List.cons.injEq] at h + obtain ⟨rfl, -⟩ := h + rcases hec with rfl | rfl <;> decide + +/-- `numVal` on a number text of the grammar: the normal form of its + sign, its digits, and its exponent less the fraction length. -/ +theorem numVal_numberText (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + numVal (numberText neg ints frac ex) = + some (mkNumVal neg (digitsVal (ints ++ frac)) (expValue ex - frac.length)) := by + obtain ⟨i, is, rfl⟩ : ∃ i is, ints = i :: is := by + cases ints with + | nil => exact absurd rfl hi.1 + | cons i is => exact ⟨i, is, rfl⟩ + have hdig : ∀ x ∈ i :: is, x.isDigit = true := fun x hx => isDigit_of_IsDigit (hi.2 x hx) + have hfdig : ∀ x ∈ frac, x.isDigit = true := fun x hx => isDigit_of_IsDigit (hf x hx) + obtain ⟨ni, -, -, -, -⟩ := ne_of_dig (hi.2 i List.mem_cons_self) + have hfin := finish_eq neg (i :: is) frac ex hx + have hfin0 := finish_eq neg (i :: is) [] ex hx + have hxh := expOk_head hx + -- the text after the integer digits + have hspan1 : ((i :: is) ++ ((if frac = [] then [] else '.' :: frac) ++ ex)).span (·.isDigit) = + (i :: is, (if frac = [] then [] else '.' :: frac) ++ ex) := by + apply span_append _ _ _ hdig + intro y r h + split at h + · exact (hxh y r (by simpa using h)).1 + · simp only [List.cons_append, List.cons.injEq] at h + obtain ⟨rfl, -⟩ := h; decide + have hspan2 : (frac ++ ex).span (·.isDigit) = (frac, ex) := + span_append _ _ _ hfdig (fun y r h => (hxh y r h).1) + have htext : numberText neg (i :: is) frac ex = + (if neg then ['-'] else []) ++ ((i :: is) ++ ((if frac = [] then [] else '.' :: frac) ++ ex)) := by + simp [numberText, List.append_assoc] + rw [htext] + generalize hT : (i :: is) ++ ((if frac = [] then [] else '.' :: frac) ++ ex) = T at hspan1 + unfold numVal + split + rename_i heq + split at heq + · rename_i hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + cases neg + · exfalso; rw [← hT] at hc; simp at hc; exact ni hc.1 + · simp at hc; subst hc + rw [hspan1] + dsimp only + rw [ite_eq_right (by simp)] + by_cases hfr : frac = [] + · subst hfr + simp only [ite_true, List.nil_append] at hfin0 ⊢ + split + · exact absurd rfl (hxh '.' _ rfl).2 + · simpa using hfin0 + · rw [ite_eq_right hfr] + split + · rename_i r hc + simp only [List.cons_append, List.cons.injEq, true_and] at hc + rw [← hc, hspan2] + dsimp only + rw [ite_eq_right (by cases frac with | nil => exact absurd rfl hfr | cons => simp)] + exact hfin + · rename_i hc; exact absurd (hc (frac ++ ex) rfl) id + · rename_i hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + cases neg + · simp only [Bool.false_eq_true, ite_false, List.nil_append] + rw [hspan1] + dsimp only + rw [ite_eq_right (by simp)] + by_cases hfr : frac = [] + · subst hfr + simp only [ite_true, List.nil_append] at hfin0 ⊢ + split + · exact absurd rfl (hxh '.' _ rfl).2 + · simpa using hfin0 + · rw [ite_eq_right hfr] + split + · rename_i r hc + simp only [List.cons_append, List.cons.injEq, true_and] at hc + rw [← hc, hspan2] + dsimp only + rw [ite_eq_right (by cases frac with | nil => exact absurd rfl hfr | cons => simp)] + exact hfin + · rename_i hc; exact absurd (hc (frac ++ ex) rfl) id + · exact absurd (hc T rfl) id + +/-- `numVal` reads a number text of the grammar as its exact value. -/ +theorem numVal_value (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + ∀ m, m ≤ v.exp → m ≤ expValue ex - frac.length → + atBase v.neg v.sig v.exp m = + atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) m := by + refine ⟨_, numVal_numberText neg ints frac ex hi hf hx, fun m _ hm => ?_⟩ + rw [(mkNumVal_value _ _ _ m hm).2, digitsVal_eq] + +/-- A whole number as a number value has that value. -/ +theorem ofInt_value (n m : Int) (hm : m ≤ 0) : + m ≤ (NumVal.ofInt n).exp ∧ + atBase (NumVal.ofInt n).neg (NumVal.ofInt n).sig (NumVal.ofInt n).exp m = + n * 10 ^ (-m).toNat := by + unfold NumVal.ofInt + refine ⟨by have := mkNumVal_exp (decide (n < 0)) n.natAbs 0; omega, ?_⟩ + rw [(mkNumVal_value _ _ _ m hm).2] + unfold atBase + rw [Int.natCast_mul, Int.natCast_pow, show (0 - m).toNat = (-m).toNat by omega, + ← Int.mul_assoc] + congr 1 + by_cases h : n < 0 + · simp only [h, decide_true, ite_true]; omega + · simp only [h, decide_false, Bool.false_eq_true, ite_false]; omega + +/-- The comparison the date rules make: the current time against a + number text of the grammar is the exact comparison of the whole + number with the text's decimal value. -/ +theorem numVal_cmp_now (now : Int) (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + NumVal.cmp (NumVal.ofInt now) v = + compare (now * 10 ^ (-(min 0 (expValue ex - frac.length))).toNat) + (atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) + (min 0 (expValue ex - frac.length))) := by + refine ⟨_, numVal_numberText neg ints frac ex hi hf hx, ?_⟩ + have hv := mkNumVal_exp neg (digitsVal (ints ++ frac)) (expValue ex - frac.length) + obtain ⟨h1, h2⟩ := ofInt_value now (min 0 (expValue ex - frac.length)) (Int.min_le_left _ _) + rw [cmp_exact _ _ (min 0 (expValue ex - frac.length)) h1 hv, h2, + (mkNumVal_value _ _ _ _ (Int.min_le_right _ _)).2, digitsVal_eq] + +end Jws.Proofs.Num diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index 15cb764..b1252c2 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -3,17 +3,20 @@ names. The round trips are the theorems proved in - `Proofs/Token.lean` and `Proofs/B64.lean`. The single + `Proofs/Token.lean` and `Proofs/B64.lean`; the number + statements are proved in `Proofs/Num.lean`. The single tokens are checked in the kernel with `decide +kernel`: no native evaluation, which the bar forbids. -/ import Jws +import Spec.Defs import Proofs.Token import Proofs.B64 import Proofs.Check +import Proofs.Num -open Jws +open Jws Spec theorem Spec.printToken_parse (s : List Char) (t : Token) (h : parseToken s = some t) : @@ -194,6 +197,47 @@ theorem Spec.rejects_fractional_exp_after : = some false := by decide +kernel +theorem Spec.cmp_exact (a b : NumVal) (m : Int) (ha : m ≤ a.exp) (hb : m ≤ b.exp) : + NumVal.cmp a b = compare (atBase a.neg a.sig a.exp m) (atBase b.neg b.sig b.exp m) := + Jws.Proofs.Num.cmp_exact a b m ha hb + +theorem Spec.numVal_value (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + ∀ m, m ≤ v.exp → m ≤ expValue ex - frac.length → + atBase v.neg v.sig v.exp m = + atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) m := + Jws.Proofs.Num.numVal_value neg ints frac ex hi hf hx + +theorem Spec.numVal_cmp_now (now : Int) (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + NumVal.cmp (NumVal.ofInt now) v = + compare (now * 10 ^ (-(min 0 (expValue ex - frac.length))).toNat) + (atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) + (min 0 (expValue ex - frac.length))) := + Jws.Proofs.Num.numVal_cmp_now now neg ints frac ex hi hf hx + +theorem Spec.rejects_negative_exp : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOi0xfQ.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 0, required := [], + issuer := none, audience := none }) + = some false := by + decide +kernel + +theorem Spec.accepts_negative_fraction_exp : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOi0wLjV9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some (-1), required := [], + issuer := none, audience := none }) + = some true := by + decide +kernel + theorem Spec.accept_sound (t : Token) (ctx : Context) (h : accept t ctx = true) : "none".toList ∉ ctx.allow ∧ t.sig ≠ [] ∧ diff --git a/README.md b/README.md index 6047143..cc1d2b8 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ 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 their proofs in `Proofs/SpecProofs.lean`. No reviewer -has approved them yet, so this project is not called -verified. +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 @@ -109,7 +110,7 @@ where they end in `sorry`) and proved in | The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). | `Spec.rejects_alg_outside_allowlist` | | A token whose first part carries a padding character does not parse: `=` is not in the base64url alphabet. | `Spec.rejects_part_not_base64url` | | A token whose header part decodes to a JSON array, not an object, is refused. | `Spec.rejects_header_not_object` | -| 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 exact decimal comparison (`Jws.NumVal.cmp` on `Jws.numVal`) orders them; `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: 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` | @@ -117,6 +118,11 @@ where they end in `sorry`) and proved in | 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` | @@ -136,7 +142,8 @@ both directions: `Spec.decode_encode` above, and the reverse, encoding a decoding gives the text back (`Jws.Base64url.Proofs.encode_decode`, in `Proofs/B64.lean`). The theorems about acceptance are -proved in `Proofs/Check.lean`. The single token theorems +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 @@ -181,14 +188,14 @@ directly, the same functions the theorems are about. or `makeToken`, has parts and values that match: the `Token` structure does not enforce it, and the theorems cover only those two ways of making one. -- That the checker's exact decimal reading of a date - claim (`Jws.numVal`) and its comparison - (`Jws.NumVal.cmp`) agree with the decimal value of the - number text. The theorems state the date rules in the - checker's own terms; the agreement rests on the tests, - which compare it with Python's `Decimal`, including - fractions, exponent spellings, and exponents of - 999999999 and -999999999. +- That the JSON parser hands the checker only number + texts of the JSON grammar. `Spec.numVal_value` and + `Spec.numVal_cmp_now` cover 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's + `Decimal` on 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 diff --git a/SPEC.md b/SPEC.md index d448872..03defd5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -120,6 +120,11 @@ The token is accepted only when every rule holds: decimal, not by its spelling: `2.0e9` is 2000000000, and a value like `1e999999999` or `1e-999999999` is compared exactly, without ever being written out in full. + `Spec/Defs.lean` writes this value out from the grammar + (`Spec.numberText`, `Spec.digitsValue`, `Spec.expValue`, + `Spec.atBase`), and the checker's reading and comparison are + proved exact against it (`Spec.numVal_value`, + `Spec.cmp_exact`, `Spec.numVal_cmp_now`). 10. When `exp` is present and a current time is supplied, the current time must be before `exp`. A current time equal to `exp` rejects (the token is expired at that second). @@ -248,8 +253,9 @@ digits, an unknown operation). ## Properties and their theorems Each property a reader can check in the page (`README.md`) -names its theorem. The theorems are stated in `Spec.lean` -and proved in `Proofs/SpecProofs.lean`. The single token +names its theorem. The theorems are stated in `Spec.lean`, +with the definitions in `Spec/Defs.lean`, and proved in +`Proofs/SpecProofs.lean`. The single token theorems are single inputs, one for each named fixed case in the test harness, run through the same `Jws.parseToken` and `Jws.accept` the command line tool calls; the tests in @@ -274,7 +280,7 @@ input theorems and the tests. | A token whose algorithm is outside the allowlist is refused (rule 7) | `Spec.rejects_alg_outside_allowlist` | | A token with a part that is not strict base64url does not parse | `Spec.rejects_part_not_base64url` | | A token whose header is not a JSON object is refused (rule 3) | `Spec.rejects_header_not_object` | -| An accepted token meets rules 2 to 18, the date comparisons as `Jws.NumVal.cmp` makes them | `Spec.accept_sound` | +| An accepted token meets rules 2 to 18, the date comparisons as `Jws.NumVal.cmp` makes them (proved exact by `Spec.numVal_cmp_now`) | `Spec.accept_sound` | | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | | The token with claims `{"iss":"joe","exp":2000000000,"iss":"evil"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_iss` | @@ -282,5 +288,10 @@ input theorems and the tests. | The token with header `{"alg":"HS256","b64":false,"crit":["b64"]}` parses and is refused under every context (rule 18) | `Spec.rejects_crit_b64` | | A token with `exp` 2000000000.5 is accepted at 2000000000 (rules 9, 10) | `Spec.accepts_fractional_exp_before` | | The same token is refused at 2000000001 (rules 9, 10) | `Spec.rejects_fractional_exp_after` | +| `Jws.NumVal.cmp` compares exact decimal values (rules 9 to 11) | `Spec.cmp_exact` | +| `Jws.numVal` reads every number text of the JSON grammar as its decimal value (rule 9) | `Spec.numVal_value` | +| The current time is compared exactly with a date claim's decimal value (rules 10, 11) | `Spec.numVal_cmp_now` | +| A token with `exp` -1 is refused at the current time 0 (rules 9, 10) | `Spec.rejects_negative_exp` | +| A token with `exp` -0.5 is accepted at the current time -1 (rules 9, 10) | `Spec.accepts_negative_fraction_exp` | | A token carrying `aud` is refused when no audience is expected (rule 16) | `Spec.rejects_aud_without_audience` | | The same token is accepted when its audience is the expected one | `Spec.accepts_aud_with_audience` | diff --git a/Spec.lean b/Spec.lean index daf70a4..336767b 100644 --- a/Spec.lean +++ b/Spec.lean @@ -7,15 +7,18 @@ checks that each one proves exactly the statement written here. The `sorry` below is expected in this file only. - The statements use only the definitions in `Jws/Token.lean`, + The statements use the definitions in `Jws/Token.lean`, `Jws/Base64url.lean`, `Jws/Utf8.lean`, and the JSON code carried into this repo (`Json/Value.lean` and `Json/Parse.lean`), the code under proof named in - `bar.toml`. No definition of their own is needed, so this - project has no `Spec/Defs.lean`. + `bar.toml`. The statements about the value of a number also + use `Spec/Defs.lean`: the decimal value of a JSON number + text, written from RFC 8259 separately from the checker's + own reading. That file is part of the specification too. -/ import Jws +import Spec.Defs open Jws @@ -306,6 +309,78 @@ theorem Spec.rejects_fractional_exp_after : issuer := none, audience := none }) = some false := sorry +/-! ## The value of a date claim + +The date rules compare the current time with the number an `exp` +or `nbf` claim holds, through the checker's exact decimal reading +`numVal` and its comparison `NumVal.cmp`. These statements tie +both to the decimal value of the number text, defined in +`Spec/Defs.lean` from the JSON grammar: `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. -/ + +/-- `NumVal.cmp` compares the exact values of two number values: + over any power of ten `10^m` with `m` at most both exponents, + it is the comparison of the two values written as whole + numbers over `10^m`. -/ +theorem Spec.cmp_exact (a b : NumVal) (m : Int) (ha : m ≤ a.exp) (hb : m ≤ b.exp) : + NumVal.cmp a b = compare (atBase a.neg a.sig a.exp m) (atBase b.neg b.sig b.exp m) := sorry + +/-- `numVal` reads every number text of the JSON grammar (an + optional minus sign, one or more integer digits, optional + fraction digits after a point, an optional exponent part), + and what it returns has the text's decimal value: the sign, + the integer and fraction digits read as one whole number, and + ten to the exponent less the count of fraction digits. -/ +theorem Spec.numVal_value (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + ∀ m, m ≤ v.exp → m ≤ expValue ex - frac.length → + atBase v.neg v.sig v.exp m = + atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) m := sorry + +/-- The comparison the `exp` and `nbf` rules make, between the + current time `now` (`NumVal.ofInt now`) and a claim's number + text of the JSON grammar, is the exact comparison of the whole + number `now` with the text's decimal value: both are written + as whole numbers over `10^m`, where `m` is the smaller of zero + and the text's exponent less its count of fraction digits. -/ +theorem Spec.numVal_cmp_now (now : Int) (neg : Bool) (ints frac ex : List Char) + (hi : ints ≠ [] ∧ ∀ d ∈ ints, Json.IsDigit d) (hf : ∀ d ∈ frac, Json.IsDigit d) + (hx : Json.ExpOk ex) : + ∃ v, numVal (numberText neg ints frac ex) = some v ∧ + NumVal.cmp (NumVal.ofInt now) v = + compare (now * 10 ^ (-(min 0 (expValue ex - frac.length))).toNat) + (atBase neg (digitsValue (ints ++ frac)) (expValue ex - frac.length) + (min 0 (expValue ex - frac.length))) := sorry + +/-- The harness's hand built token with claims `{"exp":-1}` (header + `{"alg":"HS256"}`, its 32 byte signature) is refused at the + current time 0, with HS256 allowed: it expired a second before. + A reading that dropped the minus sign would accept it. The + harness case is "mutation: negative exp, now zero (reject)". -/ +theorem Spec.rejects_negative_exp : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOi0xfQ.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some 0, required := [], + issuer := none, audience := none }) + = some false := sorry + +/-- The same header and signature with claims `{"exp":-0.5}` are + accepted at the current time -1, half a second before the + expiry. The harness case is "exp a negative fraction -0.5, now + -1: accepted". -/ +theorem Spec.accepts_negative_fraction_exp : + (parseToken + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOi0wLjV9.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8".toList).map + (fun t => accept t + { allow := ["HS256".toList], now := some (-1), required := [], + issuer := none, audience := none }) + = some true := sorry + /-! ## What acceptance means The plan's main claim: a token is accepted only when its header @@ -318,10 +393,12 @@ checks. One statement collects every rule the check applies. -/ member name; the header has no `crit` member; the header's `alg` is a string other than `none` that is on the allowlist; every required claim is present; `exp`, `nbf` and `iat`, when - present, are numbers the checker reads exactly, with the - current time before `exp` and not before `nbf` when a time is - given; `iss`, when present, is a string; `aud`, when present, - is a string or an array of strings; the issuer is the expected + present, are numbers the checker reads with `numVal`, with + the current time before `exp` and not before `nbf` under + `NumVal.cmp` when a time is given (the statements in the + section above say these are the exact decimal values); `iss`, + when present, is a string; `aud`, when present, is a string + or an array of strings; the issuer is the expected one when one is given; and `aud` is absent when no audience is expected, or names the expected audience when one is. A program that accepts nothing meets this statement; the diff --git a/Spec/Defs.lean b/Spec/Defs.lean new file mode 100644 index 0000000..47bcf66 --- /dev/null +++ b/Spec/Defs.lean @@ -0,0 +1,47 @@ +/- + The definitions the statements in `Spec.lean` use besides the code + under proof. They give the decimal value of a JSON number text, + written from RFC 8259 section 6 (the number grammar) and plain + positional notation, separately from the checker's own reading + (`Jws.numVal` and `Jws.NumVal.cmp` in `Jws/Token.lean`). The + statements in `Spec.lean` tie the checker's reading to these. + + This file is part of the specification: a reviewer approves it + with `Spec.lean` and `SPEC.md`. It imports nothing from the + project. +-/ + +namespace Spec + +/-- The value of a run of decimal digit characters, most significant + first: each digit times ten to the count of digits after it. -/ +def digitsValue : List Char → Nat + | [] => 0 + | c :: cs => (c.toNat - '0'.toNat) * 10 ^ cs.length + digitsValue cs + +/-- The power of ten a JSON exponent part denotes (RFC 8259 section + 6). The part is the letter `e` or `E`, then `-`, `+` or no sign, + then digits; it denotes the digits' value, negated after `-`. No + exponent part denotes zero. -/ +def expValue : List Char → Int + | [] => 0 + | _ :: '-' :: ds => -(digitsValue ds : Int) + | _ :: '+' :: ds => (digitsValue ds : Int) + | _ :: ds => (digitsValue ds : Int) + +/-- The text of a JSON number from its pieces (RFC 8259 section 6): + a minus sign when `neg`, the integer digits, then a point and + the fraction digits when there are any, then the exponent part + as written. -/ +def numberText (neg : Bool) (ints frac ex : List Char) : List Char := + (if neg then ['-'] else []) ++ ints ++ (if frac = [] then [] else '.' :: frac) ++ ex + +/-- The decimal `(-1 if neg) * sig * 10^exp`, written as a whole + number over the power of ten `10^m`. For any `m` at most `exp` + this is exactly the decimal times `10^(-m)`, so two decimals + compare as these whole numbers compare over one `m` at most + both exponents. -/ +def atBase (neg : Bool) (sig : Nat) (exp m : Int) : Int := + (if neg then -1 else 1) * ((sig * 10 ^ (exp - m).toNat : Nat) : Int) + +end Spec diff --git a/bar.toml b/bar.toml index 4a4850e..e6d4455 100644 --- a/bar.toml +++ b/bar.toml @@ -12,7 +12,7 @@ spec_authors = ["abu-aska", "zero-aska"] entry_points = ["Main.lean"] [spec] -files = ["SPEC.md", "Spec.lean"] +files = ["SPEC.md", "Spec.lean", "Spec/Defs.lean"] # The code under proof: the functions the statements are about. code = [ # The token value, its constructor and fields. @@ -23,7 +23,8 @@ code = [ "Jws.splitSep", "Jws.joinSep", "Jws.printToken", "Jws.parseToken", "Jws.makeToken", # The exact decimal reading of claim numbers, which the check uses. - "Jws.NumVal", "Jws.digitsVal", "Jws.strip", "Jws.mkNumVal", + "Jws.NumVal", "Jws.NumVal.mk", "Jws.NumVal.neg", "Jws.NumVal.sig", + "Jws.NumVal.exp", "Jws.digitsVal", "Jws.strip", "Jws.mkNumVal", "Jws.numVal", "Jws.numVal.finish", "Jws.numVal.withExp", "Jws.NumVal.ofInt", "Jws.numDigits", "Jws.magCmp", "Jws.NumVal.cmp", # The check: the context, the claim reading, the accept function. diff --git a/comparator.json b/comparator.json index dde8987..1875025 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after","Spec.accept_sound"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after","Spec.cmp_exact","Spec.numVal_value","Spec.numVal_cmp_now","Spec.rejects_negative_exp","Spec.accepts_negative_fraction_exp","Spec.accept_sound"]} \ No newline at end of file From 7e79270630db8dd463838dd9a29e0a9447805594 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:19:47 +0000 Subject: [PATCH 16/23] Prove the UTF-8 and base64url decoders strict, as the page says 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/SpecProofs.lean | 26 ++++++++- Proofs/Utf8.lean | 123 +++++++++++++++++++++++++++++++++++++++++ README.md | 26 ++++++--- SPEC.md | 25 +++++++-- Spec.lean | 46 ++++++++++++++- comparator.json | 2 +- 6 files changed, 228 insertions(+), 20 deletions(-) diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index b1252c2..f98efd5 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -3,8 +3,8 @@ names. The round trips are the theorems proved in - `Proofs/Token.lean` and `Proofs/B64.lean`; the number - statements are proved in `Proofs/Num.lean`. The single + `Proofs/Token.lean`, `Proofs/B64.lean` and `Proofs/Utf8.lean`; + the number statements are proved in `Proofs/Num.lean`. The single tokens are checked in the kernel with `decide +kernel`: no native evaluation, which the bar forbids. -/ @@ -13,6 +13,7 @@ import Jws import Spec.Defs import Proofs.Token import Proofs.B64 +import Proofs.Utf8 import Proofs.Check import Proofs.Num @@ -41,6 +42,27 @@ theorem Spec.decode_encode (bs : List UInt8) : Base64url.decode (Base64url.encode bs) = some bs := Jws.Base64url.Proofs.decode_encode bs +theorem Spec.encode_decode (cs : List Char) (bs : List UInt8) + (h : Base64url.decode cs = some bs) : + Base64url.encode bs = cs := + Jws.Base64url.Proofs.encode_decode cs bs h + +theorem Spec.utf8_decode_encode (cs : List Char) : + Utf8.decode (Utf8.encode cs) = some cs := + Jws.Utf8.Proofs.decode_encode cs + +theorem Spec.utf8_encode_decode (bs : List UInt8) (cs : List Char) + (h : Utf8.decode bs = some cs) : + Utf8.encode cs = bs := + Jws.Utf8.Proofs.encode_decode bs cs h + +theorem Spec.utf8_rejects_ill_formed : + ([[0xC0, 0x80], [0xE0, 0x80, 0x80], [0xF0, 0x80, 0x80, 0x80], + [0xED, 0xA0, 0x80], [0xF4, 0x90, 0x80, 0x80], [0x80], [0xE2, 0x82], + [0xF8, 0x88, 0x80, 0x80, 0x80]] : List (List UInt8)).all + (fun bs => (Utf8.decode bs).isNone) = true := by + decide +kernel + theorem Spec.accepts_rfc_a1 : (match parseToken diff --git a/Proofs/Utf8.lean b/Proofs/Utf8.lean index 0735767..db74c1a 100644 --- a/Proofs/Utf8.lean +++ b/Proofs/Utf8.lean @@ -132,4 +132,127 @@ theorem decode_encode : ∀ cs : List Char, decode (encode cs) = some cs := by rw [decode_append_char, ih] rfl + +/-- A byte `contVal` reads is a continuation byte with that payload. -/ +theorem contVal_spec {b : UInt8} {v : Nat} (h : contVal b = some v) : + b.toNat = 0x80 + v ∧ v < 64 := by + unfold contVal at h + split at h + · rename_i hb; have := Option.some.inj h; omega + · cases h + +/-- A Unicode scalar value is the code of the character made from it. -/ +theorem toNat_ofNat_valid {n : Nat} (h : n.isValidChar) : (Char.ofNat n).toNat = n := by + rw [Char.ofNat, dite_eq_left h]; rfl + +/-- A number equal to a byte's value converts to that byte. -/ +theorem u8_eq {k : Nat} {b : UInt8} (h : k = b.toNat) : k.toUInt8 = b := by + apply UInt8.toNat_inj.mp + rw [toNat_toUInt8 (by have := b.toNat_lt; omega)] + exact h + +/-- The converse round trip, by strong induction on the byte count: + each branch of the decoder that succeeds read the bytes the + encoder writes for the character it made. -/ +theorem encode_decode_lt : ∀ (n : Nat) (bs : List UInt8), bs.length < n → + ∀ cs, decode bs = some cs → encode cs = bs + | 0, _, hl, _, _ => absurd hl (Nat.not_lt_zero _) + | _ + 1, [], _, cs, h => by + rw [decode.eq_1] at h; cases h; rfl + | n + 1, b :: rest, hl, cs, h => by + rw [decode.eq_def] at h + dsimp only at h + split at h + · rename_i hb + obtain ⟨cs', h1, rfl⟩ := Option.map_eq_some_iff.mp h + show encodeChar _ ++ encode cs' = b :: rest + rw [encode_decode_lt n rest (by simp at hl; omega) cs' h1] + have hv : (Char.ofNat b.toNat).toNat = b.toNat := toNat_ofNat_valid (by left; omega) + unfold encodeChar + rw [hv, ite_eq_left hb, u8_eq rfl] + rfl + · split at h + · cases h + · split at h + · split at h + · split at h + · rename_i h0 h1 h2 _ b1 rest1 _ v1 hv1 + obtain ⟨cs', h1', rfl⟩ := Option.map_eq_some_iff.mp h + obtain ⟨hb1, hv⟩ := contVal_spec hv1 + show encodeChar _ ++ encode cs' = b :: b1 :: rest1 + rw [encode_decode_lt n rest1 (by simp at hl; omega) cs' h1'] + generalize hN : (b.toNat - 192) * 64 + v1 = N + have hc : (Char.ofNat N).toNat = N := toNat_ofNat_valid (by left; omega) + unfold encodeChar + rw [hc, ite_eq_right (by omega), ite_eq_left (by omega), + show (0xC0 + N / 64).toUInt8 = b from u8_eq (by omega), + show (0x80 + N % 64).toUInt8 = b1 from u8_eq (by omega)] + rfl + · cases h + · cases h + · split at h + · split at h + · split at h + · split at h + · rename_i h0 h1 h2 h3 _ b1 b2 rest1 _ _ v1 v2 hv1 hv2 hside + obtain ⟨cs', h1', rfl⟩ := Option.map_eq_some_iff.mp h + obtain ⟨hb1, hv1'⟩ := contVal_spec hv1 + obtain ⟨hb2, hv2'⟩ := contVal_spec hv2 + obtain ⟨hs1, hs2⟩ := hside + show encodeChar _ ++ encode cs' = b :: b1 :: b2 :: rest1 + rw [encode_decode_lt n rest1 (by simp at hl; omega) cs' h1'] + generalize hN : (b.toNat - 224) * 4096 + v1 * 64 + v2 = N + have hc : (Char.ofNat N).toNat = N := toNat_ofNat_valid (by + by_cases hb : b.toNat ≤ 237 + · left; omega + · right; omega) + unfold encodeChar + rw [hc, ite_eq_right (by omega), ite_eq_right (by omega), ite_eq_left (by omega), + show (0xE0 + N / 4096).toUInt8 = b from u8_eq (by omega), + show (0x80 + N / 64 % 64).toUInt8 = b1 from u8_eq (by omega), + show (0x80 + N % 64).toUInt8 = b2 from u8_eq (by omega)] + rfl + · cases h + · cases h + · cases h + · split at h + · split at h + · split at h + · split at h + · rename_i h0 h1 h2 h3 h4 _ b1 b2 b3 rest1 _ _ _ v1 v2 v3 hv1 hv2 hv3 hside + obtain ⟨cs', h1', rfl⟩ := Option.map_eq_some_iff.mp h + obtain ⟨hb1, hv1'⟩ := contVal_spec hv1 + obtain ⟨hb2, hv2'⟩ := contVal_spec hv2 + obtain ⟨hb3, hv3'⟩ := contVal_spec hv3 + obtain ⟨hs1, hs2⟩ := hside + show encodeChar _ ++ encode cs' = b :: b1 :: b2 :: b3 :: rest1 + rw [encode_decode_lt n rest1 (by simp at hl; omega) cs' h1'] + generalize hN : (b.toNat - 240) * 262144 + v1 * 4096 + v2 * 64 + v3 = N + have hc : (Char.ofNat N).toNat = N := toNat_ofNat_valid (by + right + by_cases hb : b.toNat = 240 + · omega + · by_cases hb' : b.toNat = 244 + · omega + · omega) + unfold encodeChar + rw [hc, ite_eq_right (by omega), ite_eq_right (by omega), + ite_eq_right (by omega), + show (0xF0 + N / 262144).toUInt8 = b from u8_eq (by omega), + show (0x80 + N / 4096 % 64).toUInt8 = b1 from u8_eq (by omega), + show (0x80 + N / 64 % 64).toUInt8 = b2 from u8_eq (by omega), + show (0x80 + N % 64).toUInt8 = b3 from u8_eq (by omega)] + rfl + · cases h + · cases h + · cases h + · cases h + +/-- Encoding a decoding gives the bytes back: the decoder accepts only + byte strings that are the standard encoding of the text it + returns, so it is strict. -/ +theorem encode_decode (bs : List UInt8) (cs : List Char) (h : decode bs = some cs) : + encode cs = bs := + encode_decode_lt _ bs (Nat.lt_succ_self _) cs h + end Jws.Utf8.Proofs diff --git a/README.md b/README.md index cc1d2b8..eab1eb6 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,13 @@ where they end in `sorry`) and proved in | Claim | Theorem | |-------|---------| | Printing a parsed token gives back the text that was parsed, byte for byte. The command line tool answers an accepted token with this print, so the answer is the input and its signature still matches its signing input. | `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 (strict base64url, strict UTF-8, then the JSON parser), and signature bytes whose base64url is the third part; the text has exactly those three parts. | `Spec.parseToken_reads` | +| 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. The two decoders are strict, 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 gives the decoded bytes back: the decoder accepts only the standard encoding of its text, each character in its shortest form, so no overlong form, no encoded surrogate, and nothing above U+10FFFF. | `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` | @@ -135,13 +139,13 @@ 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 -(`Jws.Utf8.Proofs.decode_encode`, in `Proofs/Utf8.lean`), -and the base64url round trip. The base64url layer proves -both directions: `Spec.decode_encode` above, and the -reverse, encoding a decoding gives the text back -(`Jws.Base64url.Proofs.encode_decode`, in -`Proofs/B64.lean`). The theorems about acceptance are +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 @@ -177,6 +181,12 @@ directly, the same functions the theorems are 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 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 diff --git a/SPEC.md b/SPEC.md index 03defd5..d026413 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,15 +66,23 @@ strict: Encoding writes no padding and always sets the unused bits to zero, so decoding an encoding and encoding a decoding are both -the identity. Both directions are proved in this repo. +the identity. Both directions are proved in this repo +(`Spec.decode_encode`, `Spec.encode_decode`). The header and payload bytes must then be UTF-8, read strictly: overlong forms, encodings of the surrogate values U+D800 to U+DFFF, values above U+10FFFF, stray continuation bytes, and -truncated sequences are all turned away. The texts must then -parse as JSON under the rules of the json slice carried into -this repo (RFC 8259 grammar; lone surrogate escapes in strings -are turned away, as that slice's parser cannot hold them). +truncated sequences are all turned away. Both directions are +proved here too: decoding an encoding gives the text back +(`Spec.utf8_decode_encode`), and the decoder accepts only the +standard encoding of the text it returns, each character in +its shortest form (`Spec.utf8_encode_decode`); +`Spec.utf8_rejects_ill_formed` pins an example of each kind +turned away. The texts must then parse as JSON under the rules +of the json slice carried into this repo (RFC 8259 grammar; +lone surrogate escapes in strings are turned away, as that +slice's parser cannot hold them). Which texts the JSON parser +turns away is not proved here; the tests cover it. ## The check @@ -197,7 +205,8 @@ when it is the canonical encoding of its bytes. What the parser reads is stated too (`Spec.parseToken_reads`): the text splits at its dots into exactly those three parts, and the header and claims values are what the two held parts decode to through -strict base64url, strict UTF-8, and the JSON parser. +strict base64url, strict UTF-8 (both proved strict, above), and +the JSON parser. A token can also be made from values (`Jws.makeToken`): each JSON part is written with the json slice's canonical printer (no @@ -271,6 +280,10 @@ input theorems and the tests. | A parsed token holds the received first two parts, the values they decode to, and the signature bytes whose base64url is the third part | `Spec.parseToken_reads` | | Parsing the printed form of a token made from well formed JSON values gives that token back | `Spec.parseToken_print` | | Decoding a base64url encoding gives the bytes back | `Spec.decode_encode` | +| The base64url decoder accepts only the canonical encoding of the bytes it returns | `Spec.encode_decode` | +| Decoding a UTF-8 encoding gives the text back | `Spec.utf8_decode_encode` | +| The UTF-8 decoder accepts only the standard encoding of the text it returns, each character in its shortest form | `Spec.utf8_encode_decode` | +| The UTF-8 decoder turns away overlong forms, an encoded surrogate, a value above U+10FFFF, a stray continuation byte, a truncated sequence, and a five byte lead | `Spec.utf8_rejects_ill_formed` | | The RFC 7515 A.1 token is accepted under its allowlist with no time checks and the issuer checked | `Spec.accepts_rfc_a1` | | The harness's hand built token is accepted under its base context | `Spec.accepts_hand_built` | | The RFC 7515 A.5 unsecured token is refused under an HS256 allowlist | `Spec.rejects_unsecured_none` | diff --git a/Spec.lean b/Spec.lean index 336767b..60e7908 100644 --- a/Spec.lean +++ b/Spec.lean @@ -41,9 +41,11 @@ theorem Spec.printToken_parse (s : List Char) (t : Token) into exactly three parts, the kept header part, the kept payload part, and the base64url of the signature bytes; the header and claims values are what the two kept parts decode - to through strict base64url, strict UTF-8, and the JSON - parser. So a parser that kept the right parts but made up the - values fails this statement. -/ + to through the base64url decoder, the UTF-8 decoder, and the + JSON parser. So a parser that kept the right parts but made + up the values fails this statement. The two decoders are + strict: see `Spec.encode_decode` and `Spec.utf8_encode_decode` + below. -/ theorem Spec.parseToken_reads (s : List Char) (t : Token) (h : parseToken s = some t) : splitSep '.' s = [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] ∧ @@ -66,6 +68,44 @@ theorem Spec.parseToken_print (h c : Json.Json) (sig : List UInt8) theorem Spec.decode_encode (bs : List UInt8) : Base64url.decode (Base64url.encode bs) = some bs := sorry +/-! ## Strict decoding + +Each decoder accepts only an input that is the encoding of what +it returns. With the round trips, the base64url decoder and the +UTF-8 decoder are exactly the inverses of their encoders: no +padding, no nonzero unused bits, no overlong form, no surrogate, +nothing above U+10FFFF. -/ + +/-- Encoding what the base64url decoder returned gives the text + back: the decoder accepts only the canonical, unpadded + encoding of the bytes it returns. -/ +theorem Spec.encode_decode (cs : List Char) (bs : List UInt8) + (h : Base64url.decode cs = some bs) : + Base64url.encode bs = cs := sorry + +/-- Decoding the UTF-8 encoding of any text gives the text + back. -/ +theorem Spec.utf8_decode_encode (cs : List Char) : + Utf8.decode (Utf8.encode cs) = some cs := sorry + +/-- Encoding what the UTF-8 decoder returned gives the bytes + back: the decoder accepts only the standard encoding (RFC + 3629) of the text it returns, with each character in its + shortest form. -/ +theorem Spec.utf8_encode_decode (bs : List UInt8) (cs : List Char) + (h : Utf8.decode bs = some cs) : + Utf8.encode cs = bs := sorry + +/-- The UTF-8 decoder turns away, among others: 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. -/ +theorem Spec.utf8_rejects_ill_formed : + ([[0xC0, 0x80], [0xE0, 0x80, 0x80], [0xF0, 0x80, 0x80, 0x80], + [0xED, 0xA0, 0x80], [0xF4, 0x90, 0x80, 0x80], [0x80], [0xE2, 0x82], + [0xF8, 0x88, 0x80, 0x80, 0x80]] : List (List UInt8)).all + (fun bs => (Utf8.decode bs).isNone) = true := sorry + /-! ## Single tokens, accepted and refused Single inputs run through the same two entry points the diff --git a/comparator.json b/comparator.json index 1875025..f9207e7 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after","Spec.cmp_exact","Spec.numVal_value","Spec.numVal_cmp_now","Spec.rejects_negative_exp","Spec.accepts_negative_fraction_exp","Spec.accept_sound"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.printToken_parse","Spec.parseToken_reads","Spec.parseToken_print","Spec.decode_encode","Spec.encode_decode","Spec.utf8_decode_encode","Spec.utf8_encode_decode","Spec.utf8_rejects_ill_formed","Spec.accepts_rfc_a1","Spec.accepts_hand_built","Spec.rejects_unsecured_none","Spec.rejects_unsecured_none_when_allowlist_names_none","Spec.rejects_unsecured_none_when_allowlist_mixes_none","Spec.rejects_empty_signature","Spec.rejects_alg_outside_allowlist","Spec.rejects_part_not_base64url","Spec.rejects_header_not_object","Spec.accept_names_unique","Spec.rejects_duplicate_alg","Spec.rejects_duplicate_iss","Spec.accept_no_crit","Spec.rejects_crit_b64","Spec.rejects_aud_without_audience","Spec.accepts_aud_with_audience","Spec.accepts_fractional_exp_before","Spec.rejects_fractional_exp_after","Spec.cmp_exact","Spec.numVal_value","Spec.numVal_cmp_now","Spec.rejects_negative_exp","Spec.accepts_negative_fraction_exp","Spec.accept_sound"]} \ No newline at end of file From c2efa541513c09e88d89e8064be0730340fc1c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:20:04 +0000 Subject: [PATCH 17/23] Keep the printToken_parse claim to what the theorem says 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 11 +++++++++-- SPEC.md | 5 +++-- Spec.lean | 3 +-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index eab1eb6..89f0300 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ where they end in `sorry`) and proved in | Claim | Theorem | |-------|---------| -| Printing a parsed token gives back the text that was parsed, byte for byte. The command line tool answers an accepted token with this print, so the answer is the input and its signature still matches its signing input. | `Spec.printToken_parse` | +| 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. The two decoders are strict, 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` | @@ -163,7 +163,14 @@ axioms only (`propext`, `Classical.choice`, `Quot.sound`): The command line tool in `Main.lean` calls `Jws.parseToken`, `Jws.accept`, and `Jws.printToken` -directly, the same functions the theorems are about. +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. ## What is not proved diff --git a/SPEC.md b/SPEC.md index d026413..05e9047 100644 --- a/SPEC.md +++ b/SPEC.md @@ -251,8 +251,9 @@ separated decimal form as the payload: - Field 6: the expected audience. Empty means none. The answer is `ok:` followed by the character codes of the -token text as received (the print of the parsed token, proved -equal to the input) when the token is accepted, `none` +token text as received (`Jws.printToken` of the parsed token, +which `Spec.printToken_parse` proves equal to the text parsed) +when the token is accepted, `none` when it is not accepted, and `error` when the request itself is malformed (wrong field count, a field that is not a code list, a code that is not a Unicode scalar value, a current diff --git a/Spec.lean b/Spec.lean index 60e7908..e99089c 100644 --- a/Spec.lean +++ b/Spec.lean @@ -31,8 +31,7 @@ of the proved theorem of the same shape in `Proofs`. -/ for byte. The two first parts are kept as received, so the JWS Signing Input a verifier needs is never rebuilt from the JSON values; the signature part is the base64url of the - signature bytes. The command line tool answers an accepted - token with this print. -/ + signature bytes. -/ theorem Spec.printToken_parse (s : List Char) (t : Token) (h : parseToken s = some t) : printToken t = s := sorry From d20868e14ee0e282857174fd6f49f8bc6cc863f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:20:52 +0000 Subject: [PATCH 18/23] Cite the general theorems where a claim row states a general rule 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 10 +++++----- SPEC.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 89f0300..f6db148 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,12 @@ where they end in `sorry`) and proved in | 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.rejects_unsecured_none_when_allowlist_names_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. | `Spec.rejects_empty_signature` | -| The A.1 token is refused when its algorithm, HS256, is outside the allowlist (RS256 alone). | `Spec.rejects_alg_outside_allowlist` | -| A token whose first part carries a padding character does not parse: `=` is not in the base64url alphabet. | `Spec.rejects_part_not_base64url` | -| A token whose header part decodes to a JSON array, not an object, is refused. | `Spec.rejects_header_not_object` | +| 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` | diff --git a/SPEC.md b/SPEC.md index 05e9047..3dc23b2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -288,12 +288,12 @@ input theorems and the tests. | The RFC 7515 A.1 token is accepted under its allowlist with no time checks and the issuer checked | `Spec.accepts_rfc_a1` | | The harness's hand built token is accepted under its base context | `Spec.accepts_hand_built` | | The RFC 7515 A.5 unsecured token is refused under an HS256 allowlist | `Spec.rejects_unsecured_none` | -| The A.5 token is refused when the allowlist names `none` | `Spec.rejects_unsecured_none_when_allowlist_names_none` | +| The A.5 token is refused when the allowlist names `none`; every context whose allowlist names `none` refuses every token (rule 6) | `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` | -| A token with an empty signature part is refused (rule 2) | `Spec.rejects_empty_signature` | -| A token whose algorithm is outside the allowlist is refused (rule 7) | `Spec.rejects_alg_outside_allowlist` | -| A token with a part that is not strict base64url does not parse | `Spec.rejects_part_not_base64url` | -| A token whose header is not a JSON object is refused (rule 3) | `Spec.rejects_header_not_object` | +| The A.1 token with its signature part emptied is refused; every accepted token has a non-empty signature (rule 2) | `Spec.rejects_empty_signature`, `Spec.accept_sound` | +| The A.1 token is refused under an allowlist without HS256; every accepted token's `alg` is on the allowlist (rule 7) | `Spec.rejects_alg_outside_allowlist`, `Spec.accept_sound` | +| The hand built token with `=` after its first part does not parse; every part of a parsed token is the canonical, unpadded base64url encoding of its bytes (rule 1) | `Spec.rejects_part_not_base64url`, `Spec.parseToken_reads`, `Spec.encode_decode` | +| The hand built token whose header is the JSON array `["HS256"]` is refused; every accepted token's header is a JSON object (rule 3) | `Spec.rejects_header_not_object`, `Spec.accept_sound` | | An accepted token meets rules 2 to 18, the date comparisons as `Jws.NumVal.cmp` makes them (proved exact by `Spec.numVal_cmp_now`) | `Spec.accept_sound` | | An accepted token's header and claims are objects that repeat no member name (rules 3, 4, 17) | `Spec.accept_names_unique` | | The token with header `{"alg":"HS256","alg":"none"}` parses and is refused under every context (rule 17) | `Spec.rejects_duplicate_alg` | From 2b10f3292c3d1a31a08d18aa2884ef9c403ab843 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:21:41 +0000 Subject: [PATCH 19/23] Tests: describe what the harness runs, and fail on a PyJWT decode difference 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- tests/TEST-RESULTS.md | 2 +- tests/run_tests.py | 75 ++++++++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index e15dc62..e6d0363 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -10,7 +10,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil ## The oracle and PyJWT Verdicts come from the oracle, which reimplements the SPEC.md rules in Python. PyJWT 2.15.1 built 20 tokens (HS256, RS256, ES256, and none algorithms), kept in tests/pyjwt_corpus.json with PyJWT's own unverified decode of each. The harness reads that file, so it needs no PyJWT to run; to rebuild the corpus, make a virtual environment from tests/requirements.txt and run tests/make_pyjwt_corpus.py (README, How to run it). -PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. +PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, 0 differences. No decode difference is named, so any difference is a failure. ## PyJWT verdicts on the probe tokens diff --git a/tests/run_tests.py b/tests/run_tests.py index ac1555a..b2f1f0f 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Test harness for the JWS compact parser and checker. -Three parts, matching the slice plan: +Every input is a request line for the built tool, and the +expected answer comes from an oracle that reimplements SPEC.md in +Python. The inputs come in six groups: 1. Fixed cases: the RFC 7515 Appendix A examples (A.1 HS256, A.2 RS256, A.3 ES256, A.5 the unsecured none example; the @@ -9,30 +11,45 @@ token, so the A.1 cases cover it), each with contexts that accept and contexts that reject. The A.1 token with its signature part emptied, under an otherwise passing context, - is a fixed reject case. - -2. Differential: tokens built with PyJWT (read from the committed - corpus tests/pyjwt_corpus.json, which tests/make_pyjwt_corpus.py - builds from the pinned tests/requirements.txt), plus hand built - tokens and a seeded random corpus, each run under several check - contexts. Every verdict (and, on acceptance, the token text the - tool answers with) is compared against an oracle that - reimplements SPEC.md in Python. PyJWT also cross checks the - parsing: its own unverified decode of each token it built, kept - in the corpus, is compared with the oracle's parse. - -3. Mutations and malformed requests: padding added, characters - outside the alphabet, two or four parts, a header that is not - an object, alg missing, alg none with an empty signature, - exp equal to now, nbf equal to now, audience arrays that do - and do not hold the expected value, fractional dates, and - more; plus request lines that must be answered with `error`. - -The oracle is the authority for verdicts. PyJWT never decides a -verdict here: it builds well formed tokens and cross checks -decoding, because its own checks differ from this slice's rules -(it verifies signatures and registered claims its own way when -asked, and accepts some framings this slice turns away). + is a fixed reject case, and A.4, whose payload is not JSON, + is a fixed reject case (a named departure). The RFC 7515 A.1 + HMAC is checked on the tool's answer to each accepted A.1 + case. + +2. Mutations and named cases: padding added, characters outside + the alphabet, two or four parts, a header that is not an + object, alg missing, alg none with an empty signature, exp + and nbf equal to now, fractional and negative dates, + audience arrays that do and do not hold the expected value, + repeated names, crit, and more. Several of these are the + single inputs the theorems in Spec.lean pin, by name. + +3. Malformed requests that must be answered with `error`. + +4. PyJWT-built tokens, read from the committed corpus + tests/pyjwt_corpus.json (which tests/make_pyjwt_corpus.py + builds from the pinned tests/requirements.txt), each run + under five contexts. PyJWT's own unverified decode of each + token, kept in the corpus, is compared with the oracle's + parse; any difference fails the run (none is named). + +5. PyJWT verdict probes: HS256 tokens signed with a known key, + with PyJWT's own verdict (jwt.decode with that key and the + probe's context) kept in the corpus. The expected verdict is + still the oracle's. PyJWT's is compared beside it: every + disagreement must be one of the differences named in + PYJWT_NAMED, with its reason, and an unnamed disagreement, + or a named one that no longer differs, fails the run. + +6. Two seeded corpora: random tokens and contexts (seed SEED), + and a corpus of the shapes the rules turn on (seed SEED + 1): + repeated names, crit, whitespace and CR LF, escapes, + fractional dates, and aud. + +The oracle decides every expected verdict. PyJWT never does: its +own checks differ from this slice's rules in the named ways, so +its decodes and its probe verdicts are compared beside the +oracle's, and only the named differences are allowed. Run: python3 tests/run_tests.py (uses .lake/build/bin/jws; needs only the Python standard library). Writes tests/TEST-RESULTS.md @@ -1142,6 +1159,12 @@ def main(): failures += compare_probes(verdicts) + # A PyJWT decode that differs from the oracle's parse fails the + # run: no decode difference is named. + for tok in PYJWT_INFO["decode_differences"]: + failures.append(("pyjwt decode of %s... (differs from the oracle's" + " parse, unnamed)" % tok, "differs", "same")) + fixed = [c for c in CASES if c[0].startswith("A.")] fixed_fail = [f for f in failures if f[0].startswith("A.")] @@ -1175,6 +1198,8 @@ def main(): " it)." % (PYJWT_INFO["version"], PYJWT_INFO["tokens"])) lines_out.append("PyJWT's decode was compared with the oracle's" " parse on %d tokens: %d agreements, %d differences." + " No decode difference is named, so any difference" + " is a failure." % (PYJWT_INFO["decode_comparisons"], PYJWT_INFO["decode_agreements"], len(PYJWT_INFO["decode_differences"]))) From ec499299da3a9e21d8e5c692247094f190993967 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:57:01 +0000 Subject: [PATCH 20/23] Answer error on an empty name in the allowlist or required claims field 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Main.lean | 25 ++++++++++++------ README.md | 11 +++++--- SPEC.md | 7 ++++- tests/TEST-RESULTS.md | 11 ++++---- tests/run_tests.py | 59 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/Main.lean b/Main.lean index 78c33a9..95dbef5 100644 --- a/Main.lean +++ b/Main.lean @@ -15,6 +15,8 @@ import Jws for no time comparisons. Field 3: the allowed algorithms, names separated by commas inside the field text. Field 4: the required claims, same style. + A name in field 3 or 4 is never empty: a leading, + trailing or doubled comma makes the request malformed. Field 5: the expected issuer, or empty. Field 6: the expected audience, or empty. @@ -61,9 +63,16 @@ def chomp (line : String) : String := else line /-- Split a field text into names at its commas. An empty field - names nothing. -/ -def names (cs : List Char) : List (List Char) := - if cs.isEmpty then [] else Jws.splitSep ',' cs + names nothing. A name may not be empty, so a field with a + leading, trailing or doubled comma is malformed (`none`): the + text `HS256,` would otherwise put the empty name on the + allowlist, and a header whose `alg` is the empty string would + pass. -/ +def names (cs : List Char) : Option (List (List Char)) := + if cs.isEmpty then some [] + else + let ns := Jws.splitSep ',' cs + if ns.any (·.isEmpty) then none else some ns /-- Read the current time field: empty for no time comparisons, else an optional minus sign and at most 19 digits. -/ @@ -85,13 +94,12 @@ def answerCheck (fields : List (List Nat)) : Option String := | [tf, nf, af, rf, isf, auf] => match toChars tf, toChars nf, toChars af, toChars rf, toChars isf, toChars auf with | some t, some nowCs, some allowCs, some reqCs, some issCs, some audCs => - match readNow nowCs with - | none => none - | some now => + match readNow nowCs, names allowCs, names reqCs with + | some now, some allow, some required => let ctx : Jws.Context := - { allow := names allowCs + { allow := allow now := now - required := names reqCs + required := required issuer := if issCs.isEmpty then none else some issCs audience := if audCs.isEmpty then none else some audCs } match Jws.parseToken t with @@ -99,6 +107,7 @@ def answerCheck (fields : List (List Nat)) : Option String := | some tok => some (if Jws.accept tok ctx then "ok:" ++ showCodes (Jws.printToken tok) else "none") + | _, _, _ => none | _, _, _, _, _, _ => none | _ => none diff --git a/README.md b/README.md index f6db148..31a32d9 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,13 @@ 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. 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. +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 diff --git a/SPEC.md b/SPEC.md index 3dc23b2..77621f1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -250,6 +250,11 @@ separated decimal form as the payload: - Field 5: the expected issuer. Empty means none. - Field 6: the expected audience. Empty means none. +A name in field 3 or field 4 is never empty: a field with a +leading, trailing or doubled comma, such as `HS256,`, is +malformed. So no request puts the empty name on the allowlist, +where a header whose `alg` is the empty string would meet rule 7. + The answer is `ok:` followed by the character codes of the token text as received (`Jws.printToken` of the parsed token, which `Spec.printToken_parse` proves equal to the text parsed) @@ -258,7 +263,7 @@ when it is not accepted, and `error` when the request itself is malformed (wrong field count, a field that is not a code list, a code that is not a Unicode scalar value, a current time field that is not an optional minus sign plus at most 19 -digits, an unknown operation). +digits, an empty name in field 3 or 4, an unknown operation). ## Properties and their theorems diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index e6d0363..8b4b92a 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -4,7 +4,7 @@ Written by `python3 tests/run_tests.py`, which drives the built tool `.lake/buil - Oracle: the SPEC.md rules reimplemented in tests/run_tests.py (Python 3.13), with tokens and decodes from PyJWT 2.15.1 (cryptography 46.0.3) - Seed: 20261026 -- Inputs: 743 +- Inputs: 752 - Failures: 0 ## The oracle and PyJWT @@ -27,10 +27,10 @@ PyJWT's decode was compared with the oracle's parse on 20 tokens: 20 agreements, ## Counts -- Total check and request inputs: 743 -- Of these: 20 PyJWT-built tokens under 5 contexts each, 36 PyJWT verdict probes, 240 random corpus cases (seed 20261026), 240 shape corpus cases (seed 20261027), and 127 fixed and mutation cases -- Agreements with the oracle: 743 -- Of the verdict cases: 105 accepted, 628 rejected, 10 malformed requests answered error +- Total check and request inputs: 752 +- Of these: 20 PyJWT-built tokens under 5 contexts each, 36 PyJWT verdict probes, 240 random corpus cases (seed 20261026), 240 shape corpus cases (seed 20261027), and 136 fixed and mutation cases +- Agreements with the oracle: 752 +- Of the verdict cases: 105 accepted, 630 rejected, 17 malformed requests answered error ## RFC 7515 fixed cases @@ -63,6 +63,7 @@ The A.5 example is the RFC's unsecured (alg none) compact example; in the final - Named change from review 1 item 6: with no expected audience, a token that carries `aud` is turned away (SPEC rule 16). RFC 7519 section 4.1.3 asks a reader that does not identify itself with a value in `aud` to reject the token. PyJWT reads it the same way; some other libraries accept such a token. Generated tokens with `aud` under a context with no audience now expect refusal in the oracle. - From review 1 item 9: the allowlist does not stop key and algorithm confusion. The page warns that a 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. The generated contexts here mix families only as test data for the allowlist rule. - The none rule is the exact name `none`. An alg of `None` is a different name and can be allowed. +- Named change from the independent check of the carried branch: a name in the allowlist or required claims field is never empty, so a leading, trailing or doubled comma makes the request malformed and the tool answers `error`. Before, the allowlist text `HS256,` put the empty name on the allowlist, and a header whose `alg` is the empty string passed. Fixed malformed cases pin each comma shape, and the oracle reads both fields as the tool does. - Named change from review 1 item 7: `exp`, `nbf` and `iat` may be non-integers (RFC 7519 section 2) and are compared exactly with the current time (SPEC rule 9). The earlier reading turned every fractional date away; the three fixed cases that pinned it now expect acceptance, and new cases cover fractions a millionth either side of now, equality, `nbf`, and exponents of -999999999. - Date claims are judged by value, not by spelling: `2.0e9` and `20000000000e-1` are the number 2000000000, and `1e999999999` compares without ever being written out. - PyJWT decode differences (its parser against the oracle's on well formed tokens): 0. diff --git a/tests/run_tests.py b/tests/run_tests.py index b2f1f0f..8c28fbf 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -24,7 +24,9 @@ repeated names, crit, and more. Several of these are the single inputs the theorems in Spec.lean pin, by name. -3. Malformed requests that must be answered with `error`. +3. Malformed requests that must be answered with `error`, + among them an empty name (a leading, trailing or doubled + comma) in the allowlist or required claims field. 4. PyJWT-built tokens, read from the committed corpus tests/pyjwt_corpus.json (which tests/make_pyjwt_corpus.py @@ -348,8 +350,27 @@ def decode_answer(ans): CASES = [] +def read_names(text): + """How the tool reads field 3 (the allowlist) and field 4 (the + required claims): an empty field names nothing; otherwise the + names between the commas, none of them empty. A leading, + trailing or doubled comma makes the request malformed (None).""" + if text == "": + return [] + ns = text.split(",") + if any(n == "" for n in ns): + return None + return ns + + def add_check(name, token, now, allow, required, issuer, audience): - want = oracle_check(token, now, allow, required, issuer, audience) + allow_names = read_names(",".join(allow)) + required_names = read_names(",".join(required)) + if allow_names is None or required_names is None: + want = "error" + else: + want = oracle_check(token, now, allow_names, required_names, issuer, + audience) CASES.append((name, check_request(token, now, allow, required, issuer, audience), want)) @@ -780,6 +801,31 @@ def claims_token(claims_bytes, header_bytes=GOOD_H, sig=SIG): codes_of("HS256"), "", "", ""]), "error") add_raw("malformed: empty line", "", "error") add_raw("malformed: check with no payload", "check", "error") + +# An empty name in the allowlist or the required claims field is a +# malformed request. Before, the allowlist text "HS256," put the +# empty name on the allowlist, and a header whose alg is the empty +# string passed (found by the independent check of this branch). +EMPTY_ALG = claims_token(b'{}', header_bytes=b'{"alg":""}') +for label, allow_text, req_text in [ + ("allowlist with a trailing comma, alg the empty string", + "HS256,", ""), + ("allowlist with a leading comma", ",HS256", ""), + ("allowlist with a doubled comma", "HS256,,RS256", ""), + ("allowlist that is one comma", ",", ""), + ("required claims with a trailing comma", "HS256", "exp,"), + ("required claims with a doubled comma", "HS256", "iss,,exp")]: + add_raw("malformed: " + label, + "check " + ";".join([codes_of(EMPTY_ALG), "", + codes_of(allow_text), codes_of(req_text), + "", ""]), "error") +add_check("alg the empty string, allowlist HS256,RS256: refused", + EMPTY_ALG, None, ["HS256", "RS256"], [], None, None) +add_check("alg the empty string, empty allowlist: refused", + EMPTY_ALG, None, [], [], None, None) +add_check("malformed: allowlist naming HS256 and the empty name, read" + " as the tool reads it", BASE, NOW, ["HS256", ""], [], None, + None) add_check("now field with a minus sign and digits (read as negative)", claims_token(b'{"exp":100,"nbf":-50}'), -5, ["HS256"], [], None, None) @@ -1329,6 +1375,15 @@ def main(): " data for the allowlist rule.") lines_out.append("- The none rule is the exact name `none`. An alg of" " `None` is a different name and can be allowed.") + lines_out.append("- Named change from the independent check of the" + " carried branch: a name in the allowlist or required" + " claims field is never empty, so a leading, trailing" + " or doubled comma makes the request malformed and the" + " tool answers `error`. Before, the allowlist text" + " `HS256,` put the empty name on the allowlist, and a" + " header whose `alg` is the empty string passed. Fixed" + " malformed cases pin each comma shape, and the oracle" + " reads both fields as the tool does.") lines_out.append("- Named change from review 1 item 7: `exp`, `nbf`" " and `iat` may be non-integers (RFC 7519 section 2)" " and are compared exactly with the current time" From b365967e71f4868cb0635e266087ab7800c26803 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:10:43 +0000 Subject: [PATCH 21/23] Restate numVal's exponent step so the nanoda kernel accepts it 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/Num.lean | 90 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 28 deletions(-) diff --git a/Proofs/Num.lean b/Proofs/Num.lean index 900fcc2..e768ded 100644 --- a/Proofs/Num.lean +++ b/Proofs/Num.lean @@ -279,41 +279,75 @@ theorem expValue_plain (ec d : Char) (ds : List Char) (n1 : d ≠ '-') (n2 : d · rename_i heq; simp at heq; exact absurd heq.2.1 n2 · rename_i heq; simp at heq; rw [heq.2] -/-- `numVal`'s exponent step on an exponent part of the grammar. -/ -theorem withExp_eq (neg : Bool) (ids fds : List Char) (ec : Char) (sgn : List Char) (d : Char) - (ds : List Char) (hs : sgn = [] ∨ sgn = ['-'] ∨ sgn = ['+']) +/-! The exponent step, one lemma per sign. Each states its sign + character in the text itself rather than as `sgn ++ _` with `sgn` + substituted: the nanoda kernel overflows its stack when it has to + reduce a match on a literal character reached through `++`. -/ + +/-- `numVal`'s exponent step on an unsigned exponent. -/ +theorem withExp_none (neg : Bool) (ids fds : List Char) (ec d : Char) (ds : List Char) (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : - numVal.withExp neg ids fds (sgn ++ d :: ds) = - some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: (sgn ++ d :: ds)) - fds.length)) := by + numVal.withExp neg ids fds (d :: ds) = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: d :: ds) - fds.length)) := by obtain ⟨n1, n2, -, -, -⟩ := ne_of_dig hd have hsp := span_digits d ds hd hds unfold numVal.withExp split rename_i heq + split at heq + · rename_i hc; simp at hc; exact absurd hc.1 n1 + · rename_i hc; simp at hc; exact absurd hc.1 n2 + · simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [hsp] + simp [expValue_plain ec d ds n1 n2, digitsVal_eq] + +/-- `numVal`'s exponent step on an exponent after `-`. -/ +theorem withExp_minus (neg : Bool) (ids fds : List Char) (ec d : Char) (ds : List Char) + (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : + numVal.withExp neg ids fds ('-' :: d :: ds) = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: '-' :: d :: ds) - fds.length)) := by + have hsp := span_digits d ds hd hds + unfold numVal.withExp + split + rename_i heq + split at heq + · rename_i hc; simp at hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [← hc, hsp] + simp [expValue, digitsVal_eq] + · rename_i hc; simp at hc + · rename_i hc1 _; exact absurd (hc1 (d :: ds) rfl) (by simp) + +/-- `numVal`'s exponent step on an exponent after `+`. -/ +theorem withExp_plus (neg : Bool) (ids fds : List Char) (ec d : Char) (ds : List Char) + (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : + numVal.withExp neg ids fds ('+' :: d :: ds) = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: '+' :: d :: ds) - fds.length)) := by + have hsp := span_digits d ds hd hds + unfold numVal.withExp + split + rename_i heq + split at heq + · rename_i hc; simp at hc + · rename_i hc; simp at hc + simp only [Prod.mk.injEq] at heq + obtain ⟨rfl, rfl⟩ := heq + rw [← hc, hsp] + simp [expValue, digitsVal_eq] + · rename_i _ hc2; exact absurd (hc2 (d :: ds) rfl) (by simp) + +/-- `numVal`'s exponent step on an exponent part of the grammar. -/ +theorem withExp_eq (neg : Bool) (ids fds : List Char) (ec : Char) (sgn : List Char) (d : Char) + (ds : List Char) (hs : sgn = [] ∨ sgn = ['-'] ∨ sgn = ['+']) + (hd : Json.IsDigit d) (hds : ∀ x ∈ ds, Json.IsDigit x) : + numVal.withExp neg ids fds (sgn ++ d :: ds) = + some (mkNumVal neg (digitsVal (ids ++ fds)) (expValue (ec :: (sgn ++ d :: ds)) - fds.length)) := by rcases hs with rfl | rfl | rfl - · split at heq - · rename_i hc; simp at hc; exact absurd hc.1 n1 - · rename_i hc; simp at hc; exact absurd hc.1 n2 - · simp only [List.nil_append, Prod.mk.injEq] at heq - obtain ⟨rfl, rfl⟩ := heq - rw [hsp] - simp [expValue_plain ec d ds n1 n2, digitsVal_eq] - · split at heq - · rename_i hc; simp at hc - simp only [Prod.mk.injEq] at heq - obtain ⟨rfl, rfl⟩ := heq - rw [← hc, hsp] - simp [expValue, digitsVal_eq] - · rename_i hc; simp at hc - · rename_i hc1 _; exact absurd (hc1 (d :: ds) rfl) (by simp) - · split at heq - · rename_i hc; simp at hc - · rename_i hc; simp at hc - simp only [Prod.mk.injEq] at heq - obtain ⟨rfl, rfl⟩ := heq - rw [← hc, hsp] - simp [expValue, digitsVal_eq] - · rename_i _ hc2; exact absurd (hc2 (d :: ds) rfl) (by simp) + · rw [List.nil_append]; exact withExp_none neg ids fds ec d ds hd hds + · rw [List.singleton_append]; exact withExp_minus neg ids fds ec d ds hd hds + · rw [List.singleton_append]; exact withExp_plus neg ids fds ec d ds hd hds /-- `numVal`'s last step on an exponent part of the grammar, or none. -/ theorem finish_eq (neg : Bool) (ids fds ex : List Char) (hx : Json.ExpOk ex) : From 6a9bd286b4278d9cdcefe30c23520d08b064dae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:21:10 +0000 Subject: [PATCH 22/23] Say what utf8_encode_decode proves, and build before testing 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Jws/Utf8.lean | 3 ++- Proofs/Utf8.lean | 3 +-- README.md | 27 +++++++++++++++++++++------ SPEC.md | 16 ++++++++++------ Spec.lean | 12 ++++++------ 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/Jws/Utf8.lean b/Jws/Utf8.lean index 06223a4..3669831 100644 --- a/Jws/Utf8.lean +++ b/Jws/Utf8.lean @@ -4,7 +4,8 @@ carries hold their strings as lists of characters, so the token code needs both directions between characters and bytes. - The encoder writes the standard one to four bytes per character. + The encoder is meant to write the standard one to four bytes per + character; no theorem compares it with RFC 3629. The decoder is strict. It turns away stray continuation bytes, overlong forms (a character written with more bytes than it needs, including any lead byte below C2), lead bytes above F4, diff --git a/Proofs/Utf8.lean b/Proofs/Utf8.lean index db74c1a..7936b56 100644 --- a/Proofs/Utf8.lean +++ b/Proofs/Utf8.lean @@ -249,8 +249,7 @@ theorem encode_decode_lt : ∀ (n : Nat) (bs : List UInt8), bs.length < n → · cases h /-- Encoding a decoding gives the bytes back: the decoder accepts only - byte strings that are the standard encoding of the text it - returns, so it is strict. -/ + byte strings that `encode` writes for the text it returns. -/ theorem encode_decode (bs : List UInt8) (cs : List Char) (h : decode bs = some cs) : encode cs = bs := encode_decode_lt _ bs (Nat.lt_succ_self _) cs h diff --git a/README.md b/README.md index 31a32d9..385dee7 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,12 @@ where they end in `sorry`) and proved in | 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. The two decoders are strict, as the rows below prove. | `Spec.parseToken_reads` | +| 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 gives the decoded bytes back: the decoder accepts only the standard encoding of its text, each character in its shortest form, so no overlong form, no encoded surrogate, and nothing above U+10FFFF. | `Spec.utf8_encode_decode` | +| 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` | @@ -197,6 +197,20 @@ on the tool's answer. 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.encode` writes the standard UTF-8 + encoding (RFC 3629), each character in its shortest + form. `Spec.utf8_encode_decode` says 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_formed` pins 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 @@ -304,21 +318,22 @@ pinned by tests: ## Re-check it -1. `lake comparator --paranoid`: builds the challenge +1. `lake build`: the libraries, the proofs, and the tool + build clean. This also builds `.lake/build/bin/jws`, + which the test harness runs. +2. `lake comparator --paranoid`: builds the challenge file `Spec.lean` and the proofs in a sandbox and checks that each theorem named in `comparator.json` proves 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. -2. `python3 tests/run_tests.py`: the fixed RFC cases, +3. `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 to `tests/TEST-RESULTS.md`. The harness reads the PyJWT tokens and verdicts from `tests/pyjwt_corpus.json` and needs no library beyond Python's own. -3. `lake build`: the libraries, the proofs, and the tool - build clean. 4. Read `SPEC.md` against RFC 7515 and RFC 7519 and check the pinned readings are the ones you want. diff --git a/SPEC.md b/SPEC.md index 77621f1..fad47f3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -74,11 +74,14 @@ overlong forms, encodings of the surrogate values U+D800 to U+DFFF, values above U+10FFFF, stray continuation bytes, and truncated sequences are all turned away. Both directions are proved here too: decoding an encoding gives the text back -(`Spec.utf8_decode_encode`), and the decoder accepts only the -standard encoding of the text it returns, each character in -its shortest form (`Spec.utf8_encode_decode`); +(`Spec.utf8_decode_encode`), and the decoder accepts only what +this repo's encoder `Jws.Utf8.encode` writes for the text it +returns (`Spec.utf8_encode_decode`); `Spec.utf8_rejects_ill_formed` pins an example of each kind -turned away. The texts must then parse as JSON under the rules +turned away. That the encoder writes the standard encoding +(RFC 3629), each character in its shortest form, is not +proved; the tests cover it for the two byte `é` only (see +README, What is not proved). The texts must then parse as JSON under the rules of the json slice carried into this repo (RFC 8259 grammar; lone surrogate escapes in strings are turned away, as that slice's parser cannot hold them). Which texts the JSON parser @@ -205,7 +208,8 @@ when it is the canonical encoding of its bytes. What the parser reads is stated too (`Spec.parseToken_reads`): the text splits at its dots into exactly those three parts, and the header and claims values are what the two held parts decode to through -strict base64url, strict UTF-8 (both proved strict, above), and +strict base64url, strict UTF-8 (each decoder accepts only its +encoder's output, proved above), and the JSON parser. A token can also be made from values (`Jws.makeToken`): each JSON @@ -288,7 +292,7 @@ input theorems and the tests. | Decoding a base64url encoding gives the bytes back | `Spec.decode_encode` | | The base64url decoder accepts only the canonical encoding of the bytes it returns | `Spec.encode_decode` | | Decoding a UTF-8 encoding gives the text back | `Spec.utf8_decode_encode` | -| The UTF-8 decoder accepts only the standard encoding of the text it returns, each character in its shortest form | `Spec.utf8_encode_decode` | +| The UTF-8 decoder accepts only what `Jws.Utf8.encode` writes for the text it returns | `Spec.utf8_encode_decode` | | The UTF-8 decoder turns away overlong forms, an encoded surrogate, a value above U+10FFFF, a stray continuation byte, a truncated sequence, and a five byte lead | `Spec.utf8_rejects_ill_formed` | | The RFC 7515 A.1 token is accepted under its allowlist with no time checks and the issuer checked | `Spec.accepts_rfc_a1` | | The harness's hand built token is accepted under its base context | `Spec.accepts_hand_built` | diff --git a/Spec.lean b/Spec.lean index e99089c..7f1c605 100644 --- a/Spec.lean +++ b/Spec.lean @@ -42,9 +42,9 @@ theorem Spec.printToken_parse (s : List Char) (t : Token) header and claims values are what the two kept parts decode to through the base64url decoder, the UTF-8 decoder, and the JSON parser. So a parser that kept the right parts but made - up the values fails this statement. The two decoders are - strict: see `Spec.encode_decode` and `Spec.utf8_encode_decode` - below. -/ + up the values fails this statement. Each of the two decoders + accepts only its encoder's output: see `Spec.encode_decode` + and `Spec.utf8_encode_decode` below. -/ theorem Spec.parseToken_reads (s : List Char) (t : Token) (h : parseToken s = some t) : splitSep '.' s = [t.headerSeg, t.payloadSeg, Base64url.encode t.sig] ∧ @@ -88,9 +88,9 @@ theorem Spec.utf8_decode_encode (cs : List Char) : Utf8.decode (Utf8.encode cs) = some cs := sorry /-- Encoding what the UTF-8 decoder returned gives the bytes - back: the decoder accepts only the standard encoding (RFC - 3629) of the text it returns, with each character in its - shortest form. -/ + back: the decoder accepts only what `Utf8.encode` writes for + the text it returns. That `Utf8.encode` writes the standard + encoding (RFC 3629) is not stated here. -/ theorem Spec.utf8_encode_decode (bs : List UInt8) (cs : List Char) (h : Utf8.decode bs = some cs) : Utf8.encode cs = bs := sorry From 700b82989da40d00864b2ba03e486b2644d95bdb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:40:01 +0000 Subject: [PATCH 23/23] Declare the Apache-2.0 package license in lakefile.toml 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 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- lakefile.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/lakefile.toml b/lakefile.toml index 53e281c..c5bcf3a 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -1,5 +1,6 @@ name = "jws" version = "0.1.0" +license = "Apache-2.0" defaultTargets = ["Json", "Jws", "Proofs", "jws"] [[lean_lib]]