// Per-admin device access passwords. Single owner of the `accessHash` field // on users.json admin records. Imported by both platform/ui (src) and the // admin MCP plugin (dist) — mirrors platform/lib/admins-write. import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto' import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, mkdirSync } from 'node:fs' import { dirname } from 'node:path' const SCRYPT_N = 16384 const SCRYPT_R = 8 const SCRYPT_P = 1 const SCRYPT_KEYLEN = 64 export interface AccessUserEntry { userId: string pin: string phone?: string accessHash?: string [k: string]: unknown } /** * Identity + destination for one credential-store audit line. The writers are * pure (they only know the file paths); the caller is the only party that knows * WHO is acting and WHERE the audit log lives, so it is threaded in. `actor` is * an admin userId or a non-user sentinel (`onboarding` | `install` | `boot` | * `unknown` when an MCP caller has no resolvable USER_ID); `session` is the * spawning agent session (SESSION_NODE_ID) when one drove the write. `logFile` * is the absolute path to the append-only users-audit log, distinct from * server.log. Mirrored in platform/lib/admins-write. */ export interface AuditContext { actor: string session?: string logFile: string /** Task 1573 — the row being written, recorded on every credential write so * `actor != target` is the standing cross-identity signature. Omitted => * the line carries no target field (a self write not routed through the * identity-resolving tools). */ target?: string /** Task 1573 — true when the caller passed an explicit `userId` (a deliberate * cross-identity or self write); false/absent for an implicit self write. */ explicitUserId?: boolean } /** First 8 chars — the short-id convention used across the auth logs. */ function id8(value: string): string { return value.slice(0, 8) } function rowIds(users: AccessUserEntry[]): string { return users.map(u => id8(u.userId)).join(',') } /** * Append one canonical `[users-audit]` line. Append failures never propagate — * an audit-write fault must not break the credential write it records; the * fault is surfaced once on stderr instead. */ function appendUsersAuditLine( audit: AuditContext, fields: { action: string; field: string; rowsBefore: string; rowsAfter: string }, ): void { const sess = audit.session ? ` session=${id8(audit.session)}` : '' // userIds are uuids (shortened to id8); sentinels (boot|install|onboarding| // unknown) carry no dash and are written whole so attribution stays readable. const actor = audit.actor.includes('-') ? id8(audit.actor) : audit.actor const targetFields = audit.target !== undefined ? ` target=${audit.target.includes('-') ? id8(audit.target) : audit.target}` + ` explicitUserId=${audit.explicitUserId ? 'true' : 'false'}` : '' const line = `[users-audit] action=${fields.action} actor=${actor}${targetFields}${sess} ` + `field=${fields.field} rowsBefore=${fields.rowsBefore} rowsAfter=${fields.rowsAfter} ` + `ts=${new Date().toISOString()}\n` try { mkdirSync(dirname(audit.logFile), { recursive: true, mode: 0o700 }) appendFileSync(audit.logFile, line, { mode: 0o600 }) } catch (err) { console.error( `[users-audit] op=write-failed logFile=${audit.logFile} error=${err instanceof Error ? err.message : String(err)}`, ) } } function scryptAsync(password: string, salt: Buffer): Promise { return new Promise((resolve, reject) => { scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => { if (err) reject(err) else resolve(key) }) }) } export async function hashAccessPassword(password: string): Promise { const salt = randomBytes(32) const hash = await scryptAsync(password, salt) return `${salt.toString('hex')}:${hash.toString('hex')}` } async function matchesHash(password: string, stored: string): Promise { const colon = stored.indexOf(':') if (colon === -1) return false const salt = Buffer.from(stored.slice(0, colon), 'hex') const storedHash = Buffer.from(stored.slice(colon + 1), 'hex') if (storedHash.length !== SCRYPT_KEYLEN) return false let computed: Buffer try { computed = await scryptAsync(password, salt) } catch { return false } return timingSafeEqual(computed, storedHash) } function readUsers(usersFile: string): AccessUserEntry[] { if (!existsSync(usersFile)) return [] const raw = readFileSync(usersFile, 'utf-8').trim() if (!raw) return [] const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) throw new Error('users.json is not an array') return parsed as AccessUserEntry[] } function writeUsersAtomic(usersFile: string, users: AccessUserEntry[]): void { const tmp = `${usersFile}.tmp-${process.pid}` writeFileSync(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 }) renameSync(tmp, usersFile) } /** * Write `accessHash` onto the userId's record. Throws if the record is absent, * or if `password` already verifies against a DIFFERENT record — uniqueness is * an invariant of this writer, so no path (including ones that skip the * call-site pre-check) can create a credential a login scan would resolve * ambiguously. */ export async function setAccessPassword(userId: string, password: string, usersFile: string, audit: AuditContext): Promise { const users = readUsers(usersFile) const idx = users.findIndex(u => u.userId === userId) if (idx === -1) throw new Error(`no users.json record for userId=${userId}`) // Refuse a cross-admin collision before hashing or writing — a self-rotation // (same userId already holding this password) is excluded and still allowed. if (await accessPasswordCollides(password, usersFile, userId)) { throw new Error('accessHash collision: password already in use by another admin') } const rows = rowIds(users) users[idx] = { ...users[idx], accessHash: await hashAccessPassword(password) } writeUsersAtomic(usersFile, users) appendUsersAuditLine(audit, { action: 'set-access', field: 'accessHash', rowsBefore: rows, rowsAfter: rows }) } /** Remove `accessHash` from the userId's record. No-op (no write, no audit * line) when the record is absent or already carries no accessHash. */ export function clearAccessPassword(userId: string, usersFile: string, audit: AuditContext): void { const users = readUsers(usersFile) const idx = users.findIndex(u => u.userId === userId) if (idx === -1) return const existing = users[idx].accessHash if (typeof existing !== 'string' || existing.length === 0) return const rows = rowIds(users) const { accessHash, ...rest } = users[idx] void accessHash users[idx] = rest as AccessUserEntry writeUsersAtomic(usersFile, users) appendUsersAuditLine(audit, { action: 'clear-access', field: 'accessHash', rowsBefore: rows, rowsAfter: rows }) } /** Return the userId whose accessHash matches, or null. */ export async function verifyAccessPassword(password: string, usersFile: string): Promise { let users: AccessUserEntry[] try { users = readUsers(usersFile) } catch { return null } for (const u of users) { if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) { return u.userId } } return null } /** * True when some admin OTHER than excludeUserId already has an accessHash that * matches `password`. Used by the set/change surfaces to refuse a value that * would collide with another admin's perimeter credential — the scan-based * verify attributes a login to the first matching record, so two admins sharing * a password lets one silently overwrite the other. An unreadable store returns * false: the subsequent write is what owns the read-fault error path. */ export async function accessPasswordCollides( password: string, usersFile: string, excludeUserId: string, ): Promise { let users: AccessUserEntry[] try { users = readUsers(usersFile) } catch { return false } for (const u of users) { if (u.userId === excludeUserId) continue if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) { return true } } return false } /** True when at least one record carries an accessHash. */ export function anyAccessPasswordConfigured(usersFile: string): boolean { let users: AccessUserEntry[] try { users = readUsers(usersFile) } catch { return false } return users.some(u => typeof u.accessHash === 'string' && u.accessHash.length > 0) } /** * True when users.json is present on disk but cannot be read or parsed. The * gate's `isRemoteAuthConfigured` checks this so a transient read fault on a * configured install fails CLOSED (stays "configured" → login screen, not the * setup flow) instead of downgrading to "unconfigured". A genuinely absent or * cleanly-empty store is NOT unreadable. */ export function accessStoreUnreadable(usersFile: string): boolean { if (!existsSync(usersFile)) return false try { readUsers(usersFile); return false } catch { return true } } export type MigrateResult = 'migrated' | 'noop-no-legacy' | 'noop-no-owner' | 'noop-owner-has-hash' /** * One-time, idempotent. If the legacy single-password file exists and the * owner record has no accessHash, copy the legacy hash (already scrypt * "salt:hash") onto the owner and delete the legacy file. If the owner already * has an accessHash, the legacy file is stale → delete it. If no owner record * exists yet, leave the legacy file untouched (verify still reads it as a * candidate during this window — see readLegacyRemoteHash). */ export function migrateLegacyRemotePassword( usersFile: string, legacyFile: string, ownerUserId: string, audit: AuditContext, ): MigrateResult { if (!existsSync(legacyFile)) return 'noop-no-legacy' const legacy = readFileSync(legacyFile, 'utf-8').trim() const users = readUsers(usersFile) const idx = users.findIndex(u => u.userId === ownerUserId) if (idx === -1) return 'noop-no-owner' const existing = users[idx].accessHash if (typeof existing === 'string' && existing.length > 0) { unlinkSync(legacyFile) return 'noop-owner-has-hash' } if (!legacy.includes(':')) { unlinkSync(legacyFile); return 'noop-no-legacy' } const rows = rowIds(users) users[idx] = { ...users[idx], accessHash: legacy } writeUsersAtomic(usersFile, users) unlinkSync(legacyFile) appendUsersAuditLine(audit, { action: 'migrate', field: 'accessHash', rowsBefore: rows, rowsAfter: rows }) return 'migrated' } /** Read a still-present legacy single-password hash, or null. Used by verify * as one additional candidate before the boot migration runs. */ export function readLegacyRemoteHash(legacyFile: string): string | null { if (!existsSync(legacyFile)) return null const raw = readFileSync(legacyFile, 'utf-8').trim() return raw.includes(':') ? raw : null } export { matchesHash as verifyHashCandidate }