1
0

SoCal, the whole bay, a moon, and gates that actually run

Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 03:13:32 -07:00
parent 8bcb391455
commit 44c5a79424
25 changed files with 9246 additions and 220 deletions
+70 -2
View File
@@ -13,13 +13,20 @@
* - **`jwt`** verifies a token here, for a deployment that would rather not make
* an outbound call per request. See `jwt.ts` for why HS256 is the primary path.
*
* Enforcement is on the server in all three. A viewer object that says
* `TERA_AUTH_MODE=password` is a fourth thing an operator can write and
* deliberately **not** a fourth branch below: it resolves to `jwt` in
* `config.ts`, and the only difference is that this box also signs the token
* itself, in `issueSessionToken`. A self-hoster with nowhere to get a token from
* needed a way to sign in; giving them a second authorisation path to audit was
* not worth it, so they get the same one with a local issuer attached.
*
* Enforcement is on the server in all of them. A viewer object that says
* `authenticated: false` is the only thing a route ever sees, and the route
* answers 404 — never 403 — so the endpoint cannot be used to enumerate what
* exists.
*/
import { createHash } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import type { FastifyRequest } from "fastify";
import type { AuthConfig } from "../config.ts";
import { verifyJwt } from "./jwt.ts";
@@ -87,6 +94,67 @@ export function createAuth(config: AuthConfig): AuthService {
};
}
// ---- Issuing a session ----------------------------------------------------
/**
* Sign the session token `resolve()` will later verify.
*
* Signing and verification live in the same directory on purpose: the claims
* this writes are exactly the claims `jwt.ts` checks, including the `iss` and
* `aud` an operator configured, and a token that this server issues but cannot
* accept is the failure mode worth designing against. `alg` is pinned to HS256
* to match `verifyHs256`, which will not accept anything else.
*/
export function issueSessionToken(
config: AuthConfig,
subject: string,
ttlSeconds: number,
): string {
const now = Math.floor(Date.now() / 1000);
const claims: Record<string, unknown> = { sub: subject, iat: now, exp: now + ttlSeconds };
if (config.issuer !== "") claims.iss = config.issuer;
if (config.audience !== "") claims.aud = config.audience;
const signed = `${base64url({ alg: "HS256", typ: "JWT" })}.${base64url(claims)}`;
const signature = createHmac("sha256", config.jwtSecret).update(signed).digest("base64url");
return `${signed}.${signature}`;
}
/**
* The `Set-Cookie` value for a signed-in browser.
*
* `HttpOnly` because no script has any business reading a bearer token — it is
* the difference between an XSS bug that defaces a page and one that walks off
* with a session. `Secure` unconditionally, including in development: browsers
* treat `localhost` and `127.0.0.1` as trustworthy origins and will store a
* Secure cookie there, so there is no dev exemption to add and therefore no dev
* exemption to accidentally ship. `SameSite=Lax` because nothing here is a
* cross-site POST, and `Lax` is what stops another origin's form from acting as
* the signed-in user.
*/
export function sessionCookie(config: AuthConfig, token: string, ttlSeconds: number): string {
return cookie(config.cookieName, encodeURIComponent(token), Math.max(0, Math.floor(ttlSeconds)));
}
/**
* Clearing has to repeat every attribute that was set. A browser matches a
* replacement cookie on name, domain and path, so dropping `Path=/` here would
* leave the original in place and sign nobody out.
*/
export function clearedSessionCookie(config: AuthConfig): string {
return cookie(config.cookieName, "", 0);
}
function cookie(name: string, value: string, maxAge: number): string {
return `${name}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`;
}
function base64url(value: unknown): string {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
// ---- Reading a session ----------------------------------------------------
function bearerToken(req: FastifyRequest): string | null {
const header = req.headers.authorization;
if (typeof header !== "string") return null;
+206
View File
@@ -0,0 +1,206 @@
/**
* Credential verification for `TERA_AUTH_MODE=password`.
*
* The rule this file exists to enforce is that **no plaintext password ever
* comes out of the environment**. `TERA_AUTH_PASSWORD_HASH` carries a scrypt
* digest and the parameters it was derived under, so an operator who leaks their
* unit file, their `docker inspect` output or their shell history has leaked a
* hash and not an account. A `TERA_AUTH_PASSWORD` that this module would happily
* compare against is the version of this feature that must never exist.
*
* scrypt is `node:crypto`'s, which is why there is no new dependency: it is
* memory-hard, it ships in the runtime, and the alternative is pulling argon2 or
* bcrypt — a native build — into a repo whose acceptance test is `npm ci` on a
* stranger's laptop.
*
* ## Generating a hash
*
* There is no script to run and nothing to install. Paste this, type the
* password, press ctrl-D, and put the line it prints in
* `TERA_AUTH_PASSWORD_HASH`:
*
* ```sh
* node -e 'const c=require("node:crypto");let p="";process.stdin.setEncoding("utf8").on("data",d=>p+=d).on("end",()=>{const s=c.randomBytes(16),k=c.scryptSync(p.replace(/\n$/,""),s,32,{N:16384,r:8,p:1,maxmem:67108864});console.log(`scrypt$16384$8$1$${s.toString("base64url")}$${k.toString("base64url")}`)})'
* ```
*
* It reads stdin rather than taking the password as an argument because an
* argument lands in shell history and in anybody's `ps` output. A trailing
* newline is stripped so that typing the password and pressing return produces
* the same hash as piping it in.
*
* `session.test.ts` pins the output of exactly that command against the parser
* below, so the two cannot drift apart without a test failing.
*/
import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
/** A parsed `TERA_AUTH_PASSWORD_HASH`, with the parameters it was made under. */
export interface ScryptHash {
n: number;
r: number;
p: number;
salt: Buffer;
key: Buffer;
}
/**
* Interactive-login parameters as of 2026: ~16 MiB and a few tens of
* milliseconds. High enough that a leaked hash is not a wordlist away from a
* password, low enough that the login endpoint is not itself a way to burn the
* box's CPU. The chosen values are recorded *in* the hash, so raising them later
* does not invalidate the hashes already in operators' unit files.
*/
const DEFAULT_PARAMS = { n: 16384, r: 8, p: 1 } as const;
const SALT_BYTES = 16;
const KEY_BYTES = 32;
/**
* An upper bound on what a hash string is allowed to ask scrypt to allocate.
* `TERA_AUTH_PASSWORD_HASH` is operator input rather than attacker input, but a
* fat-fingered `N` should be a boot-time demotion and not a box that allocates
* sixteen gigabytes on the first login attempt.
*/
const MAX_MEMORY_BYTES = 256 * 1024 * 1024;
/**
* Longer than this and we do not even hash it. scrypt's cost is independent of
* the password's length, so this is not about work — it is about not copying a
* megabyte of request body into a key-derivation function on an unauthenticated
* endpoint.
*/
export const MAX_PASSWORD_LENGTH = 256;
export function parseScryptHash(encoded: string): ScryptHash | null {
const parts = encoded.split("$");
if (parts.length !== 6) return null;
const [scheme, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts;
if (scheme !== "scrypt") return null;
if (nRaw === undefined || rRaw === undefined || pRaw === undefined) return null;
if (saltRaw === undefined || keyRaw === undefined) return null;
const n = positiveInt(nRaw);
const r = positiveInt(rRaw);
const p = positiveInt(pRaw);
if (n === null || r === null || p === null) return null;
// scrypt requires N to be a power of two greater than one, and rejects
// anything else at call time. Catching it here turns a runtime throw on the
// login path into one line in `/api/v1/health`.
if (n < 2 || (n & (n - 1)) !== 0) return null;
if (memoryFor(n, r, p) > MAX_MEMORY_BYTES) return null;
const salt = fromBase64Url(saltRaw);
const key = fromBase64Url(keyRaw);
if (salt === null || key === null) return null;
if (salt.length < 8 || key.length < 16) return null;
return { n, r, p, salt, key };
}
export function formatScryptHash(hash: ScryptHash): string {
const salt = hash.salt.toString("base64url");
const key = hash.key.toString("base64url");
return `scrypt$${hash.n}$${hash.r}$${hash.p}$${salt}$${key}`;
}
/** Used by the tests and available to anyone who would rather not paste shell. */
export async function hashPassword(plaintext: string): Promise<string> {
const { n, r, p } = DEFAULT_PARAMS;
const salt = randomBytes(SALT_BYTES);
const key = await derive(plaintext, salt, KEY_BYTES, { n, r, p });
if (key === null) throw new Error("scrypt failed");
return formatScryptHash({ n, r, p, salt, key });
}
/**
* Derive and compare in constant time.
*
* The comparison is `timingSafeEqual` over two buffers that are the same length
* by construction — the derived key's length is taken from the stored key — so
* there is no early exit for an attacker to measure. The caller must still call
* this even when it already knows the username is wrong; see `credentialsMatch`.
*/
export async function verifyPassword(hash: ScryptHash, supplied: string): Promise<boolean> {
if (supplied.length > MAX_PASSWORD_LENGTH) return false;
const derived = await derive(supplied, hash.salt, hash.key.length, hash);
if (derived === null) return false;
return timingSafeEqual(derived, hash.key);
}
/**
* The whole credential check, and the reason it is one function rather than two
* calls at the route: **a wrong username and a wrong password must be the same
* event**. Both branches derive a key, both take the same path, and the two
* answers are combined only at the end. An implementation that returns early on
* an unknown username answers in a millisecond instead of fifty and hands an
* attacker a user-enumeration oracle for free.
*/
export async function credentialsMatch(
expectedUsername: string,
hash: ScryptHash,
suppliedUsername: string,
suppliedPassword: string,
): Promise<boolean> {
const usernameOk = timingSafeStringEqual(expectedUsername, suppliedUsername);
const passwordOk = await verifyPassword(hash, suppliedPassword);
return usernameOk && passwordOk;
}
/**
* Compare two strings of unequal length without leaking the length.
*
* `timingSafeEqual` throws when the buffers differ in size, which would make the
* *shape* of the failure depend on the input. Hashing both first gives two
* 32-byte buffers whatever arrived, and a username is not secret enough for the
* extra digest to be worth avoiding.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
const left = createHash("sha256").update(a, "utf8").digest();
const right = createHash("sha256").update(b, "utf8").digest();
return timingSafeEqual(left, right);
}
// ---- Internals ------------------------------------------------------------
function derive(
plaintext: string,
salt: Buffer,
length: number,
params: { n: number; r: number; p: number },
): Promise<Buffer | null> {
const { n, r, p } = params;
return new Promise((resolve) => {
// The callback form, not `scryptSync`: this runs on a login request, and
// fifty milliseconds of synchronous key derivation is fifty milliseconds in
// which the one server answers nobody else.
scrypt(
plaintext,
salt,
length,
{ N: n, r, p, maxmem: Math.max(32 * 1024 * 1024, memoryFor(n, r, p) * 2) },
(err, derived) => resolve(err === null ? derived : null),
);
});
}
/** scrypt's working set, which is what `maxmem` is checked against. */
function memoryFor(n: number, r: number, p: number): number {
return 128 * n * r * p;
}
function positiveInt(raw: string): number | null {
if (!/^[0-9]{1,10}$/.test(raw)) return null;
const value = Number(raw);
return value > 0 ? value : null;
}
/**
* `Buffer.from(s, "base64url")` never fails — it stops at the first character it
* cannot use and returns what it had. Round-tripping is the only way to know the
* string was really base64url, and a hash with a typo in it must be a boot-time
* demotion rather than a password that can never be right.
*/
function fromBase64Url(raw: string): Buffer | null {
if (raw === "") return null;
const decoded = Buffer.from(raw, "base64url");
return decoded.toString("base64url") === raw ? decoded : null;
}