local-auth.mjs
417 lines 12.9 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Phase 8 P1b-b — local credential store, Argon2id verify, JWT issuance (§3, §5).
3 */
4
5 import fs from 'fs';
6 import path from 'path';
7 import crypto from 'crypto';
8 import argon2 from 'argon2';
9 import jwt from 'jsonwebtoken';
10 import { writeRolesFile, readRolesObject, loadRoleMap } from '../roles.mjs';
11 import { resolveLocalAuthRole } from './local-auth-role.mjs';
12
13 export const CREDENTIALS_FILE = 'hub_local_credentials.json';
14 export const LOGIN_ATTEMPTS_FILE = 'hub_local_login_attempts.json';
15
16 /** OWASP 2024 floor parameters (§3.2). */
17 export const ARGON2_PARAMS = Object.freeze({
18 type: argon2.argon2id,
19 memoryCost: 65536,
20 timeCost: 3,
21 parallelism: 4,
22 hashLength: 32,
23 });
24
25 /** Fixed decoy PHC for timing-safe unknown-username verify (§3.3). */
26 export const DECOY_ARGON2_PHC =
27 '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
28
29 const SCHEMA = 'knowtation.hub_local_credentials/v0';
30
31 /**
32 * Normalize username for lookup: NFC, trim, lowercase-fold for comparison (§3.1).
33 * @param {string} username
34 * @returns {string}
35 */
36 export function normalizeUsername(username) {
37 if (typeof username !== 'string') return '';
38 return username.normalize('NFC').trim().toLowerCase();
39 }
40
41 /**
42 * @param {string} dataDir
43 * @returns {string}
44 */
45 export function credentialsPath(dataDir) {
46 return path.join(dataDir, CREDENTIALS_FILE);
47 }
48
49 /**
50 * @param {string} dataDir
51 * @returns {string}
52 */
53 export function loginAttemptsPath(dataDir) {
54 return path.join(dataDir, LOGIN_ATTEMPTS_FILE);
55 }
56
57 /**
58 * @param {string} filePath
59 */
60 export function chmod0600(filePath) {
61 fs.chmodSync(filePath, 0o600);
62 }
63
64 /**
65 * @param {object} params
66 * @returns {boolean}
67 */
68 export function assertArgon2ParamsFloor(params) {
69 if (!params || typeof params !== 'object') return false;
70 if (params.timeCost != null && params.timeCost < 3) return false;
71 if (params.memoryCost != null && params.memoryCost < 65536) return false;
72 if (params.parallelism != null && params.parallelism !== 4) return false;
73 return true;
74 }
75
76 /**
77 * Parse PHC string and validate parameter floors.
78 * @param {string} phc
79 * @returns {{ ok: boolean, params?: { timeCost: number, memoryCost: number, parallelism: number } }}
80 */
81 export function parsePhcParams(phc) {
82 if (typeof phc !== 'string' || !phc.startsWith('$argon2id$')) return { ok: false };
83 const mMatch = phc.match(/\$m=(\d+),t=(\d+),p=(\d+)\$/);
84 if (!mMatch) return { ok: false };
85 const memoryCost = parseInt(mMatch[1], 10);
86 const timeCost = parseInt(mMatch[2], 10);
87 const parallelism = parseInt(mMatch[3], 10);
88 if (!assertArgon2ParamsFloor({ memoryCost, timeCost, parallelism })) return { ok: false };
89 return { ok: true, params: { memoryCost, timeCost, parallelism } };
90 }
91
92 /**
93 * @param {string} dataDir
94 * @returns {{ schema: string, credentials: Record<string, object>, userCounter: number }}
95 */
96 export function loadCredentialStore(dataDir) {
97 const filePath = credentialsPath(dataDir);
98 if (!fs.existsSync(filePath)) {
99 return { schema: SCHEMA, credentials: {}, userCounter: 0 };
100 }
101 const raw = fs.readFileSync(filePath, 'utf8');
102 const data = JSON.parse(raw);
103 return {
104 schema: data.schema || SCHEMA,
105 credentials: data.credentials && typeof data.credentials === 'object' ? data.credentials : {},
106 userCounter: Number.isFinite(data.userCounter) ? data.userCounter : 0,
107 };
108 }
109
110 /**
111 * @param {string} dataDir
112 * @param {{ schema: string, credentials: Record<string, object>, userCounter: number }} store
113 */
114 export function saveCredentialStore(dataDir, store) {
115 if (!dataDir) throw new Error('dataDir required');
116 const filePath = credentialsPath(dataDir);
117 const dir = path.dirname(filePath);
118 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
119 const payload = {
120 schema: SCHEMA,
121 credentials: store.credentials,
122 userCounter: store.userCounter,
123 };
124 fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
125 chmod0600(filePath);
126 }
127
128 /**
129 * @param {{ credentials: Record<string, object> }} store
130 * @param {string} normalizedUsername
131 * @returns {{ sub: string, cred: object } | null}
132 */
133 export function findCredentialByUsername(store, normalizedUsername) {
134 for (const [sub, cred] of Object.entries(store.credentials)) {
135 if (normalizeUsername(cred.username) === normalizedUsername) {
136 return { sub, cred };
137 }
138 }
139 return null;
140 }
141
142 /**
143 * @param {string} passphrase
144 * @returns {Promise<string>}
145 */
146 export async function hashPassphrase(passphrase) {
147 return argon2.hash(passphrase, {
148 ...ARGON2_PARAMS,
149 salt: crypto.randomBytes(16),
150 });
151 }
152
153 /**
154 * Timing-safe verify: dummy verify when username not found (§3.3).
155 * @param {string|null|undefined} phc
156 * @param {string} passphrase
157 * @returns {Promise<boolean>}
158 */
159 export async function verifyPassphrase(phc, passphrase) {
160 const target = phc || DECOY_ARGON2_PHC;
161 try {
162 return await argon2.verify(target, passphrase);
163 } catch (_) {
164 return false;
165 } finally {
166 if (typeof passphrase === 'string') {
167 passphrase.replace(/./g, '\0');
168 }
169 }
170 }
171
172 /**
173 * @param {object} credential
174 * @param {string} role
175 * @param {string} sessionSecret
176 * @param {string} jwtExpiry
177 * @returns {string}
178 */
179 export function issueLocalToken(credential, role, sessionSecret, jwtExpiry = '24h') {
180 const sub = `local:${credential.userId}`;
181 return jwt.sign(
182 {
183 sub,
184 provider: 'local',
185 id: credential.userId,
186 name: credential.username,
187 role,
188 },
189 sessionSecret,
190 { expiresIn: jwtExpiry }
191 );
192 }
193
194 /**
195 * Create a new local credential and optionally assign admin role.
196 * @param {string} dataDir
197 * @param {string} username
198 * @param {string} passphrase
199 * @param {{ role?: string, mustRotatePassphrase?: boolean }} [opts]
200 * @returns {Promise<{ userId: string, sub: string }>}
201 */
202 export async function createLocalCredential(dataDir, username, passphrase, opts = {}) {
203 const store = loadCredentialStore(dataDir);
204 const normalized = normalizeUsername(username);
205 if (findCredentialByUsername(store, normalized)) {
206 throw new Error('USERNAME_TAKEN');
207 }
208 store.userCounter += 1;
209 const finalUserId =
210 store.userCounter === 1 ? 'admin_001' : `user_${String(store.userCounter).padStart(3, '0')}`;
211 const sub = `local:${finalUserId}`;
212 const argon2id = await hashPassphrase(passphrase);
213 store.credentials[sub] = {
214 userId: finalUserId,
215 username: username.normalize('NFC').trim(),
216 argon2id,
217 createdAt: new Date().toISOString(),
218 mustRotatePassphrase: Boolean(opts.mustRotatePassphrase),
219 };
220 saveCredentialStore(dataDir, store);
221 const role = opts.role || 'admin';
222 const roles = readRolesObject(dataDir);
223 roles[sub] = role;
224 writeRolesFile(dataDir, roles);
225 return { userId: finalUserId, sub };
226 }
227
228 /**
229 * @param {string} dataDir
230 * @returns {boolean}
231 */
232 export function credentialStoreHasAdmin(dataDir) {
233 const store = loadCredentialStore(dataDir);
234 if (Object.keys(store.credentials).length === 0) return false;
235 const roleMap = loadRoleMap(dataDir);
236 for (const sub of Object.keys(store.credentials)) {
237 if (roleMap.get(sub) === 'admin') return true;
238 }
239 return false;
240 }
241
242 /**
243 * @param {string} dataDir
244 * @returns {boolean}
245 */
246 export function hasAnyCredential(dataDir) {
247 const store = loadCredentialStore(dataDir);
248 return Object.keys(store.credentials).length > 0;
249 }
250
251 /**
252 * @param {string} dataDir
253 * @returns {Record<string, { failures: number, firstFailureAt: string|null, lockedUntil: string|null }>}
254 */
255 export function loadLoginAttempts(dataDir) {
256 const filePath = loginAttemptsPath(dataDir);
257 if (!fs.existsSync(filePath)) return {};
258 try {
259 const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
260 return data && typeof data === 'object' ? data : {};
261 } catch (_) {
262 return {};
263 }
264 }
265
266 /**
267 * @param {string} dataDir
268 * @param {Record<string, object>} attempts
269 */
270 export function saveLoginAttempts(dataDir, attempts) {
271 const filePath = loginAttemptsPath(dataDir);
272 const dir = path.dirname(filePath);
273 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
274 fs.writeFileSync(filePath, JSON.stringify(attempts, null, 2), 'utf8');
275 chmod0600(filePath);
276 }
277
278 const LOCKOUT_FAILURES = 5;
279 const LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
280
281 /**
282 * @param {Record<string, object>} attempts
283 * @param {string} key - sub or username key
284 * @returns {{ locked: boolean, retryAfterSeconds?: number }}
285 */
286 export function checkAccountLocked(attempts, key) {
287 const rec = attempts[key];
288 if (!rec || !rec.lockedUntil) return { locked: false };
289 const until = new Date(rec.lockedUntil).getTime();
290 if (Date.now() >= until) return { locked: false };
291 return { locked: true, retryAfterSeconds: Math.ceil((until - Date.now()) / 1000) };
292 }
293
294 /**
295 * @param {Record<string, object>} attempts
296 * @param {string} key
297 */
298 export function recordLoginFailure(attempts, key) {
299 const now = new Date().toISOString();
300 const rec = attempts[key] || { failures: 0, firstFailureAt: null, lockedUntil: null };
301 if (rec.firstFailureAt) {
302 const first = new Date(rec.firstFailureAt).getTime();
303 if (Date.now() - first > LOCKOUT_WINDOW_MS) {
304 rec.failures = 0;
305 rec.firstFailureAt = null;
306 rec.lockedUntil = null;
307 }
308 }
309 if (!rec.firstFailureAt) rec.firstFailureAt = now;
310 rec.failures += 1;
311 if (rec.failures >= LOCKOUT_FAILURES) {
312 rec.lockedUntil = new Date(Date.now() + LOCKOUT_WINDOW_MS).toISOString();
313 }
314 attempts[key] = rec;
315 return rec;
316 }
317
318 /**
319 * @param {Record<string, object>} attempts
320 * @param {string} key
321 */
322 export function clearLoginAttempts(attempts, key) {
323 delete attempts[key];
324 }
325
326 /** Global token bucket: 20 attempts/minute (§7.2). */
327 const globalBucket = { tokens: 20, lastRefill: Date.now() };
328 const GLOBAL_RATE = 20;
329 const GLOBAL_WINDOW_MS = 60 * 1000;
330
331 /**
332 * @returns {{ allowed: boolean, retryAfterSeconds?: number }}
333 */
334 export function checkGlobalLoginRateLimit() {
335 const now = Date.now();
336 const elapsed = now - globalBucket.lastRefill;
337 if (elapsed >= GLOBAL_WINDOW_MS) {
338 globalBucket.tokens = GLOBAL_RATE;
339 globalBucket.lastRefill = now;
340 }
341 if (globalBucket.tokens <= 0) {
342 const retryAfterSeconds = Math.ceil((GLOBAL_WINDOW_MS - (now - globalBucket.lastRefill)) / 1000);
343 return { allowed: false, retryAfterSeconds: Math.max(1, retryAfterSeconds) };
344 }
345 globalBucket.tokens -= 1;
346 return { allowed: true };
347 }
348
349 /** Reset global bucket (tests). */
350 export function resetGlobalLoginRateLimitForTests() {
351 globalBucket.tokens = GLOBAL_RATE;
352 globalBucket.lastRefill = Date.now();
353 }
354
355 /**
356 * Perform local login verification.
357 * @param {string} dataDir
358 * @param {string} username
359 * @param {string} passphrase
360 * @param {{ sessionSecret: string, jwtExpiry?: string, offlineLockedActive?: boolean, adminUserIdsSet?: Set<string> }} opts
361 * @returns {Promise<{ ok: true, token: string, sub: string, credential: object } | { ok: false, code: string, retryAfterSeconds?: number }>}
362 */
363 export async function authenticateLocalUser(dataDir, username, passphrase, opts) {
364 const global = checkGlobalLoginRateLimit();
365 if (!global.allowed) {
366 return { ok: false, code: 'RATE_LIMITED', retryAfterSeconds: global.retryAfterSeconds };
367 }
368
369 if (!hasAnyCredential(dataDir)) {
370 return { ok: false, code: 'OFFLINE_LOCKED_NOT_BOOTSTRAPPED' };
371 }
372
373 const store = loadCredentialStore(dataDir);
374 const normalized = normalizeUsername(username);
375 const found = findCredentialByUsername(store, normalized);
376 const attempts = loadLoginAttempts(dataDir);
377 const attemptKey = found ? found.sub : `username:${normalized}`;
378
379 const lock = checkAccountLocked(attempts, attemptKey);
380 if (lock.locked) {
381 return { ok: false, code: 'ACCOUNT_LOCKED', retryAfterSeconds: lock.retryAfterSeconds };
382 }
383
384 const phc = found ? found.cred.argon2id : null;
385 const valid = await verifyPassphrase(phc, passphrase);
386
387 if (!valid) {
388 recordLoginFailure(attempts, attemptKey);
389 saveLoginAttempts(dataDir, attempts);
390 return { ok: false, code: 'INVALID_CREDENTIALS' };
391 }
392
393 clearLoginAttempts(attempts, attemptKey);
394 saveLoginAttempts(dataDir, attempts);
395
396 const role = resolveLocalAuthRole(dataDir, found.sub, {
397 offlineLockedActive: opts.offlineLockedActive,
398 adminUserIdsSet: opts.adminUserIdsSet,
399 });
400
401 const token = issueLocalToken(found.cred, role, opts.sessionSecret, opts.jwtExpiry || '24h');
402 return { ok: true, token, sub: found.sub, credential: found.cred };
403 }
404
405 /**
406 * Issue CLI token after passphrase verify (§8.2).
407 * @param {string} dataDir
408 * @param {string} username
409 * @param {string} passphrase
410 * @param {{ sessionSecret: string, jwtExpiry?: string, offlineLockedActive?: boolean, adminUserIdsSet?: Set<string> }} opts
411 * @returns {Promise<{ ok: true, token: string, sub: string } | { ok: false, code: string, retryAfterSeconds?: number }>}
412 */
413 export async function issueCliLocalToken(dataDir, username, passphrase, opts) {
414 const result = await authenticateLocalUser(dataDir, username, passphrase, opts);
415 if (!result.ok) return result;
416 return { ok: true, token: result.token, sub: result.sub };
417 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago