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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user