fix(billing): 7 accounts/billing review follow-ups#276
Conversation
Hardening pass over the B0–B9 accounts/billing stack, from the review follow-ups on #260/#261/#264/#265/#266/#267/#272. Billing correctness — a metered turn can no longer ship free: - meter: recordUsage always returns the priced record; the audit-log append is best-effort and decoupled from pricing/debit, so ENOSPC on usage.jsonl loses the audit line, not the debit (#264). - limits: readCounter distinguishes ENOENT (0, fine) from an I/O or parse failure, and the $200/day global cap now fails CLOSED instead of silently reading as $0 spent (#267). - gateway: a 2xx upstream that reports no usage is priced from a bytes/4 estimate rather than debiting 0 (#264). Availability + abuse: - Bounded every unauthenticated body read behind readCappedText — readJsonBody, the Stripe webhook raw read (signature needs the raw string), and the gateway (LISA_GW_MAX_BODY_MB, default 20 MiB). Past the cap: 413 + Connection: close (#260/#264/#266). - accounts: scrypt moves off the event loop; /register gains a per-IP sliding-window cap (20 / 10 min). Coarse backstop, not a security boundary — XFF is client-controlled (#260). Correctness under multi-instance + replay: - turn-lease: heartbeat a held lease at TTL/3 so a chat SSE turn running under --timeout 3600 can't outlive its 180s lease and let a peer instance double-run the account (#272). - SIWA: verify Apple's nonce claim against sha256(raw nonce) end to end (server, web, iOS). LISA_CLOUD_APPLE_REQUIRE_NONCE is default-OFF so already-shipped TestFlight builds keep working (#261). - IAP: pin Apple Root CA G3 by SHA-256 on both the cached and fetched paths, assert basicConstraints CA on issuers, and assert Apple's marker OIDs on the leaf and the WWDR intermediate — a signature chain alone never said what each cert was FOR (#265). Verified: typecheck + build clean; 1104 tests pass (15 new). The iOS companion type-checks against the iPhoneSimulator SDK with SIWA both on and off — compile-verified, but the nonce path has not been exercised against a real Apple sign-in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review — accounts/billing hardeningVerdict: strong work. Every one of the seven items closes a hole that was really there, the why is written down where the next reader needs it, and the tests aim at the failure modes rather than the happy path (the Three things I checked that could have been bugs and aren't, so nobody re-litigates them:
One genuine regression below, plus two things worth a decision. 🔴 1.
|
…R length Two review follow-ups on the hardening pass itself. 1. `ipRateOk` refused every NEW key once the bucket map hit GENERIC_MAX_KEYS. The key is `auth:<clientIp>` and clientIp reads a spoofable x-forwarded-for, so one client could mint 10k distinct keys and lock every subsequent IP out of /register + /login — turning a DoS backstop into a DoS amplifier. It now fails OPEN on saturation, which is the correct direction for something its own docstring calls "NOT a security boundary": scrypt cost, email uniqueness and the per-email throttle are the real guards and none of them consults this map. 2. `oidToDer` emitted `content.length` as a bare short-form DER length. Past 127 bytes that is invalid DER, so the needle could never match and the cert role check would silently always fail. Now throws instead of lying. Also documents what `certHasOid` actually proves (TLV present anywhere in the cert, not extnID) so the comment no longer claims more than the check. Tests: saturation-fails-open (10k-key flood, new IP still admitted, existing key still capped) and long-form-OID refusal. Full suite 1106 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correction + fixes pushed (
|
Review — 3 adversarial passes (billing money-paths · auth/DoS · IAP crypto)Reviewed across the three risk surfaces. Headline: the Apple IAP cert-chain + JWS validation is sound — no forgery path (G3 SHA-256 pinned and asserted on both the cache and fetch paths, real per-link signature verification up to the pinned root, ✅ Fixed on this branch (with tests)Lease double-run → double-charge (2e84ffa) — Login lockout bypass (2e84ffa) — Gateway body cap disabled by a mistyped env (2e84ffa) — Per-IP rate-limit DoS + oidToDer — addressed in your
|
|
M1 fixed in All 6 review findings now addressed (M1/M2/login-concurrency/gateway-NaN here; per-IP DoS + oidToDer in your |
Hardening pass over the B0–B9 accounts/billing stack — the seven review follow-ups from #260/#261/#264/#265/#266/#267/#272. Each was a way for the merged stack to lose money, stay up under load, or trust something it shouldn't.
Billing correctness — a metered turn can no longer ship free
recordUsagereturned null when the audit append failed ⇒ debit skipped ⇒ free, unrecoverable turnreadCounterreturned{microUSD:0}on any read error ⇒ the $200/day cap failed openok:false, cap fails closed, and the write path won't clobber an unreadable-but-valid counterdebitTurn(0)= free inferenceestimateUsageFromBytes(bytes/4). Non-2xx still settles at 0 — an upstream error isn't the user's billAvailability + abuse
for awaitaccumulate. NewreadCappedTextcapsreadJsonBody, the Stripe webhook raw read (signature is over the raw string, so it can't go through the JSON path), and the gateway (LISA_GW_MAX_BODY_MB, default 20 MiB — LLM payloads carry base64 images). Past the cap: 413 +Connection: close, without destroying the socket.scryptSyncon unauthenticated/register+/loginstalled every other request. Now async;/register(which had no throttle at all — the existing one is per-email, login-only) gets 20 / 10 min per IP. Documented in-code as a coarse backstop: behind Cloud Run the IP comes from a spoofable header, and scrypt cost + email uniqueness remain the real guards.Multi-instance + replay
TURN_TTL_MSis 180s but chat SSE runs under--timeout 3600, so withLISA_FIRESTORE=1andMAX_INSTANCES>1the lease expired mid-turn and a peer double-ran the account. AddedrenewLease(CAS) + a TTL/3 heartbeat that stops itself if ownership is lost, wired into both the chat and gateway paths.req.nonce, the server verified none. Now end-to-end (server, web, iOS), timing-safe againstsha256(rawNonce).LISA_CLOUD_APPLE_REQUIRE_NONCEis default-OFF so already-shipped TestFlight builds keep signing in; a nonce that is sent is always verified. Flip it on once clients update.ca === true, and Apple's marker OIDs are asserted on the leaf and the WWDR intermediate..cais used rather thankeyUsage, which reads backundefinedon Apple certs. Theopts.rootseam is preserved; an existing file atLISA_APPLE_ROOT_PATHstill skips the pin (documented air-gapped escape hatch).Verification
npm run typecheck+npm run buildclean. 1104 tests pass, 0 fail — 15 new:renewLease, cloudAuth nonce (accept / wrong / absent / unhashed), the IP window, cap fail-closed (unreadable + corrupt), meter append-failure,estimateUsageFromBytes,oidToDeragainst the two DER byte strings verified on real Apple certs, and a newhttp-body.test.ts(cap boundary, split multi-byte chars, stream error).The iOS companion type-checks against the iPhoneSimulator SDK with
LISA_ENABLE_SIWAboth on and off. That's compile-verified only — the nonce path has not been exercised against a real Apple sign-in, so the first TestFlight build wants a manual sign-in check beforeLISA_CLOUD_APPLE_REQUIRE_NONCEgoes on.Independent of #274 (per-tenant SSE isolation); both branch from
main.🤖 Generated with Claude Code