identity: PKCE S256 (RFC 7636 §4.2) — now the erlang binary substrate is fixed
oauth.sx routes the PKCE check through pkce_ok: an S256 challenge carried as
{s256, Hash} compares crypto:hash(sha256, Verifier) =:= Hash; a bare
challenge stays plain (§4.1), so both methods coexist with no change to
existing flows (the bare path is the old =:= behaviour). Raw sha256 digests
are compared (base64url is wire encoding, omitted). New tests/pkce.sx (6,
incl. S256 through PAR). Verified pkce 6/6; substrate fix is in the
preceding commit. 239 total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ SUITES=(
|
|||||||
"par|id-par-test-pass|id-par-test-count"
|
"par|id-par-test-pass|id-par-test-count"
|
||||||
"dynreg|id-dyn-test-pass|id-dyn-test-count"
|
"dynreg|id-dyn-test-pass|id-dyn-test-count"
|
||||||
"account|id-acct-test-pass|id-acct-test-count"
|
"account|id-acct-test-pass|id-acct-test-count"
|
||||||
|
"pkce|id-pkce-test-pass|id-pkce-test-count"
|
||||||
)
|
)
|
||||||
|
|
||||||
cat > "$TMPFILE" << 'EPOCHS'
|
cat > "$TMPFILE" << 'EPOCHS'
|
||||||
@@ -95,6 +96,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/identity/tests/par.sx")
|
(load "lib/identity/tests/par.sx")
|
||||||
(load "lib/identity/tests/dynreg.sx")
|
(load "lib/identity/tests/dynreg.sx")
|
||||||
(load "lib/identity/tests/account.sx")
|
(load "lib/identity/tests/account.sx")
|
||||||
|
(load "lib/identity/tests/pkce.sx")
|
||||||
(epoch 100)
|
(epoch 100)
|
||||||
(eval "(list id-session-test-pass id-session-test-count)")
|
(eval "(list id-session-test-pass id-session-test-count)")
|
||||||
(epoch 101)
|
(epoch 101)
|
||||||
@@ -139,6 +141,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(eval "(list id-dyn-test-pass id-dyn-test-count)")
|
(eval "(list id-dyn-test-pass id-dyn-test-count)")
|
||||||
(epoch 121)
|
(epoch 121)
|
||||||
(eval "(list id-acct-test-pass id-acct-test-count)")
|
(eval "(list id-acct-test-pass id-acct-test-count)")
|
||||||
|
(epoch 122)
|
||||||
|
(eval "(list id-pkce-test-pass id-pkce-test-count)")
|
||||||
EPOCHS
|
EPOCHS
|
||||||
|
|
||||||
timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
92
lib/identity/tests/pkce.sx
Normal file
92
lib/identity/tests/pkce.sx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
;; identity/tests/pkce.sx — PKCE S256 (RFC 7636 §4.2). The challenge is
|
||||||
|
;; sha256(verifier) (compared as raw digests — base64url is wire encoding,
|
||||||
|
;; omitted); the verifier is presented at exchange and re-hashed. An S256
|
||||||
|
;; challenge is carried as a tagged {s256, Hash}; bare challenges remain
|
||||||
|
;; `plain` (RFC 7636 §4.1), so both methods coexist. Requires the fixed
|
||||||
|
;; erlang binary substrate (binary =:= + crypto:hash on binary literals).
|
||||||
|
|
||||||
|
(define id-pkce-test-count 0)
|
||||||
|
(define id-pkce-test-pass 0)
|
||||||
|
(define id-pkce-test-fails (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
id-pkce-test
|
||||||
|
(fn
|
||||||
|
(name actual expected)
|
||||||
|
(set! id-pkce-test-count (+ id-pkce-test-count 1))
|
||||||
|
(if
|
||||||
|
(= actual expected)
|
||||||
|
(set! id-pkce-test-pass (+ id-pkce-test-pass 1))
|
||||||
|
(append! id-pkce-test-fails {:name name :expected expected :actual actual}))))
|
||||||
|
|
||||||
|
(define idp-ev erlang-eval-ast)
|
||||||
|
(define idpnm (fn (v) (get v :name)))
|
||||||
|
|
||||||
|
(identity-load-oauth!)
|
||||||
|
|
||||||
|
;; Shared S256 prelude: verifier V, challenge = sha256(V), consented code.
|
||||||
|
(define
|
||||||
|
idp-s256
|
||||||
|
"V = <<\"verifier-abc-123\">>,\n Ch = crypto:hash(sha256, V),\n O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, read, alice, {s256, Ch}),\n {code, Cd} = identity_oauth:consent(O, Rq, allow)")
|
||||||
|
|
||||||
|
;; ── S256 happy path ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"S256: exchange with the matching verifier yields an active token"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
(str
|
||||||
|
idp-s256
|
||||||
|
", {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, V),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
|
||||||
|
"active")
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"S256: the token carries the authorized subject"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
(str
|
||||||
|
idp-s256
|
||||||
|
", {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, V),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end")))
|
||||||
|
"alice")
|
||||||
|
|
||||||
|
;; ── S256 rejects a wrong verifier ────────────────────────────────
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"S256: a wrong verifier is invalid_grant"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
(str
|
||||||
|
idp-s256
|
||||||
|
", case identity_oauth:exchange(O, Cd, web, uri1, <<\"wrong-verifier\">>) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end")))
|
||||||
|
"invalid_grant")
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"S256: the bare challenge hash is not accepted as the verifier"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
(str
|
||||||
|
idp-s256
|
||||||
|
", case identity_oauth:exchange(O, Cd, web, uri1, Ch) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end")))
|
||||||
|
"invalid_grant")
|
||||||
|
|
||||||
|
;; ── plain PKCE still works (coexistence) ─────────────────────────
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"plain: a bare challenge still matches its verifier"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, read, alice, plainverif),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, plainverif),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||||
|
"active")
|
||||||
|
|
||||||
|
;; ── S256 threads through PAR ──────────────────────────────────────
|
||||||
|
|
||||||
|
(id-pkce-test
|
||||||
|
"S256 works through a pushed authorization request"
|
||||||
|
(idpnm
|
||||||
|
(idp-ev
|
||||||
|
"V = <<\"par-verifier\">>,\n Ch = crypto:hash(sha256, V),\n O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, {s256, Ch}),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, V),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||||
|
"active")
|
||||||
|
|
||||||
|
(define
|
||||||
|
id-pkce-test-summary
|
||||||
|
(str "pkce " id-pkce-test-pass "/" id-pkce-test-count))
|
||||||
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/identity/conformance.sh` → **233/233** (4 phases + 15 ext) — slow (~10min, run in background; internal timeout 1200)
|
`bash lib/identity/conformance.sh` → **239/239** (4 phases + 16 ext) — slow (~10min, run in background; internal timeout 1200)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
|||||||
- [x] tests: audit completeness, cross-instance subject mapping
|
- [x] tests: audit completeness, cross-instance subject mapping
|
||||||
|
|
||||||
## Extensions (base roadmap complete; deepen the engine)
|
## Extensions (base roadmap complete; deepen the engine)
|
||||||
- [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers)
|
- [x] PKCE S256 method (RFC 7636 §4.2) — substrate fixed; `{s256, sha256(verifier)}` challenge
|
||||||
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
||||||
- [x] scope as a set + scope narrowing on refresh (RFC 6749 §6)
|
- [x] scope as a set + scope narrowing on refresh (RFC 6749 §6)
|
||||||
- [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2)
|
- [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2)
|
||||||
@@ -95,6 +95,21 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
|||||||
- [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type)
|
- [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type)
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
- 2026-06-07 — PKCE S256 UNBLOCKED + implemented (RFC 7636 §4.2). Root-caused
|
||||||
|
the substrate blocker to ONE bug in `lib/erlang/transpile.sx`
|
||||||
|
`er-eval-binary-segment`: a string literal in a binary (`<<"abc">>`) was
|
||||||
|
evaluated as a single integer segment, emitting one `0` byte — so every
|
||||||
|
binary literal became `{:bytes (0)}` (hence binary `=:=` "always equal" and
|
||||||
|
crypto:hash input-independent). Fix (user-authorized cross-scope edit on
|
||||||
|
architecture; loops/erlang should adopt as owner): the integer branch now
|
||||||
|
expands a string value to per-character bytes (`<<"abc">>` ≡ `<<97,98,99>>`).
|
||||||
|
Verified: `byte_size(<<"abc">>)`=3, binary `=:=` correct, sha256 distinct.
|
||||||
|
Then `oauth.sx`: PKCE check routed through `pkce_ok` — `{s256, H}` challenge
|
||||||
|
compares `crypto:hash(sha256, Verifier) =:= H`; bare challenge stays `plain`
|
||||||
|
(RFC §4.1), so both coexist with zero change to existing flows. New
|
||||||
|
tests/pkce.sx (6, incl. S256-through-PAR). +6 → 239. Done directly on
|
||||||
|
architecture (where fixed-erlang + merged-identity coexist); loops/identity
|
||||||
|
trails until refreshed.
|
||||||
- 2026-06-07 — "disconnect app" (ext): `identity_tokens:revoke_app(Subject,
|
- 2026-06-07 — "disconnect app" (ext): `identity_tokens:revoke_app(Subject,
|
||||||
Client)` revokes every grant a subject holds for one client at once (audited
|
Client)` revokes every grant a subject holds for one client at once (audited
|
||||||
one revoke per grant), exposed at the facade as `identity:revoke_app`. The
|
one revoke per grant), exposed at the facade as `identity:revoke_app`. The
|
||||||
@@ -276,6 +291,12 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
|||||||
`tests/session.sx`). 11/11.
|
`tests/session.sx`). 11/11.
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
- 2026-06-07 — **RESOLVED.** Both symptoms below were ONE bug —
|
||||||
|
`er-eval-binary-segment` in `lib/erlang/transpile.sx` emitting a single `0`
|
||||||
|
byte for a string literal in a binary, so every `<<"...">>` was `{:bytes (0)}`
|
||||||
|
(equal to each other, and hashing the same). Fixed (string → per-character
|
||||||
|
bytes); binary `=:=` and `crypto:hash` now correct, and PKCE S256 is
|
||||||
|
implemented. See Progress log 2026-06-07. (Historical detail below.)
|
||||||
- 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs
|
- 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs
|
||||||
in `lib/erlang` make a correct/secure S256 impossible (S256 needs
|
in `lib/erlang` make a correct/secure S256 impossible (S256 needs
|
||||||
`BASE64URL(SHA256(verifier))` compared against the stored challenge):
|
`BASE64URL(SHA256(verifier))` compared against the stored challenge):
|
||||||
|
|||||||
Reference in New Issue
Block a user