SoCal, the whole bay, a moon, and gates that actually run
gates / clean-clone (push) Successful in 15s
gates / zero-config-boot (push) Successful in 9s
gates / no-binary-art (push) Successful in 4s

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
+2
View File
@@ -19,6 +19,7 @@ import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts";
import type { ErrorBody } from "../../src/server/wire.ts";
@@ -46,6 +47,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerWeather(app, services);
registerMarkers(app, services);
registerOffices(app, services);
registerSession(app, services);
app.setNotFoundHandler(async (_req, reply) => {
const body: ErrorBody = { error: "not_found", message: "No such route." };
+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;
}
+114 -3
View File
@@ -18,6 +18,7 @@
*/
import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import type {
AuthMode,
FlightsSourceId,
@@ -58,6 +59,23 @@ export interface MarkersConfig {
ttlSeconds: number;
}
/**
* The one account `TERA_AUTH_MODE=password` will sign in, once the environment
* has been read and found to contain a usable credential. `null` everywhere
* else, and `routes/session.ts` reads that null as "this box cannot sign anyone
* in" and answers 404 on the login endpoint.
*/
export interface PasswordLogin {
username: string;
/** Parsed at boot so a typo in the hash is a health line, not a login that never works. */
hash: ScryptHash;
/** Lifetime of the token this box issues for itself, in seconds. */
sessionTtlSeconds: number;
/** Failed attempts one IP may make inside `rateWindowSeconds` before 429s start. */
rateAttempts: number;
rateWindowSeconds: number;
}
export interface AuthConfig {
mode: AuthMode;
/** Where a browser sends someone to sign in. `sso` mode only. */
@@ -73,6 +91,8 @@ export interface AuthConfig {
jwksUrl: string;
issuer: string;
audience: string;
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
passwordLogin: PasswordLogin | null;
}
export interface Config {
@@ -243,11 +263,17 @@ function loadMarkers(env: Env, degraded: string[]): MarkersConfig {
};
}
const AUTH_MODES: AuthMode[] = ["none", "sso", "jwt"];
/**
* `password` is a mode an operator asks for, not a mode the rest of the server
* ever sees. It resolves to `jwt` below; see `loadPasswordLogin` for why.
*/
type ConfiguredAuthMode = AuthMode | "password";
const AUTH_MODES: ConfiguredAuthMode[] = ["none", "sso", "jwt", "password"];
function loadAuth(env: Env, degraded: string[]): AuthConfig {
const asked = str(env, "TERA_AUTH_MODE", "none");
let mode = oneOf(asked, AUTH_MODES);
let mode: ConfiguredAuthMode | null = oneOf(asked, AUTH_MODES);
if (mode === null) {
degraded.push(
`TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` +
@@ -260,7 +286,8 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", "");
const jwtVerify = str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
let jwtVerify: "hs256" | "jwks" =
str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";
// A demotion here has teeth: it takes private offices with it, which is the
// safe direction. Unverifiable credentials must never mean "let them in".
@@ -287,6 +314,33 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
mode = "none";
}
// `password` collapses into `jwt` here, and that is the whole design rather
// than an implementation detail. `routes/session.ts` signs exactly the token
// `jwt` mode already verifies, so a browser that signed in on this box and a
// browser carrying a token from the fleet's issuer arrive at `offices.ts`
// through the same `resolve()` — one authorisation path that can be reasoned
// about, rather than two that have to be kept in agreement. What `password`
// adds is an issuer, not a verifier.
let passwordLogin: PasswordLogin | null = null;
if (mode === "password") {
passwordLogin = loadPasswordLogin(env, jwtSecret, degraded);
if (passwordLogin === null) {
mode = "none";
} else {
mode = "jwt";
if (jwtVerify === "jwks") {
// Verifying against someone else's public keys while signing with a
// local secret means this box would reject its own sessions.
degraded.push(
"TERA_AUTH_MODE=password issues its own HS256 tokens, so " +
"TERA_AUTH_JWT_VERIFY=jwks was ignored; this box verifies the " +
"sessions it signs.",
);
jwtVerify = "hs256";
}
}
}
return {
mode,
entryUrl,
@@ -297,6 +351,63 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
jwksUrl,
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
passwordLogin,
};
}
/**
* The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
* the environment did not supply a usable one.
*
* Every failure here is a demotion to `mode=none` rather than a refusal to boot,
* which is the rule the whole file follows — but note which direction the
* demotion runs: mode=none makes every private office answer 404 to everybody,
* including the operator. Nobody is let in by a misconfiguration.
*
* `TERA_AUTH_PASSWORD_HASH` is a hash and there is deliberately no plaintext
* equivalent to set. `auth/password.ts` carries the command that produces one.
*/
function loadPasswordLogin(
env: Env,
jwtSecret: string,
degraded: string[],
): PasswordLogin | null {
const username = str(env, "TERA_AUTH_PASSWORD_USER", "");
const encoded = str(env, "TERA_AUTH_PASSWORD_HASH", "");
if (username === "" || encoded === "") {
degraded.push(
"TERA_AUTH_MODE=password needs TERA_AUTH_PASSWORD_USER and " +
"TERA_AUTH_PASSWORD_HASH (a scrypt hash — see server/src/auth/password.ts " +
"for the one-liner that prints one). Demoted to mode=none; private " +
"offices will answer 404 to everyone.",
);
return null;
}
if (jwtSecret === "") {
degraded.push(
"TERA_AUTH_MODE=password needs TERA_AUTH_JWT_SECRET to sign the session " +
"cookie it issues — try `openssl rand -base64 48`. Demoted to mode=none.",
);
return null;
}
const hash = parseScryptHash(encoded);
if (hash === null) {
degraded.push(
"TERA_AUTH_PASSWORD_HASH is not a hash this server can read; it should " +
"look like `scrypt$16384$8$1$<salt>$<key>`. Demoted to mode=none.",
);
return null;
}
return {
username,
hash,
sessionTtlSeconds: num(env, "TERA_AUTH_SESSION_TTL", 43_200, degraded),
rateAttempts: num(env, "TERA_AUTH_LOGIN_ATTEMPTS", 8, degraded),
rateWindowSeconds: num(env, "TERA_AUTH_LOGIN_WINDOW", 300, degraded),
};
}
+215
View File
@@ -0,0 +1,215 @@
/**
* `/api/v1/session` — the only place this box issues a credential rather than
* checking one.
*
* It exists because neither committed mode lets a human sign in: `none` is open
* and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password`
* fills that gap for a self-hoster with no identity provider, and it fills it by
* signing the same HS256 token `jwt` mode verifies and putting it in a cookie.
* `offices.ts` is unchanged and unaware; there is still exactly one code path
* that decides whether a private office exists for you.
*
* Three deliberate refusals to be helpful, all of them about not answering
* questions an anonymous caller should not get answers to:
*
* - A wrong username and a wrong password produce the same status, the same body
* and the same amount of work. `credentialsMatch` in `auth/password.ts` is
* where that is enforced.
* - When this box cannot sign anyone in, `POST` answers **404 with the body an
* unrouted path gets**, not "password login is disabled". Whether an operator
* configured a local account is not public information.
* - A private office still answers 404 rather than 403, before and after signing
* in. That rule lives in `offices.ts` and nothing here weakens it.
*/
import type { FastifyInstance } from "fastify";
import {
clearedSessionCookie,
issueSessionToken,
sessionCookie,
} from "../auth/index.ts";
import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
/**
* What the client gets to know. `passwordLogin` is the field the UI gates on:
* it says a login form would do something here, which is not a secret — the
* login page is served to anyone — while `authenticated` says whether this
* particular caller needs one.
*/
export interface SessionBody {
authenticated: boolean;
/** The signed-in subject, or `null`. Never a token. */
subject: string | null;
/** Whether `POST` to this endpoint can sign somebody in on this deployment. */
passwordLogin: boolean;
}
/** Byte-identical to what the not-found handler serves for a path with no route. */
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." };
/**
* One message for every rejected credential. It says nothing about which half
* was wrong, and the same object is sent for a username that does not exist.
*/
const REJECTED: ErrorBody = {
error: "unauthorized",
message: "Those credentials were not accepted.",
};
/**
* `unauthorized` rather than a token of its own because the error union in
* `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry
* the distinction, and a client that only reads `error` treats a rate-limited
* attempt as a failed one, which is the right thing for it to do.
*/
const RATE_LIMITED: ErrorBody = {
error: "unauthorized",
message: "Too many sign-in attempts. Try again shortly.",
};
const MAX_USERNAME_LENGTH = 256;
export function registerSession(app: FastifyInstance, services: Services): void {
const { auth } = services.config;
// Per app instance rather than per module, so two servers in one process —
// which is what the tests are — cannot share a limiter.
const limiter = createLimiter(
auth.passwordLogin?.rateAttempts ?? 8,
auth.passwordLogin?.rateWindowSeconds ?? 300,
);
app.get("/api/v1/session", async (req) => {
const viewer = await services.auth.resolve(req);
return body(viewer.authenticated, viewer.subject, auth.passwordLogin !== null);
});
app.post("/api/v1/session", async (req, reply) => {
const login = auth.passwordLogin;
if (login === null) return reply.code(404).send(NOT_FOUND);
const retryAfter = limiter.check(req.ip);
if (retryAfter !== null) {
// Before reading the body and before hashing anything: the point of the
// limit is that a flood of attempts costs the box nothing.
return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED);
}
const supplied = credentials(req.body);
if (supplied === null) {
// A malformed body is a failed attempt, not a 400. Answering differently
// would let an attacker probe the endpoint without spending attempts.
limiter.fail(req.ip);
return reply.code(401).send(REJECTED);
}
const ok = await credentialsMatch(
login.username,
login.hash,
supplied.username,
supplied.password,
);
if (!ok) {
limiter.fail(req.ip);
return reply.code(401).send(REJECTED);
}
limiter.succeed(req.ip);
const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds);
reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds));
return body(true, login.username, true);
});
// Signing out is available in every mode, including the ones where this box
// never issued the cookie. Refusing to clear a cookie tells the caller
// something about how the deployment is configured and helps nobody.
app.delete("/api/v1/session", async (_req, reply) => {
reply.header("set-cookie", clearedSessionCookie(auth));
return body(false, null, auth.passwordLogin !== null);
});
}
function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody {
return { authenticated, subject, passwordLogin };
}
/**
* Read the two fields, or decide there was nothing to read.
*
* Length caps are here rather than at the hash: a request body is up to a
* megabyte and there is no reason to hand any of it to a key-derivation
* function on an unauthenticated route.
*/
function credentials(raw: unknown): { username: string; password: string } | null {
if (raw === null || typeof raw !== "object") return null;
const { username, password } = raw as { username?: unknown; password?: unknown };
if (typeof username !== "string" || typeof password !== "string") return null;
if (username === "" || password === "") return null;
if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null;
return { username, password };
}
// ---- Rate limiting --------------------------------------------------------
interface Bucket {
failures: number;
windowEndsAt: number;
}
/**
* A fixed window of failures per client address, held in memory.
*
* In memory because there is one process and one box (CONTRACT.md §5) and a
* Redis to lose is worse than a limiter that resets on deploy. Keyed on
* `req.ip`, which is the real client because Caddy is the only thing that can
* reach the socket and `trustProxy` is on — nothing else can forge the header.
*
* Only failures count. A working session should not be able to lock its own
* owner out by reloading, and an attacker who is already succeeding is not
* someone a rate limit is going to help with.
*/
function createLimiter(attempts: number, windowSeconds: number) {
const buckets = new Map<string, Bucket>();
const limit = Math.max(1, Math.floor(attempts));
const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000;
/** Bounded so a flood from many addresses cannot grow the map without limit. */
const MAX_TRACKED = 4096;
function sweep(now: number): void {
for (const [key, bucket] of buckets) {
if (bucket.windowEndsAt <= now) buckets.delete(key);
}
// Still full after dropping the expired ones: that is a flood rather than
// accumulation, and clearing is the right trade. The worst case is that some
// attackers get their attempts back; the alternative is unbounded memory.
if (buckets.size >= MAX_TRACKED) buckets.clear();
}
return {
/** Seconds the caller should wait, or `null` when it may try now. */
check(ip: string): number | null {
const now = Date.now();
const bucket = buckets.get(ip);
if (bucket === undefined || bucket.windowEndsAt <= now) return null;
if (bucket.failures < limit) return null;
return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000));
},
fail(ip: string): void {
const now = Date.now();
if (buckets.size >= MAX_TRACKED) sweep(now);
const bucket = buckets.get(ip);
if (bucket === undefined || bucket.windowEndsAt <= now) {
buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs });
return;
}
bucket.failures += 1;
},
succeed(ip: string): void {
buckets.delete(ip);
},
};
}
+307
View File
@@ -0,0 +1,307 @@
/**
* Signing in, and the things signing in must not reveal.
*
* The positive case is one test; the rest of this file is about the negatives,
* because those are the ones that fail quietly in production. A wrong username
* and a wrong password have to be the same event byte for byte. A box with no
* local account has to look like a box with no such route. And a private office
* has to go back to not existing the moment the cookie is cleared — a logout
* that only hides the UI is not a logout.
*
* Follows `offices.test.ts`: a temp directory of office packs, `buildApp` with a
* config built from a fake environment, and `inject()` rather than a socket.
*/
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { hashPassword, parseScryptHash, verifyPassword } from "../auth/password.ts";
import type { SessionBody } from "../routes/session.ts";
const SECRET = "not-a-real-secret-and-never-was";
const USER = "karti";
const PASSWORD = "correct horse battery staple";
/**
* Produced by the exact command documented in `auth/password.ts`, pasted
* verbatim, for the password above. It is a fixture rather than a call to
* `hashPassword` so that the shell an operator is told to run and the parser
* this server ships cannot drift apart without this test going red.
*/
const HASH_FROM_THE_DOCUMENTED_COMMAND =
"scrypt$16384$8$1$Ns_--HDodVjObBpWzIp5TQ$_2BLRqJI14VRzr2iA-3OqR-Z1X-bVrezZYqmaU477xc";
/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */
const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] };
let dir = "";
before(async () => {
dir = await mkdtemp(join(tmpdir(), "tera-session-"));
await writeFile(
join(dir, "open.json"),
JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }),
);
await writeFile(
join(dir, "closed.json"),
JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }),
);
});
const passwordEnv = {
TERA_AUTH_MODE: "password",
TERA_AUTH_PASSWORD_USER: USER,
TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
TERA_AUTH_JWT_SECRET: SECRET,
};
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env });
config.logLevel = "silent";
return buildApp(config);
}
function login(app: ReturnType<typeof buildApp>, username: string, password: string) {
return app.inject({
method: "POST",
url: "/api/v1/session",
payload: { username, password },
});
}
/** The `Set-Cookie` value, reduced to what a browser would send back. */
function cookiePair(setCookie: unknown): string {
const header = Array.isArray(setCookie) ? String(setCookie[0]) : String(setCookie);
return header.split(";")[0] ?? "";
}
describe("the login endpoint", () => {
it("accepts the right credentials and sets a locked-down cookie", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
const res = await login(app, USER, PASSWORD);
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json<SessionBody>(), {
authenticated: true,
subject: USER,
passwordLogin: true,
});
const header = String(res.headers["set-cookie"]);
assert.match(header, /^tera_session=[^;]+;/);
assert.match(header, /HttpOnly/);
assert.match(header, /Secure/);
assert.match(header, /SameSite=Lax/);
assert.match(header, /Path=\//);
// Whatever else it is, it must never be a body a shared cache would keep.
assert.equal(res.headers["cache-control"], "private, no-store");
});
it("rejects a wrong password and an unknown user identically", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
const wrongPassword = await login(app, USER, "not the password");
const unknownUser = await login(app, "someone-else", PASSWORD);
assert.equal(wrongPassword.statusCode, 401);
assert.equal(unknownUser.statusCode, 401);
assert.deepEqual(wrongPassword.json(), unknownUser.json());
// Nothing that could be used to tell the two apart, including a cookie that
// was set and immediately cleared.
assert.equal(wrongPassword.headers["set-cookie"], undefined);
assert.equal(unknownUser.headers["set-cookie"], undefined);
});
it("treats a malformed body as a failed attempt rather than a hint", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
for (const payload of [{}, { username: USER }, { username: 1, password: 2 }]) {
const res = await app.inject({ method: "POST", url: "/api/v1/session", payload });
assert.equal(res.statusCode, 401);
}
});
it("stops answering after too many failures from one address", async () => {
const app = appWith({ ...passwordEnv, TERA_AUTH_LOGIN_ATTEMPTS: "3" });
after(() => app.close());
for (let i = 0; i < 3; i += 1) {
assert.equal((await login(app, USER, "wrong")).statusCode, 401);
}
const limited = await login(app, USER, "wrong");
assert.equal(limited.statusCode, 429);
assert.ok(Number(limited.headers["retry-after"]) > 0);
// And the limit is not a way past the password: the right credentials are
// refused too while the window is open.
assert.equal((await login(app, USER, PASSWORD)).statusCode, 429);
});
});
describe("a session cookie and a private office", () => {
it("opens the private office, then closes again once the session is cleared", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
const before = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
assert.equal(before.statusCode, 404);
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const opened = await app.inject({
method: "GET",
url: "/api/v1/offices/closed",
headers: { cookie },
});
assert.equal(opened.statusCode, 200);
assert.equal(opened.headers["cache-control"], "private, no-store");
const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
assert.equal(out.statusCode, 200);
assert.equal(out.json<SessionBody>().authenticated, false);
// The browser is told to drop it, and the same attributes are repeated so
// that the replacement actually matches the cookie it is replacing.
const cleared = String(out.headers["set-cookie"]);
assert.match(cleared, /^tera_session=;/);
assert.match(cleared, /Max-Age=0/);
assert.match(cleared, /HttpOnly/);
assert.match(cleared, /Secure/);
// A browser that dropped the cookie is a browser with no session, and the
// office goes back to not existing.
const after_ = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
assert.equal(after_.statusCode, 404);
});
it("reports the session state for the client to gate its UI", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
const anonymous = await app.inject({ method: "GET", url: "/api/v1/session" });
assert.deepEqual(anonymous.json<SessionBody>(), {
authenticated: false,
subject: null,
passwordLogin: true,
});
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const signedIn = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
assert.deepEqual(signedIn.json<SessionBody>(), {
authenticated: true,
subject: USER,
passwordLogin: true,
});
});
it("still keeps the token out of reach of a script", async () => {
const app = appWith(passwordEnv);
after(() => app.close());
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
// The session body is what a page can read. It must not contain the bearer
// token the cookie is carrying.
assert.equal(state.body.includes(cookie.split("=")[1] ?? "never"), false);
});
});
describe("deployments that cannot sign anyone in", () => {
it("leaves mode=none open and offers no login", async () => {
const app = appWith({});
after(() => app.close());
assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/open" })).statusCode, 200);
const state = await app.inject({ method: "GET", url: "/api/v1/session" });
assert.deepEqual(state.json<SessionBody>(), {
authenticated: false,
subject: null,
passwordLogin: false,
});
// Not "login is disabled" — the same 404 an unrouted path gets.
const attempt = await login(app, USER, PASSWORD);
const nowhere = await app.inject({ method: "POST", url: "/api/v1/no-such-thing" });
assert.equal(attempt.statusCode, 404);
assert.deepEqual(attempt.json(), nowhere.json());
});
it("demotes to mode=none when the hash is unreadable, and says so", async () => {
const config = loadConfig({
TERA_OFFICES_DIR: dir,
...passwordEnv,
TERA_AUTH_PASSWORD_HASH: "scrypt$notanumber$8$1$aaaa$bbbb",
});
config.logLevel = "silent";
const app = buildApp(config);
after(() => app.close());
assert.equal(config.auth.mode, "none");
assert.equal(config.auth.passwordLogin, null);
assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_PASSWORD_HASH")));
// The demotion runs towards closed: nobody gets the private office.
assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/closed" })).statusCode, 404);
assert.equal((await login(app, USER, PASSWORD)).statusCode, 404);
});
it("demotes when there is no secret to sign a session with", async () => {
const config = loadConfig({
TERA_AUTH_MODE: "password",
TERA_AUTH_PASSWORD_USER: USER,
TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
});
assert.equal(config.auth.mode, "none");
assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_JWT_SECRET")));
});
it("resolves password mode to jwt so there is one authorisation path", async () => {
const config = loadConfig(passwordEnv);
assert.equal(config.auth.mode, "jwt");
assert.equal(config.auth.jwtVerify, "hs256");
assert.equal(config.auth.passwordLogin?.username, USER);
});
});
describe("the hash format", () => {
it("reads what the documented one-liner writes", async () => {
const parsed = parseScryptHash(HASH_FROM_THE_DOCUMENTED_COMMAND);
assert.notEqual(parsed, null);
assert.equal(parsed?.n, 16384);
assert.equal(parsed?.r, 8);
assert.equal(parsed?.p, 1);
assert.equal(await verifyPassword(parsed!, PASSWORD), true);
assert.equal(await verifyPassword(parsed!, "close but no"), false);
});
it("round-trips a freshly generated hash, with a different salt each time", async () => {
const first = await hashPassword(PASSWORD);
const second = await hashPassword(PASSWORD);
assert.notEqual(first, second);
const parsed = parseScryptHash(first);
assert.notEqual(parsed, null);
assert.equal(await verifyPassword(parsed!, PASSWORD), true);
});
it("refuses anything it cannot verify against", () => {
for (const bad of [
"",
"hunter2",
"bcrypt$16384$8$1$aaaa$bbbb",
"scrypt$16384$8$1$aaaa",
"scrypt$16383$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // N not a power of two
"scrypt$1073741824$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // absurd memory
"scrypt$16384$8$1$not base64!$aaaaaaaaaaaaaaaaaaaaaaaa",
"scrypt$16384$8$1$c21d5m2wtoFIu4-rXO6mXA$aa", // key too short to be a key
]) {
assert.equal(parseScryptHash(bad), null, `${bad} must not parse`);
}
});
});