local-auth-bootstrap.mjs
345 lines 10.3 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Phase 8 P1b-b — first-admin bootstrap: setup token + CLI (§4).
3 */
4
5 import fs from 'fs';
6 import path from 'path';
7 import crypto from 'crypto';
8 import { createLocalCredential, loadCredentialStore, credentialStoreHasAdmin } from './local-auth.mjs';
9 import { validatePassphraseStrength } from './breached-passwords.mjs';
10 import { appendLocalAuthAudit } from './local-auth-audit.mjs';
11
12 export const BOOTSTRAP_FILE = 'hub_local_bootstrap.json';
13 export const BOOTSTRAP_CONSUMED_FILE = 'hub_local_bootstrap_consumed.json';
14
15 /** Fixed decoy SHA-256 for timing-safe unknown-token verify (§7.4). */
16 const DECOY_TOKEN_HASH = crypto.createHash('sha256').update('decoy-bootstrap-token').digest('hex');
17
18 /**
19 * @param {string} dataDir
20 * @returns {Set<string>}
21 */
22 function loadConsumedTokenHashes(dataDir) {
23 const filePath = path.join(dataDir, BOOTSTRAP_CONSUMED_FILE);
24 if (!fs.existsSync(filePath)) return new Set();
25 try {
26 const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
27 const arr = Array.isArray(data.consumedHashes) ? data.consumedHashes : [];
28 return new Set(arr);
29 } catch (_) {
30 return new Set();
31 }
32 }
33
34 /**
35 * @param {string} dataDir
36 * @param {string} tokenHash
37 */
38 function markTokenHashConsumed(dataDir, tokenHash) {
39 const consumed = loadConsumedTokenHashes(dataDir);
40 consumed.add(tokenHash);
41 const filePath = path.join(dataDir, BOOTSTRAP_CONSUMED_FILE);
42 fs.writeFileSync(filePath, JSON.stringify({ consumedHashes: [...consumed] }, null, 2), 'utf8');
43 fs.chmodSync(filePath, 0o600);
44 }
45
46 /**
47 * @param {string} dataDir
48 * @returns {string}
49 */
50 export function bootstrapPath(dataDir) {
51 return path.join(dataDir, BOOTSTRAP_FILE);
52 }
53
54 /**
55 * @param {string} token
56 * @returns {string}
57 */
58 export function hashSetupToken(token) {
59 return crypto.createHash('sha256').update(String(token)).digest('hex');
60 }
61
62 /**
63 * Timing-safe token hash compare.
64 * @param {string} presented
65 * @param {string} storedHash
66 * @returns {boolean}
67 */
68 export function verifySetupTokenHash(presented, storedHash) {
69 const a = Buffer.from(hashSetupToken(presented || ''), 'hex');
70 const b = Buffer.from(storedHash || DECOY_TOKEN_HASH, 'hex');
71 if (a.length !== b.length) {
72 const decoy = Buffer.from(DECOY_TOKEN_HASH, 'hex');
73 crypto.timingSafeEqual(decoy, decoy);
74 return false;
75 }
76 return crypto.timingSafeEqual(a, b);
77 }
78
79 /**
80 * @param {string} dataDir
81 * @returns {object|null}
82 */
83 export function loadBootstrapRecord(dataDir) {
84 const filePath = bootstrapPath(dataDir);
85 if (!fs.existsSync(filePath)) return null;
86 try {
87 return JSON.parse(fs.readFileSync(filePath, 'utf8'));
88 } catch (_) {
89 return null;
90 }
91 }
92
93 /**
94 * @param {string} dataDir
95 * @param {object} record
96 */
97 export function saveBootstrapRecord(dataDir, record) {
98 const filePath = bootstrapPath(dataDir);
99 const dir = path.dirname(filePath);
100 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
101 fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
102 fs.chmodSync(filePath, 0o600);
103 }
104
105 /**
106 * Prune expired bootstrap file at boot (§4.1).
107 * @param {string} dataDir
108 */
109 export function pruneExpiredBootstrapRecord(dataDir) {
110 const rec = loadBootstrapRecord(dataDir);
111 if (!rec || !rec.expiresAt) return;
112 if (new Date(rec.expiresAt).getTime() < Date.now()) {
113 try {
114 fs.unlinkSync(bootstrapPath(dataDir));
115 } catch (_) {
116 /* ignore */
117 }
118 }
119 }
120
121 /**
122 * Parse expires-in like 15m, 1h.
123 * @param {string} raw
124 * @returns {number} ms
125 */
126 export function parseExpiresInMs(raw) {
127 const s = String(raw || '15m').trim();
128 const m = s.match(/^(\d+)(m|h)$/i);
129 if (!m) throw new Error('expires-in must be like 15m or 1h');
130 const n = parseInt(m[1], 10);
131 const unit = m[2].toLowerCase();
132 const ms = unit === 'h' ? n * 60 * 60 * 1000 : n * 60 * 1000;
133 const maxMs = 60 * 60 * 1000;
134 if (ms > maxMs) throw new Error('expires-in capped at 60 minutes');
135 if (ms < 60 * 1000) throw new Error('expires-in minimum 1m');
136 return ms;
137 }
138
139 /**
140 * Generate setup token (Mechanism A, §4.1).
141 * @param {string} dataDir
142 * @param {string} username
143 * @param {string} expiresIn - e.g. 15m
144 * @returns {{ token: string, bootstrapTokenId: string }}
145 */
146 export function generateSetupToken(dataDir, username, expiresIn = '15m') {
147 if (credentialStoreHasAdmin(dataDir)) {
148 throw new Error('ALREADY_BOOTSTRAPPED');
149 }
150 const ms = parseExpiresInMs(expiresIn);
151 const token = crypto.randomBytes(32).toString('base64url');
152 const tokenHash = hashSetupToken(token);
153 const bootstrapTokenId = crypto.randomBytes(8).toString('hex');
154 const record = {
155 username: username.normalize('NFC').trim(),
156 tokenHash,
157 expiresAt: new Date(Date.now() + ms).toISOString(),
158 consumed: false,
159 bootstrapTokenId,
160 };
161 saveBootstrapRecord(dataDir, record);
162 return { token, bootstrapTokenId };
163 }
164
165 /** In-process bootstrap mutex (contention safety, §9 stress). */
166 let bootstrapConsumeChain = Promise.resolve();
167
168 /**
169 * @template T
170 * @param {() => Promise<T>} fn
171 * @returns {Promise<T>}
172 */
173 function withBootstrapMutex(fn) {
174 const run = bootstrapConsumeChain.then(fn, fn);
175 bootstrapConsumeChain = run.then(
176 () => {},
177 () => {}
178 );
179 return run;
180 }
181
182 /** Bootstrap attempt rate limit (§7.4). */
183 const bootstrapAttempts = { count: 0, windowStart: Date.now() };
184 const BOOTSTRAP_ATTEMPT_LIMIT = 5;
185 const BOOTSTRAP_WINDOW_MS = 15 * 60 * 1000;
186
187 /**
188 * @returns {boolean}
189 */
190 export function checkBootstrapRateLimit() {
191 if (
192 process.env.NODE_ENV === 'test' &&
193 process.env.KNOWTATION_OFFLINE_LOCKED_AUTH_TEST_NO_BOOTSTRAP_RL === '1'
194 ) {
195 return true;
196 }
197 const now = Date.now();
198 if (now - bootstrapAttempts.windowStart > BOOTSTRAP_WINDOW_MS) {
199 bootstrapAttempts.count = 0;
200 bootstrapAttempts.windowStart = now;
201 }
202 if (bootstrapAttempts.count >= BOOTSTRAP_ATTEMPT_LIMIT) return false;
203 bootstrapAttempts.count += 1;
204 return true;
205 }
206
207 /** Reset bootstrap rate limit (tests). */
208 export function resetBootstrapRateLimitForTests() {
209 bootstrapAttempts.count = 0;
210 bootstrapAttempts.windowStart = Date.now();
211 }
212
213 /**
214 * Consume setup token via bootstrap route (Mechanism A).
215 * @param {string} dataDir
216 * @param {string} setupToken
217 * @param {string} username
218 * @param {string} passphrase
219 * @param {{ ip?: string, ua?: string }} auditMeta
220 * @returns {Promise<{ ok: true, userId: string } | { ok: false, code: string }>}
221 */
222 export async function consumeSetupToken(dataDir, setupToken, username, passphrase, auditMeta = {}) {
223 return withBootstrapMutex(async () => {
224 if (!checkBootstrapRateLimit()) {
225 return { ok: false, code: 'RATE_LIMITED' };
226 }
227
228 const presentedHash = hashSetupToken(setupToken);
229 const consumedHashes = loadConsumedTokenHashes(dataDir);
230 if (consumedHashes.has(presentedHash)) {
231 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_rejected', {
232 reason: 'consumed',
233 ip: auditMeta.ip || 'unknown',
234 ts: new Date().toISOString(),
235 });
236 return { ok: false, code: 'BOOTSTRAP_TOKEN_CONSUMED' };
237 }
238
239 if (credentialStoreHasAdmin(dataDir)) {
240 return { ok: false, code: 'ALREADY_BOOTSTRAPPED' };
241 }
242 const strength = validatePassphraseStrength(passphrase);
243 if (!strength.ok) {
244 return { ok: false, code: strength.code || 'WEAK_PASSPHRASE' };
245 }
246
247 const record = loadBootstrapRecord(dataDir);
248 if (!record) {
249 verifySetupTokenHash(setupToken, DECOY_TOKEN_HASH);
250 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_rejected', {
251 reason: 'unknown_token',
252 ip: auditMeta.ip || 'unknown',
253 ts: new Date().toISOString(),
254 });
255 return { ok: false, code: 'BOOTSTRAP_TOKEN_EXPIRED' };
256 }
257 if (record.consumed) {
258 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_rejected', {
259 reason: 'consumed',
260 ip: auditMeta.ip || 'unknown',
261 ts: new Date().toISOString(),
262 });
263 return { ok: false, code: 'BOOTSTRAP_TOKEN_CONSUMED' };
264 }
265 if (new Date(record.expiresAt).getTime() < Date.now()) {
266 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_rejected', {
267 reason: 'expired',
268 ip: auditMeta.ip || 'unknown',
269 ts: new Date().toISOString(),
270 });
271 return { ok: false, code: 'BOOTSTRAP_TOKEN_EXPIRED' };
272 }
273 if (!verifySetupTokenHash(setupToken, record.tokenHash)) {
274 verifySetupTokenHash(setupToken, DECOY_TOKEN_HASH);
275 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_rejected', {
276 reason: 'invalid_token',
277 ip: auditMeta.ip || 'unknown',
278 ts: new Date().toISOString(),
279 });
280 return { ok: false, code: 'BOOTSTRAP_TOKEN_EXPIRED' };
281 }
282
283 const normalizedReq = username.normalize('NFC').trim();
284 const normalizedRec = record.username;
285 if (normalizeUsernameCompare(normalizedReq, normalizedRec) === false) {
286 return { ok: false, code: 'BOOTSTRAP_TOKEN_EXPIRED' };
287 }
288
289 const { userId, sub } = await createLocalCredential(dataDir, username, passphrase, {
290 role: 'admin',
291 mustRotatePassphrase: true,
292 });
293
294 record.consumed = true;
295 markTokenHashConsumed(dataDir, record.tokenHash);
296 try {
297 fs.unlinkSync(bootstrapPath(dataDir));
298 } catch (_) {
299 saveBootstrapRecord(dataDir, { ...record, consumed: true });
300 }
301
302 appendLocalAuthAudit(dataDir, 'local_auth.bootstrap_consumed', {
303 sub,
304 username: record.username,
305 ip: auditMeta.ip || 'unknown',
306 ts: new Date().toISOString(),
307 bootstrapTokenId: record.bootstrapTokenId || 'unknown',
308 });
309
310 return { ok: true, userId };
311 });
312 }
313
314 function normalizeUsernameCompare(a, b) {
315 return a.normalize('NFC').trim().toLowerCase() === b.normalize('NFC').trim().toLowerCase();
316 }
317
318 /**
319 * CLI bootstrap-admin (Mechanism B, §4.2).
320 * @param {string} dataDir
321 * @param {string} username
322 * @param {string} passphrase
323 * @returns {Promise<{ ok: true, userId: string }>}
324 */
325 export async function bootstrapAdminCli(dataDir, username, passphrase) {
326 if (credentialStoreHasAdmin(dataDir)) {
327 throw new Error('ALREADY_BOOTSTRAPPED');
328 }
329 const strength = validatePassphraseStrength(passphrase);
330 if (!strength.ok) {
331 const err = new Error(strength.code || 'WEAK_PASSPHRASE');
332 err.code = strength.code;
333 throw err;
334 }
335 const { userId } = await createLocalCredential(dataDir, username, passphrase, { role: 'admin' });
336 return { ok: true, userId };
337 }
338
339 /**
340 * @param {string} dataDir
341 * @returns {boolean}
342 */
343 export function isBootstrapped(dataDir) {
344 return credentialStoreHasAdmin(dataDir) || Object.keys(loadCredentialStore(dataDir).credentials).length > 0;
345 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago