local-auth.mjs
418 lines 12.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 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 type: 'session',
189 },
190 sessionSecret,
191 { expiresIn: jwtExpiry }
192 );
193 }
194
195 /**
196 * Create a new local credential and optionally assign admin role.
197 * @param {string} dataDir
198 * @param {string} username
199 * @param {string} passphrase
200 * @param {{ role?: string, mustRotatePassphrase?: boolean }} [opts]
201 * @returns {Promise<{ userId: string, sub: string }>}
202 */
203 export async function createLocalCredential(dataDir, username, passphrase, opts = {}) {
204 const store = loadCredentialStore(dataDir);
205 const normalized = normalizeUsername(username);
206 if (findCredentialByUsername(store, normalized)) {
207 throw new Error('USERNAME_TAKEN');
208 }
209 store.userCounter += 1;
210 const finalUserId =
211 store.userCounter === 1 ? 'admin_001' : `user_${String(store.userCounter).padStart(3, '0')}`;
212 const sub = `local:${finalUserId}`;
213 const argon2id = await hashPassphrase(passphrase);
214 store.credentials[sub] = {
215 userId: finalUserId,
216 username: username.normalize('NFC').trim(),
217 argon2id,
218 createdAt: new Date().toISOString(),
219 mustRotatePassphrase: Boolean(opts.mustRotatePassphrase),
220 };
221 saveCredentialStore(dataDir, store);
222 const role = opts.role || 'admin';
223 const roles = readRolesObject(dataDir);
224 roles[sub] = role;
225 writeRolesFile(dataDir, roles);
226 return { userId: finalUserId, sub };
227 }
228
229 /**
230 * @param {string} dataDir
231 * @returns {boolean}
232 */
233 export function credentialStoreHasAdmin(dataDir) {
234 const store = loadCredentialStore(dataDir);
235 if (Object.keys(store.credentials).length === 0) return false;
236 const roleMap = loadRoleMap(dataDir);
237 for (const sub of Object.keys(store.credentials)) {
238 if (roleMap.get(sub) === 'admin') return true;
239 }
240 return false;
241 }
242
243 /**
244 * @param {string} dataDir
245 * @returns {boolean}
246 */
247 export function hasAnyCredential(dataDir) {
248 const store = loadCredentialStore(dataDir);
249 return Object.keys(store.credentials).length > 0;
250 }
251
252 /**
253 * @param {string} dataDir
254 * @returns {Record<string, { failures: number, firstFailureAt: string|null, lockedUntil: string|null }>}
255 */
256 export function loadLoginAttempts(dataDir) {
257 const filePath = loginAttemptsPath(dataDir);
258 if (!fs.existsSync(filePath)) return {};
259 try {
260 const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
261 return data && typeof data === 'object' ? data : {};
262 } catch (_) {
263 return {};
264 }
265 }
266
267 /**
268 * @param {string} dataDir
269 * @param {Record<string, object>} attempts
270 */
271 export function saveLoginAttempts(dataDir, attempts) {
272 const filePath = loginAttemptsPath(dataDir);
273 const dir = path.dirname(filePath);
274 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
275 fs.writeFileSync(filePath, JSON.stringify(attempts, null, 2), 'utf8');
276 chmod0600(filePath);
277 }
278
279 const LOCKOUT_FAILURES = 5;
280 const LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
281
282 /**
283 * @param {Record<string, object>} attempts
284 * @param {string} key - sub or username key
285 * @returns {{ locked: boolean, retryAfterSeconds?: number }}
286 */
287 export function checkAccountLocked(attempts, key) {
288 const rec = attempts[key];
289 if (!rec || !rec.lockedUntil) return { locked: false };
290 const until = new Date(rec.lockedUntil).getTime();
291 if (Date.now() >= until) return { locked: false };
292 return { locked: true, retryAfterSeconds: Math.ceil((until - Date.now()) / 1000) };
293 }
294
295 /**
296 * @param {Record<string, object>} attempts
297 * @param {string} key
298 */
299 export function recordLoginFailure(attempts, key) {
300 const now = new Date().toISOString();
301 const rec = attempts[key] || { failures: 0, firstFailureAt: null, lockedUntil: null };
302 if (rec.firstFailureAt) {
303 const first = new Date(rec.firstFailureAt).getTime();
304 if (Date.now() - first > LOCKOUT_WINDOW_MS) {
305 rec.failures = 0;
306 rec.firstFailureAt = null;
307 rec.lockedUntil = null;
308 }
309 }
310 if (!rec.firstFailureAt) rec.firstFailureAt = now;
311 rec.failures += 1;
312 if (rec.failures >= LOCKOUT_FAILURES) {
313 rec.lockedUntil = new Date(Date.now() + LOCKOUT_WINDOW_MS).toISOString();
314 }
315 attempts[key] = rec;
316 return rec;
317 }
318
319 /**
320 * @param {Record<string, object>} attempts
321 * @param {string} key
322 */
323 export function clearLoginAttempts(attempts, key) {
324 delete attempts[key];
325 }
326
327 /** Global token bucket: 20 attempts/minute (§7.2). */
328 const globalBucket = { tokens: 20, lastRefill: Date.now() };
329 const GLOBAL_RATE = 20;
330 const GLOBAL_WINDOW_MS = 60 * 1000;
331
332 /**
333 * @returns {{ allowed: boolean, retryAfterSeconds?: number }}
334 */
335 export function checkGlobalLoginRateLimit() {
336 const now = Date.now();
337 const elapsed = now - globalBucket.lastRefill;
338 if (elapsed >= GLOBAL_WINDOW_MS) {
339 globalBucket.tokens = GLOBAL_RATE;
340 globalBucket.lastRefill = now;
341 }
342 if (globalBucket.tokens <= 0) {
343 const retryAfterSeconds = Math.ceil((GLOBAL_WINDOW_MS - (now - globalBucket.lastRefill)) / 1000);
344 return { allowed: false, retryAfterSeconds: Math.max(1, retryAfterSeconds) };
345 }
346 globalBucket.tokens -= 1;
347 return { allowed: true };
348 }
349
350 /** Reset global bucket (tests). */
351 export function resetGlobalLoginRateLimitForTests() {
352 globalBucket.tokens = GLOBAL_RATE;
353 globalBucket.lastRefill = Date.now();
354 }
355
356 /**
357 * Perform local login verification.
358 * @param {string} dataDir
359 * @param {string} username
360 * @param {string} passphrase
361 * @param {{ sessionSecret: string, jwtExpiry?: string, offlineLockedActive?: boolean, adminUserIdsSet?: Set<string> }} opts
362 * @returns {Promise<{ ok: true, token: string, sub: string, credential: object } | { ok: false, code: string, retryAfterSeconds?: number }>}
363 */
364 export async function authenticateLocalUser(dataDir, username, passphrase, opts) {
365 const global = checkGlobalLoginRateLimit();
366 if (!global.allowed) {
367 return { ok: false, code: 'RATE_LIMITED', retryAfterSeconds: global.retryAfterSeconds };
368 }
369
370 if (!hasAnyCredential(dataDir)) {
371 return { ok: false, code: 'OFFLINE_LOCKED_NOT_BOOTSTRAPPED' };
372 }
373
374 const store = loadCredentialStore(dataDir);
375 const normalized = normalizeUsername(username);
376 const found = findCredentialByUsername(store, normalized);
377 const attempts = loadLoginAttempts(dataDir);
378 const attemptKey = found ? found.sub : `username:${normalized}`;
379
380 const lock = checkAccountLocked(attempts, attemptKey);
381 if (lock.locked) {
382 return { ok: false, code: 'ACCOUNT_LOCKED', retryAfterSeconds: lock.retryAfterSeconds };
383 }
384
385 const phc = found ? found.cred.argon2id : null;
386 const valid = await verifyPassphrase(phc, passphrase);
387
388 if (!valid) {
389 recordLoginFailure(attempts, attemptKey);
390 saveLoginAttempts(dataDir, attempts);
391 return { ok: false, code: 'INVALID_CREDENTIALS' };
392 }
393
394 clearLoginAttempts(attempts, attemptKey);
395 saveLoginAttempts(dataDir, attempts);
396
397 const role = resolveLocalAuthRole(dataDir, found.sub, {
398 offlineLockedActive: opts.offlineLockedActive,
399 adminUserIdsSet: opts.adminUserIdsSet,
400 });
401
402 const token = issueLocalToken(found.cred, role, opts.sessionSecret, opts.jwtExpiry || '24h');
403 return { ok: true, token, sub: found.sub, credential: found.cred };
404 }
405
406 /**
407 * Issue CLI token after passphrase verify (§8.2).
408 * @param {string} dataDir
409 * @param {string} username
410 * @param {string} passphrase
411 * @param {{ sessionSecret: string, jwtExpiry?: string, offlineLockedActive?: boolean, adminUserIdsSet?: Set<string> }} opts
412 * @returns {Promise<{ ok: true, token: string, sub: string } | { ok: false, code: string, retryAfterSeconds?: number }>}
413 */
414 export async function issueCliLocalToken(dataDir, username, passphrase, opts) {
415 const result = await authenticateLocalUser(dataDir, username, passphrase, opts);
416 if (!result.ok) return result;
417 return { ok: true, token: result.token, sub: result.sub };
418 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago