Repository navigation
Expand file tree
/
Copy pathwriteAuth.ts
More file actions
339 lines (310 loc) · 13.4 KB
/
Copy pathwriteAuth.ts
File metadata and controls
339 lines (310 loc) · 13.4 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
/**
* Shared-key ("pre-shared key") authorization for the app's privileged endpoints.
*
* This app is driven by *devices* — the Proclaim Mac, the audio feeder, a couple of
* editor browsers/tablets — not by named users, so authorization is a short list of
* shared keys rather than logins. Readers need no key at all: viewer traffic
* (read-only Y-Sweet tokens, TTS, listener LiveKit tokens) stays deliberately open.
*
* Rollout has three modes, chosen by `WRITE_AUTH_MODE`:
*
* off No checking, no logging. Local dev, and the automatic mode when no keys
* are configured at all.
* observe Check and record the outcome, but always allow (the default). This is how
* a key rollout starts: clients ship the key, and a week of logs shows that
* every real device presents a valid one *before* anything can be locked out.
* enforce Unauthorized privileged requests are refused.
*
* Keys come from `WRITE_KEYS`, a comma-separated list whose entries are either `key`
* or `label:key`. The label is only ever used in logs and telemetry — it answers
* "which device is this?", so a rotation can be watched per device rather than as one
* anonymous total. Rotating is editing that list and restarting: keys stay valid until
* removed, so old and new can overlap while devices are updated one at a time.
*
* Deliberately dependency-free (no express, no PostHog) so it is trivially testable;
* the express glue and the telemetry sink are injected by server.ts.
*/
import { createHash, timingSafeEqual } from 'crypto';
export type WriteAuthMode = 'off' | 'observe' | 'enforce';
/** One configured key. `label` is for logging only and is never compared. */
export interface WriteKeyEntry {
label: string;
key: string;
}
/** Why a privileged request was (or would have been) allowed or refused. */
export type WriteAuthStatus = 'ok' | 'missing' | 'invalid';
export interface WriteAuthResult {
status: WriteAuthStatus;
/** Label of the matched key, or null when nothing matched. */
label: string | null;
}
/**
* Whatever can be said about who asked, for a request that presented no usable key.
*
* Not identity — there is none to be had here. This exists because the observe window's
* whole job is answering "which device is still missing a key", and a record saying only
* that *something* was unauthorized cannot answer it.
*/
export interface WriteAuthClient {
ip: string | null;
/** Truncated: enough to tell a Mac service from an iPad, not a tracking record. */
userAgent: string | null;
/**
* Short digest of a presented-but-unrecognized key. Set only for `invalid` — an `ok`
* request has a label, and a `missing` one presented nothing to digest. This is what
* distinguishes "still holding last month's booth key" from "random junk", which
* during a rotation is the entire question.
*/
keyFingerprint: string | null;
}
/** The audit record emitted for every privileged request, in every non-`off` mode. */
export interface WriteAuthAudit extends WriteAuthResult {
mode: WriteAuthMode;
route: string;
/** True when the request was actually refused (enforce mode + a bad key). */
refused: boolean;
client: WriteAuthClient;
}
/**
* Just enough of an express Request to read headers from. `ip` is express's, which
* behind a reverse proxy is the proxy's address unless `trust proxy` is configured —
* treat it as a hint for chasing down a device, never as an access control input.
*/
export interface HeaderBearingRequest {
headers: Record<string, string | string[] | undefined>;
ip?: string;
}
/** Header clients use to present their key. `Authorization: Bearer <key>` also works. */
export const WRITE_KEY_HEADER = 'x-write-key';
/**
* Keys shorter than this are almost certainly a placeholder rather than a real secret.
* Warned about at boot, never rejected — refusing to start over a short key would be a
* worse failure than running with one.
*/
const MIN_REASONABLE_KEY_LENGTH = 16;
/**
* Parse `WRITE_KEYS`. Entries are `label:key` or a bare `key`; blanks and entries with
* an empty key are dropped, so a trailing comma or a stray `label:` can't silently
* install a key that matches the empty string.
*/
export function parseWriteKeys(raw: string | undefined): WriteKeyEntry[] {
if (!raw) return [];
const entries: WriteKeyEntry[] = [];
for (const chunk of raw.split(',')) {
const trimmed = chunk.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf(':');
const label = separator === -1 ? '' : trimmed.slice(0, separator).trim();
const key = separator === -1 ? trimmed : trimmed.slice(separator + 1).trim();
if (!key) continue;
entries.push({ label: label || `key${entries.length + 1}`, key });
}
return entries;
}
export function parseWriteAuthMode(raw: string | undefined): WriteAuthMode {
const value = (raw ?? '').trim().toLowerCase();
if (!value) return 'observe';
if (value === 'off' || value === 'observe' || value === 'enforce') return value;
// A typo here (`enfoce`) must never silently degrade to "allow everything", so this
// is fatal rather than a warning. Leaving the variable unset is the safe default.
throw new Error(
`WRITE_AUTH_MODE must be one of off|observe|enforce (got '${raw}')`,
);
}
export interface WriteAuthConfig {
mode: WriteAuthMode;
keys: WriteKeyEntry[];
/** Boot-time lines for the operator: effective mode, key labels, any warnings. */
notices: string[];
}
/**
* Resolve the effective configuration from the environment.
*
* Two safety rules live here. Configuring no keys drops to `off` rather than logging a
* miss for every request forever — an install that doesn't use this feature shouldn't
* pay for it. Asking for `enforce` with no keys throws, because it would lock out every
* device including the Proclaim service; that is a misconfiguration worth failing at
* boot for, when it is one env var away from being fixed, rather than mid-service.
*/
export function resolveWriteAuthConfig(env: {
WRITE_KEYS?: string;
WRITE_AUTH_MODE?: string;
}): WriteAuthConfig {
const requestedMode = parseWriteAuthMode(env.WRITE_AUTH_MODE);
const keys = parseWriteKeys(env.WRITE_KEYS);
const notices: string[] = [];
if (keys.length === 0) {
if (requestedMode === 'enforce') {
throw new Error(
'WRITE_AUTH_MODE=enforce requires at least one key in WRITE_KEYS; ' +
'starting would refuse every editor, the Proclaim service and the audio feeder.',
);
}
notices.push('[write-auth] no WRITE_KEYS configured — write authorization is off');
return { mode: 'off', keys, notices };
}
const short = keys.filter((entry) => entry.key.length < MIN_REASONABLE_KEY_LENGTH);
if (short.length > 0) {
notices.push(
`[write-auth] WARNING: short key(s) [${short.map((e) => e.label).join(', ')}] — ` +
`use at least ${MIN_REASONABLE_KEY_LENGTH} random characters`,
);
}
// Two labels sharing a key value is a plausible slip while hand-editing WRITE_KEYS
// during a rotation, and a quiet one: matching returns the *first* label, so the
// second device's traffic is filed under the first device's name. The label is the
// only audit trail there is, so it silently lying is worth a line at boot.
const firstLabelForKey = new Map<string, string>();
const collisions: string[] = [];
for (const entry of keys) {
const first = firstLabelForKey.get(entry.key);
if (first) collisions.push(`${entry.label} shares ${first}'s key`);
else firstLabelForKey.set(entry.key, entry.label);
}
if (collisions.length > 0) {
notices.push(
`[write-auth] WARNING: ${collisions.join('; ')} — audits report only the first ` +
`label, so those devices are indistinguishable in the logs`,
);
}
notices.push(
`[write-auth] mode=${requestedMode} keys=[${keys.map((e) => e.label).join(', ')}]`,
);
if (requestedMode === 'observe') {
notices.push(
'[write-auth] observe mode: unauthorized writes are RECORDED BUT ALLOWED. ' +
'Set WRITE_AUTH_MODE=enforce once the logs show every device presenting a key.',
);
}
return { mode: requestedMode, keys, notices };
}
/**
* Constant-time key comparison. Compares SHA-256 digests rather than the raw strings so
* that both sides are always the same length — `timingSafeEqual` throws on a length
* mismatch, and returning early on one would leak the key's length.
*/
function keysMatch(presented: string, configured: string): boolean {
const a = createHash('sha256').update(presented, 'utf8').digest();
const b = createHash('sha256').update(configured, 'utf8').digest();
return timingSafeEqual(a, b);
}
/**
* A short, stable digest of a key. Short on purpose: it is a label for "the key that was
* presented", not a credential, and it must be safe to put in a log and a chart.
*/
export function fingerprintKey(key: string): string {
return createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 8);
}
/** Truncated so a log line stays one line and the record stays a hint, not a profile. */
const MAX_LOGGED_USER_AGENT = 60;
function describeClient(
req: HeaderBearingRequest,
presented: string | null,
status: WriteAuthStatus,
): WriteAuthClient {
const uaHeader = req.headers['user-agent'];
const ua = Array.isArray(uaHeader) ? uaHeader[0] : uaHeader;
return {
ip: req.ip ?? null,
userAgent: ua ? ua.slice(0, MAX_LOGGED_USER_AGENT) : null,
keyFingerprint: status === 'invalid' && presented ? fingerprintKey(presented) : null,
};
}
/**
* Who to file an audit record under. `ok` is the device's label, which is the whole
* reason labels exist. The other two are the interesting cases: an unrecognized key
* groups by the key itself, so every device still holding last month's key lands in one
* bucket you can watch drain; a request with no key at all has nothing but its address.
*/
export function auditDistinctId(audit: WriteAuthAudit): string {
if (audit.status === 'ok') return audit.label ?? 'unknown-device';
if (audit.client.keyFingerprint) return `stale-key-${audit.client.keyFingerprint}`;
return audit.client.ip ? `no-key-${audit.client.ip}` : 'unknown-device';
}
/** Pull the presented key from `X-Write-Key`, or from `Authorization: Bearer <key>`. */
export function extractPresentedKey(req: HeaderBearingRequest): string | null {
const header = req.headers[WRITE_KEY_HEADER];
const direct = Array.isArray(header) ? header[0] : header;
if (direct && direct.trim()) return direct.trim();
const authHeader = req.headers['authorization'];
const auth = Array.isArray(authHeader) ? authHeader[0] : authHeader;
if (auth) {
const match = /^Bearer\s+(.+)$/i.exec(auth.trim());
if (match && match[1].trim()) return match[1].trim();
}
return null;
}
export class WriteAuth {
readonly mode: WriteAuthMode;
private readonly keys: WriteKeyEntry[];
private readonly onAudit: (audit: WriteAuthAudit) => void;
constructor(config: WriteAuthConfig, onAudit: (audit: WriteAuthAudit) => void = () => {}) {
this.mode = config.mode;
this.keys = config.keys;
this.onAudit = onAudit;
}
/** Does this request carry a valid key? No logging, no side effects. */
evaluate(req: HeaderBearingRequest): WriteAuthResult {
const presented = extractPresentedKey(req);
if (!presented) return { status: 'missing', label: null };
for (const entry of this.keys) {
if (keysMatch(presented, entry.key)) return { status: 'ok', label: entry.label };
}
return { status: 'invalid', label: null };
}
/**
* Evaluate a privileged request, record the outcome, and report whether it may
* proceed. In `observe` mode `allowed` is always true — that is the whole point of
* the mode — but the audit record still says what `enforce` would have done.
*/
check(req: HeaderBearingRequest, route: string): { result: WriteAuthResult; allowed: boolean } {
if (this.mode === 'off') {
return { result: { status: 'ok', label: null }, allowed: true };
}
const presented = extractPresentedKey(req);
const result = this.evaluate(req);
const allowed = this.mode !== 'enforce' || result.status === 'ok';
this.onAudit({
...result,
mode: this.mode,
route,
refused: !allowed,
client: describeClient(req, presented, result.status),
});
return { result, allowed };
}
/**
* `check()`, plus a 401 when the request is refused. Returns true if the caller
* should continue handling the request.
*/
gate(
req: HeaderBearingRequest,
res: { status(code: number): { json(body: unknown): unknown } },
route: string,
): boolean {
const { allowed } = this.check(req, route);
if (allowed) return true;
res.status(401).json({
ok: false,
error: 'This request needs a valid write key. See docs/WRITE_KEYS.md.',
});
return false;
}
}
/** Render an audit record as a single greppable log line. */
export function formatAudit(audit: WriteAuthAudit): string {
const key =
audit.status === 'ok'
? audit.label
: audit.status === 'missing'
? 'none'
: `unknown(${audit.client.keyFingerprint ?? '?'})`;
const from = audit.client.ip ? ` ip=${audit.client.ip}` : '';
const outcome =
audit.status === 'ok'
? 'ok'
: audit.refused
? `${audit.status.toUpperCase()} (refused)`
: `${audit.status.toUpperCase()} (allowed — observe mode)`;
return `[write-auth] ${audit.mode} ${audit.route} key=${key}${from} → ${outcome}`;
}