oficina / home Public

Ruling A gets a second opinion, and it corrected this plan

Asked gpt-6-astra via codex (the account's default — named models are
refused, and the rollout was checked rather than assumed) to take
Ruling A cold: hand-rolled Ed25519 JWS, or a JWT dependency in every
app.

It agreed with option 1 under these constraints, and improved the case
for it in three ways this plan had got wrong or missed:

1. It corrected the framing. This plan sized the verifier at "60 lines".
   Its response was that parsing, replay protection and key handling are
   what decide safety, so a line-count target is the wrong goal. The
   plan now carries the six-step checklist instead — bound and parse,
   constrain the header, resolve a trusted key, verify over the ORIGINAL
   bytes, validate the claims, consume before minting — which is what
   "safe" actually costs and what makes option 1 defensible rather than
   merely cheap.

2. It named a third option: a shared policy package backed by a JWT
   library. One package exposing only this protocol, configured once
   rather than four times. Strictly better than option 2 as this plan
   had stated it, and recorded as such.

3. It named the strongest argument against its own recommendation —
   maintenance concentration, since one shared verifier distributes one
   mistake across the whole suite — with an explicit condition: if
   independent security review and adversarial testing will not happen,
   take the library instead. On this project review does happen and has
   caught this class of defect repeatedly, which is the argument FOR
   option 1; but the condition is what to check, not the conclusion.

One independent confirmation worth noting: asked what is most commonly
forgotten, it said login CSRF — a valid single-use assertion does not
stop an attacker fixing their own onto a victim's browser, so the
receiving endpoint must bind the exchange to the browser's own login
transaction. §5.6 already requires exactly that. That is the third time
this same attack has surfaced in this project, after the magic link and
the keymail callback.

Ruling A is still Paul's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 311b95a6346a3d414a2e54cd06cec4152d1f0665 parent bd3e7d7
1 file changed, +72 −4
  • docs/superpowers/plans/2026-09-07-home-assertion.md +72 −4

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-07-home-assertion.md b/docs/superpowers/plans/2026-09-07-home-assertion.md
index 01535e1..a120d90 100644
--- a/docs/superpowers/plans/2026-09-07-home-assertion.md
+++ b/docs/superpowers/plans/2026-09-07-home-assertion.md
@@ -100,8 +100,9 @@ the module.
So the choices are:
1. **Hand-rolled compact JWS** over stdlib `crypto/ed25519`: base64url
- header, base64url claims, `EdDSA` alg, one signature. Perhaps 80 lines
- for the minter and 60 for the verifier.
+ header, base64url claims, `EdDSA` alg, one signature. The minter is
+ genuinely small. The verifier is not — see the checklist below, which
+ is what "safe" actually costs.
2. **A third-party JWT library**, and a new dependency in the home and in
every consumer.
@@ -113,10 +114,77 @@ in a shared package the suite imports is option 1 done properly; writing
it three times in three apps is option 1 done the way it usually goes.
**Recommendation, for Paul to accept or reject:** option 1, with the
-verifier shipped as a small package the suite imports — so there is one
-verifier, not one per app — and with the `alg` allowlisted to `EdDSA` by
+verifier shipped as a package the suite imports — so there is one
+verifier, not one per app — and with `alg` allowlisted to `EdDSA` by
whitelist rather than by rejecting `none`. This is a §1 row.
+**Second opinion, 2026-09-07, `gpt-6-astra` via codex** (the account's
+default; named models are refused, and the rollout was checked rather
+than assumed). It agreed with option 1 under these constraints, and
+corrected the framing above: an earlier draft of this section put the
+verifier at "60 lines", and its response was that parsing, replay
+protection and key handling are what decide safety, so a line-count
+target is the wrong goal. It also named a third option this plan had not
+considered, and the strongest argument against the recommendation. Both
+are recorded below rather than filtered out.
+
+**The verifier must do all of this, in this order.** This is the list
+that makes option 1 defensible; without it, option 1 is the bad version
+of itself.
+
+1. **Bound and parse.** Cap the token size. Require exactly three
+ non-empty segments and strict unpadded base64url. Require JSON
+ objects with no duplicate keys, no trailing data, no case-insensitive
+ field aliases.
+2. **Constrain the header.** Require exactly `alg: "EdDSA"`, a bounded
+ non-empty `kid`, and a dedicated `typ` for this assertion. Reject
+ `crit`, `b64`, and any token-supplied key or key URL.
+3. **Resolve a trusted key.** Only from the configured home's HTTPS key
+ endpoint, never from the token. Require an Ed25519 public key of the
+ right length. Refetch once on an unknown `kid`, then reject; coalesce
+ and rate-limit those refetches so an unknown `kid` is not a way to
+ make every verifier hammer the home.
+4. **Verify over the ORIGINAL bytes.** `ed25519.Verify` against the
+ encoded `header.payload` exactly as received. Never decode and
+ re-serialise before verifying — that is how a signature ends up
+ covering something other than what was read.
+5. **Validate the claims.** Correct types for `sub`, `iss`, `aud`,
+ `iat`, `exp`, `jti`. Exact match on issuer and on this instance's own
+ app+host audience. Integer timestamps, `0 < exp-iat <= 120`, `exp` in
+ the future, `iat` not beyond a small explicit skew. An unknown enum
+ value becomes absent; a malformed integer hint is ignored and never
+ grants anything.
+6. **Consume before minting.** Atomically insert `(iss, jti)` and reject
+ on conflict. Retain through `exp` **plus the accepted skew**, not
+ just `exp`. Survive restarts. A storage failure is a rejection, never
+ a pass.
+
+**And the thing it says is most commonly forgotten: login CSRF.** A
+valid, single-use assertion does not by itself stop an attacker fixing
+their own assertion onto a victim's browser. The receiving endpoint must
+bind the exchange to the browser's own login transaction. §5.6 already
+requires exactly that ("a browser-bound state nonce"), so this is
+independent confirmation of the spec rather than a new requirement —
+and it is the third time this same attack has come up in this project,
+after the magic link and the keymail callback.
+
+**The third option, recorded because it was not considered:** a shared
+policy package backed by a JWT library. Every app imports one package
+that exposes only this protocol, so configuration stays in one place;
+the dependency is still in every binary, but it is configured once
+rather than four times. Strictly better than option 2 as stated.
+
+**The strongest argument against the recommendation, in its own words:**
+maintenance concentration. One person plus agents owns every parsing
+edge case and every future protocol change, and a single shared verifier
+distributes one mistake across the whole suite. A maintained library
+brings outside scrutiny. Its condition was explicit — **if independent
+security review and adversarial testing will not happen, take the
+library-backed shared package instead.** On this project review does
+happen and has repeatedly caught exactly this class of defect, which is
+the argument for option 1 rather than against it; but the condition is
+the thing to check, not the conclusion.
+
### Ruling B: what a "consumer" is, concretely
§5.6 requires return hosts validated against a consumers allowlist,