-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathauth.ts
More file actions
106 lines (98 loc) · 5.29 KB
/
Copy pathauth.ts
File metadata and controls
106 lines (98 loc) · 5.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import { timingSafeEqual } from 'crypto';
import { isProduction } from './env';
// Public dev fallback. Lives here, in tests, and in CLAUDE.md — anyone reading
// the repo can grep for it. Safe only when the deployment overrides it.
const DEV_FALLBACK_KEY = 'hf_dev_key_2026';
// Resolved from env, then validated. Two failure modes the validator catches:
// (1) `HIGHFIVE_API_KEY` is set (case-insensitively) to the literal dev
// fallback. The fallback is a public string by design — documented in
// CLAUDE.md, used by the entire backend test suite — so an env value
// of `hf_dev_key_2026`, `HF_DEV_KEY_2026`, or any case-mixed variant
// was almost certainly a copy-paste from `.env.example` rather than
// a deliberate strong secret. Comparison is case-insensitive on the
// env-value side; the canonical fallback stays lowercase.
// (2) `isProduction()` is true but `HIGHFIVE_API_KEY` is empty/unset,
// which would silently activate the dev fallback as the production
// admin gate. CLAUDE.md flags this as a "do NOT violate" rule; this
// code makes the rule self-enforcing instead of relying on operator
// vigilance. `isProduction()` (see `env.ts`) normalises `NODE_ENV`
// across casing and trailing-whitespace typos so `"Production"`,
// `"PRODUCTION"`, and `"production "` all activate the guard.
//
// Tests run under `NODE_ENV=test` (vitest's default) and don't set the env
// var, so they take the fallback branch cleanly.
//
// `||` (not `??`) is load-bearing here: `''.trim() === ''`, and we want a
// whitespace-only env value to coerce to `undefined` so the second guard
// sees "key unset" rather than "key set to empty string". `??` would
// preserve the empty string and the case-insensitive dev-key compare on
// the next line would silently pass (empty !== `hf_dev_key_2026`), then
// the production guard would not fire because `ENV_KEY !== undefined`.
// The asymmetry with the `??` on the API_KEY assignment below is
// intentional and not a style nit — there `??` is correct because the
// inputs are already known-non-empty (or undefined).
const ENV_KEY = process.env.HIGHFIVE_API_KEY?.trim() || undefined;
// Both guards `throw` independently. A deployment that hits both
// failure modes (e.g. `NODE_ENV=production` AND `HIGHFIVE_API_KEY` set
// to the dev fallback) will trip this first guard and never see the
// second. That's "fail fast on first problem" — the right behaviour
// for a startup gate, but worth flagging because a future "log all
// problems" refactor would need to convert the throws into accumulated
// errors first.
if (ENV_KEY !== undefined && ENV_KEY.toLowerCase() === DEV_FALLBACK_KEY) {
throw new Error(
`HIGHFIVE_API_KEY is set (case-insensitively) to the public dev ` +
`fallback '${DEV_FALLBACK_KEY}'. Either leave HIGHFIVE_API_KEY unset ` +
`(the dev fallback applies for local development only) or set it to ` +
`a strong production value. See CLAUDE.md "Critical rules" and the ` +
`repo root .env.production.example.`,
);
}
if (isProduction() && ENV_KEY === undefined) {
throw new Error(
'HIGHFIVE_API_KEY must be set when NODE_ENV=production. Refusing to ' +
'start backend with the public dev fallback as the admin gate. See ' +
'CLAUDE.md "Critical rules" and the repo root .env.production.example.',
);
}
const API_KEY = ENV_KEY ?? DEV_FALLBACK_KEY;
// Constant-time compare for symmetric secrets. Strict-inequality short-circuits
// on the first differing byte, leaking how many leading bytes of the submitted
// value matched the configured key — exploitable over thousands of probes from
// a network position with low jitter. timingSafeEqual is the standard fix.
//
// Length mismatch returns false before calling timingSafeEqual: Node's
// timingSafeEqual throws on length mismatch, and we don't want to surface
// that as a 500 from the auth middleware. The length-mismatch branch leaks
// the configured secret's length to a probing attacker — for the single
// fixed-length deployment key the project uses, that's a recoverable
// constant rather than ongoing leakage. The byte content (the part that
// actually carries the entropy) is what the constant-time compare protects.
// Acceptable tradeoff for the threat model in ADR-003.
function constantTimeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a, 'utf8');
const bBuf = Buffer.from(b, 'utf8');
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}
/**
* Verify a submitted key against the configured API_KEY in constant time.
*
* Single exported boundary for the secret-compare — both the API-key middleware
* in this file and the admin-gate inline in `app.ts` route through here, so
* changes to the compare semantics propagate to both gates by construction.
*/
export function verifyApiKey(provided: string): boolean {
return constantTimeEqual(provided, API_KEY);
}
/**
* The resolved admin secret. Two callers, both server-side:
* - `session.ts`'s `requireAdmin` / login compares submitted credentials
* against it via `verifyApiKey`,
* - `session.ts` keys the session-token HMAC on it (`getApiKey()`), so a
* rotation invalidates outstanding sessions.
* It is never sent to the browser (issue #142 / ADR-019).
*/
export function getApiKey(): string {
return API_KEY;
}