Skip to content

fix: sign every request instead of sending the server token; no local lockout (review D-01, D-02) - #35

Merged
HackTuah merged 9 commits into
mainfrom
fix/sec-d-auth
Oct 7, 2026
Merged

HackTuah merged 9 commits into
mainfrom
fix/sec-d-auth

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 7, 2026

Copy link
Copy Markdown
Member

Fixes D-01 and D-02 from the fourth security reviewer on v1.0.0-rc.2 (slice 54, SCR-476). D-01 reopened medium finding B-01.

D-01: the token never leaves the browser. On rc.2, the extension proved the server once per worker life and then sent Bearer <token>. If the server stopped and another local account took the port, the next sync gave that account the token (the lead reproduced it: 3 requests carried the real token).

  • The extension now signs each request instead of sending the token: Authorization: Kotiko-HMAC v1 ts=…, nonce=…, body=<sha256>, mac=…. The server accepts a signature only within 120 s of its clock, refuses a nonce it has already seen, and checks the body hash. Bearer still works for curl.
  • Every answer is signed too (X-Kotiko-Server). The extension uses an answer only when the signature checks out. Otherwise it clears its proof and sends nothing until a fresh proof succeeds. Any write (non-GET) needs a proof younger than 30 s.
  • Lead replay of reviewer D's own d-b01.mjs against a live server from this branch, killing the server and letting a squatter take both loopbacks: squatter log lines: 4; with the real token: 0. A fresh connect to the squatter is refused (server_key_rejected). The branch's e2e test also shows that a word added after the server dies never reaches the squatter.

D-02: no local program or web page can lock the extension out.

  • The proof route refuses anything that isn't application/json (415) or isn't a well-formed nonce, before counting it.
  • Loopback requests without a forwarding header are exempt from the proof limit and the wrong-token lockout.
  • Loopback requests through a reverse proxy (any of Forwarded, X-Forwarded-For, X-Real-IP …) are counted together in one bucket, so internet clients behind a local proxy stay limited.

Docs: http-api.md (the scheme with test vectors), configuration, requirements, the assurance case (residual risks stated exactly), ARCHITECTURE, and the privacy policy, now version 4 (the access-key paragraph). Update the server and the extension together: an older server turns signed requests away, and the extension says to update it.

Checks on the merged branch: lint; npm test 2060 pass; coverage 94.37 % lines, 85.02 % branches; perf; Playwright 114 passed (loopback, fullstack and security rerun by the lead: 15/15); mix test 1187 tests and 23 properties, 0 failures, 91.65 %; credo strict; Sobelow; privacy and spec sync; REUSE; DCO.

🤖 Generated with Claude Code

Ayla Croft and others added 7 commits October 7, 2026 12:29
…(D-02)

Slice 54, finding D-02 (reviewer D, rc.2). Every program on the computer, and
any web page open in a browser there, reaches the server from 127.0.0.1. A
page could send 35 no-cors POSTs to /api/v1/proof (no Content-Type, so they
reached the counter) and use up the 30-a-minute proof limit, and any local
program could send 10 wrong tokens and lock 127.0.0.1 out, the extension
included.

The proof route now refuses a request without Content-Type: application/json
(415) and a malformed nonce (400) before counting, so only well-formed proofs
count. This computer's own addresses (127.0.0.0/8, ::1, IPv4-mapped 127.x)
are exempt from the proof limit and the wrong-token lockout; other addresses
keep both. A generated token can't be guessed, and a weak chosen one is
already warned about at start.

Regression tests that fail without this: 31 malformed proofs then a JSON one
gets 200; 10 wrong tokens from 127.0.0.1, ::1 or ::ffff:127.0.0.1 then the
right one gets 200; a non-loopback address is still locked out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Slice 54, finding D-01 (reviewer D, rc.2; it reopened B-01). The extension
proved the server once per worker life and then sent the token with every
request, so after the server stopped and another account took its port, the
next sync handed that program the token.

The server now accepts a request signed with the token instead of carrying
it: Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac=, where mac is
HMAC-SHA256(token, "kotiko-req-v1\n" METHOD "\n" path?query "\n" ts "\n"
nonce "\n" sha256-hex(body)). It compares the MAC in constant time, refuses a
time more than 120 s off or from before the server started, a nonce it has
seen (kept in ETS for the window, only after the MAC matched, at most 50,000;
when full it refuses new signed requests rather than forget a live nonce),
and a body whose hash isn't the signed one (read and checked before
Plug.Parsers, which gets the same bytes through a caching body reader). A
failed signature counts towards the lockout like a wrong Bearer token. Every
answer to an accepted request, errors included, carries X-Kotiko-Server:
v1 mac=HMAC(token, "kotiko-resp-v1\n" nonce "\n" status), so the client can
tell its server's answers from anyone else's. A refused one says why in its
challenge (Bearer, Kotiko-HMAC error="..."). Bearer stays for curl and tools.

Tests: the documented vectors, a valid request and its signed answer, wrong
MAC, token, path, method and query, stale and future times and one from
before the start, a replayed nonce, a tampered body (nothing saved), a full
nonce table, malformed headers, the lockout, signed 404/413/415/400, and
every route the routers define. docs/reference/http-api.md describes the
scheme with its test vectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Slice 54, finding D-01 (reviewer D, rc.2). With the proof cached for the
worker's life, a program that took the server's port after a crash or a
restart got the real token with the next sync (the lead reproduced three
requests carrying it) and, with an add, the learner's word.

The extension no longer sends the token anywhere. lib/server-auth.js signs
every request to the server with it (WebCrypto HMAC-SHA256 over the method,
the path and query as the server sees them, the time, 16 random bytes and
the body's SHA-256) and checks the server's X-Kotiko-Server signature on
every answer before reading it. An answer without it, or with a wrong one,
is never used: the server reads as not_kotiko_server, the proof is
forgotten, and nothing more is sent until a new proof succeeds, which a
squatter can't give. Anything but a GET (adds, edits, imports, deletes, the
profile) waits for a proof under 30 s old. The proof is also forgotten on
server.connect, an address or token change and any connection error. A
server from before signed requests answers them 401 with a plain Bearer
challenge, and the extension asks to update it; today's server refusing a
time more than two minutes off says so ("clock").

Regression tests that fail without this: loopback.spec.mjs replays D-01 in
Chromium (prove and sync, stop the server, a listener takes 127.0.0.1 and
::1 at its port, sync and add a word): before, the listener logged
"GET /kotiko/api/words auth=Bearer <token>" and the word's POST with the
token; now only the signed sync and a proof it couldn't give. Background
tests cover signing, the 30 s proof, forgetting the proof, and answers that
can't be trusted; unit tests check the documented vectors against a
separate node:crypto implementation. The test servers sign their answers.
The privacy policy (version 4) and inventory say the token isn't sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…case (D-01, D-02)

Slice 54, findings D-01 and D-02. The assurance case now says the token never
leaves the browser and what is left: a program that takes the port after the
server stops gets no token and can't answer for the server; it can read the
body of a write only if it took the port within 30 s of the last proof and
before any other request found it out, and can play nothing to the server
later. It also says what a signature doesn't do (no encryption; the answer's
signature covers the status, not the body; clocks must agree within two
minutes) and that a weak chosen token can be tried without limit from this
computer now that loopback is exempt from the lockout. The security
requirements, configuration (API_TOKEN), architecture and best-practices
answers follow, and CHANGELOG says it in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
… 20 ms

Slice 54, D-01 made each request a little slower (it is signed, and its
answer's signature checked), and the llmStatus test's fixed 20 ms sleep
failed once under coverage. It now waits, up to 5 s, for the status the add
refreshes, as docs/CODE_REVIEW.md asks of tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
The loopback exemption from the D-02 fix also exempted everyone behind a
reverse proxy on the same machine (Caddy or nginx in front of the server for
remote access): every remote client arrives from 127.0.0.1, so strangers on
the internet got unthrottled guesses at a weak chosen token, re-opening B-09
there.

A loopback request is now exempt only when it carries no forwarding header
(Forwarded, X-Forwarded-For, X-Real-IP, X-Forwarded-Host, CF-Connecting-IP,
True-Client-IP; any case, any value). One that carries one counts towards
the proof limit and the wrong-token lockout under {:proxied, peer}, shared by
all proxied requests: the header's claimed client address can be forged, so
it isn't used. The extension sends no forwarding header, so a local program
that adds one to its guesses locks out only the proxied requests, never the
extension (RateLimit.peer/1).

Regression tests that fail without this: 10 wrong tokens from 127.0.0.1 with
x-forwarded-for, then the 11th proxied request (with any of the six headers,
or the right token) gets 429 while the right token from 127.0.0.1 without
one gets 200; and three proofs through the proxy, then the fourth gets 429
while the extension's proofs keep getting 200. The HTTP API reference,
configuration, requirements and assurance case say exactly this, and the
residual risk now names the shared proxied lockout instead of unlimited
guesses through a proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Ayla Croft and others added 2 commits October 7, 2026 13:09
… task

The engine swaps in 8 ms slices, so on a busy machine a test's last block
could swap a slice after load() returned, and the assertion saw the old
text: "a tabindex=0 chip…" failed CI this way on #36, a PR that only
changes .gitleaks.toml. load() now waits until the page has had no change
for 50 ms (at most 3 s). Under heavy load (128 parallel runs of the file),
failing runs went from 30 to 8; the 8 left are one older test whose
proper-noun check has a time limit of its own (recorded on SCR-764).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
@HackTuah
HackTuah merged commit a981bc2 into main Oct 7, 2026
14 checks passed
@HackTuah
HackTuah deleted the fix/sec-d-auth branch October 7, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant