fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run

Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 02:11:09 +08:00
parent fa4d5d6d97
commit df97c5e042
72 changed files with 8436 additions and 1192 deletions

View file

@ -1,21 +1,22 @@
/**
* uds-auth 角色存储 + 权限管理
*
* 角色(身份标签;admin 级权限相同):
* super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级
* fallback_admin 身份:应急账号 administrator;权限 = admin 级
* 角色(身份标签;admin 级权限相同 — SEC-26 产品决策:当前同权):
* super_admin 身份:显式初始管理员 / 管理 API 提权;权限 = admin 级
* fallback_admin 身份:仅应急账号 administrator(不可经管理 API 分配);权限 = admin 级
* admin 身份:用户管理中提权;权限 = admin 级
* user 仅看自己会话,无设置 / 不可建工作区
*
* admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。
* 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。
* prefs.viewAllSessions === false 时关闭全览。
* 「超管」是身份标签,不是更强权限隔离;viewAllSessions 是显示偏好,非安全边界。
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { ROLE_LABELS_ZH } from './i18n.js'
import { atomicWriteJson } from './utils/atomic-write.js'
import { hashPasswordAsync, verifyScryptAsync } from './utils/password-kdf.js'
function codedError(code) {
const err = new Error(code)
@ -23,6 +24,73 @@ function codedError(code) {
return err
}
const ACCOUNT_STATUS = {
ACTIVE: 'active',
DISABLED: 'disabled',
}
/** Known shared default from pre-0.3 installs — must never remain usable (R10). */
const LEGACY_DEFAULT_SHA256 = createHash('sha256').update('Admin@123').digest('hex')
/**
* R10: versioned scrypt password hash.
* Format: scrypt$N$r$p$saltB64$urlHashB64
* Legacy bare SHA-256 hex still verifies once, then must be rotated (except known default).
*/
function hashPasswordScrypt(password, saltBuf = randomBytes(16)) {
const N = 16384
const r = 8
const p = 1
const derived = scryptSync(String(password), saltBuf, 32, { N, r, p })
return [
'scrypt',
String(N),
String(r),
String(p),
saltBuf.toString('base64url'),
derived.toString('base64url'),
].join('$')
}
function verifyPasswordHash(password, stored) {
const s = String(stored || '')
if (!s) return false
if (s.startsWith('scrypt$')) {
const parts = s.split('$')
if (parts.length !== 6) return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = Buffer.from(parts[4], 'base64url')
const expect = Buffer.from(parts[5], 'base64url')
if (N !== 16384 || r !== 8 || p !== 1 || salt.length !== 16 || expect.length !== 32) return false
let derived
try {
derived = scryptSync(String(password), salt, expect.length, { N, r, p })
} catch {
return false
}
if (derived.length !== expect.length) return false
return timingSafeEqual(derived, expect)
}
// Legacy unsalted SHA-256 — reject known shared default outright.
if (/^[0-9a-f]{64}$/i.test(s)) {
if (s.toLowerCase() === LEGACY_DEFAULT_SHA256) return false
const got = createHash('sha256').update(String(password)).digest('hex')
try {
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(s, 'hex'))
} catch {
return false
}
}
return false
}
/** @deprecated keep name for call sites that still import hashPassword */
function hashPassword(password) {
return hashPasswordScrypt(password)
}
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
@ -73,11 +141,7 @@ export function canToggleViewAllSessions(role) {
return isAdminClass(role)
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
/** 初始部署默认兜底密码(扫码不可用时用);可在设置里改密或关闭。 */
/** Historical default — never enable automatically (R10). */
export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123'
export const DEFAULT_FALLBACK_USERNAME = 'administrator'
@ -85,35 +149,39 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
* RolesStore — 角色存储
* 内存 Map + 可选 JSON 文件持久化
*
* fallback admin: 默认启用,密码 Admin@123;roles.json 显式 null 表示已关闭。
* fallback admin: 默认关闭;须在设置中显式设置 scrypt 口令(≥10,禁 Admin@123)。
* 登录路径: POST /uds-auth/api/fallback/login { username, password }
*/
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
this._status = new Map() // empNo → active|disabled (R12)
this._firstBootLock = Promise.resolve()
this._mutateChain = Promise.resolve() // R09: serialize role mutations
this._saveChain = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
this._fallbackPasswordHash = null // scrypt$… or legacy hex;null = 未启用
this._fallbackNeedsRehash = false
this._fallbackCredVersion = 0 // F02: bumps on set/clear password
this._committedFallback = { hash: null, needs: false, ver: 0 }
this._fallbackRateLimit = new Map() // ip → { count, resetAt }
this._dirty = false
this._saveTimer = null
}
/**
* New installs do NOT enable a shared default password (SEC-08).
* Admins must set a password explicitly via settings / setFallbackPassword.
*/
_ensureDefaultFallback() {
if (this._fallbackPasswordHash) return
this._fallbackPasswordHash = hashPassword(DEFAULT_FALLBACK_PASSWORD)
this._markDirty()
console.info(
'[uds-auth] fallback_admin enabled by default'
+ ` (user=${DEFAULT_FALLBACK_USERNAME}, change password in settings)`,
)
// no-op — kept for call-site compatibility
}
// === 持久化 ===
async init() {
let loadedHash = undefined // undefined = missing / new file; null = explicitly cleared
let loadedOk = false
if (this._rolesFile) {
try {
const raw = await readFile(this._rolesFile, 'utf-8')
@ -127,27 +195,60 @@ export class RolesStore {
}
}
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
loadedHash = data.fallbackPasswordHash || null
if (loadedHash) this._fallbackPasswordHash = loadedHash
this._fallbackPasswordHash = data.fallbackPasswordHash || null
}
this._fallbackCredVersion = Number.isSafeInteger(data.fallbackCredVersion)
&& data.fallbackCredVersion >= 0 ? data.fallbackCredVersion : 0
for (const [empNo, st] of Object.entries(data.status || {})) {
if (st === ACCOUNT_STATUS.DISABLED || st === ACCOUNT_STATUS.ACTIVE) {
this._status.set(String(empNo), st)
}
}
loadedOk = true
} catch (err) {
if (err.code === 'ENOENT') {
// 首次启动,文件不存在 — 走默认兜底
// First boot — empty roles, fallback disabled until explicitly configured.
console.info(
'[uds-auth] roles file missing; fallback password disabled until configured'
+ ` (set initialAdmin via UDS_AUTH_INITIAL_ADMIN)`,
)
} else {
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
// Corrupt roles must NOT restore a default password (SEC-08/21).
console.error('[uds-auth:RolesStore] Failed to load roles file — refusing default password:', err.message)
this._loadFailed = true
}
}
}
// 无持久化哈希(新部署或旧文件未写该字段)→ 默认开启;显式 null 表示超管已关闭
if (loadedHash === undefined && !this._fallbackPasswordHash) {
this._ensureDefaultFallback()
// R10: known shared default hash cannot remain enabled after upgrade.
if (loadedOk && this._fallbackPasswordHash
&& String(this._fallbackPasswordHash).toLowerCase() === LEGACY_DEFAULT_SHA256) {
console.warn('[uds-auth] refusing legacy shared default fallback password — disabled until reset')
this._fallbackPasswordHash = null
this._dirty = true
await this._save()
} else if (loadedOk && this._fallbackPasswordHash
&& /^[0-9a-f]{64}$/i.test(String(this._fallbackPasswordHash))) {
this._fallbackNeedsRehash = true
console.warn('[uds-auth] legacy SHA-256 fallback hash loaded — will upgrade on next successful login')
}
if (loadedOk && this._fallbackPasswordHash) {
console.info('[uds-auth] fallback_admin password loaded from roles file')
}
this._captureCommittedFallback()
}
get loadFailed() {
return !!this._loadFailed
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
void this._withMutationLock(() => this.flush()).catch(() => {})
}, 2000)
this._saveTimer.unref?.()
}
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
@ -160,26 +261,106 @@ export class RolesStore {
}
async _save() {
const run = this._saveChain.then(() => this._saveNow())
this._saveChain = run.catch(() => {})
return run
}
async _saveNow() {
this._saveTimer = null
if (!this._dirty || !this._rolesFile) return
const snapshot = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
status: Object.fromEntries(this._status),
fallbackPasswordHash: this._fallbackPasswordHash,
fallbackCredVersion: this._fallbackCredVersion,
savedAt: new Date().toISOString(),
}
this._dirty = false
try {
const data = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
fallbackPasswordHash: this._fallbackPasswordHash,
savedAt: new Date().toISOString(),
}
await mkdir(dirname(this._rolesFile), { recursive: true })
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
await atomicWriteJson(this._rolesFile, snapshot, { mode: 0o600 })
} catch (err) {
this._dirty = true
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
console.error('[uds-auth:RolesStore] Failed to save roles file:', err.message)
const e = new Error('roles_persist_failed')
e.code = 'roles_persist_failed'
e.cause = err
throw e
}
// Changes during await writeFile — schedule another save.
if (this._dirty) this._markDirty()
}
/** R09: serialize mutations that check last-admin invariants. */
_withMutationLock(fn) {
const transaction = async () => {
const previous = {
roles: new Map(this._roles), prefs: new Map(this._prefs), status: new Map(this._status),
hash: this._fallbackPasswordHash, needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion,
}
this._transactionSnapshot = previous
try {
return await fn()
} catch (err) {
this._roles = previous.roles
this._prefs = previous.prefs
this._status = previous.status
this._fallbackPasswordHash = previous.hash
this._fallbackNeedsRehash = previous.needs
this._fallbackCredVersion = previous.ver
this._dirty = false
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = null
throw err
} finally {
this._transactionSnapshot = null
}
}
const run = this._mutateChain.then(transaction, transaction)
this._mutateChain = run.then(() => {}, () => {})
return run
}
getAccountStatus(empNo) {
const e = String(empNo || '').trim()
if (!e) return ACCOUNT_STATUS.ACTIVE
return (this._transactionSnapshot?.status || this._status).get(e) || ACCOUNT_STATUS.ACTIVE
}
isDisabled(empNo) {
return this.getAccountStatus(empNo) === ACCOUNT_STATUS.DISABLED
}
async setAccountStatus(empNo, status, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const e = String(empNo || '').trim()
if (!e) throw codedError('invalid_role_params')
if (status !== ACCOUNT_STATUS.ACTIVE && status !== ACCOUNT_STATUS.DISABLED) {
throw codedError('invalid_role_params')
}
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
if (status === ACCOUNT_STATUS.DISABLED && this._roles.get(e) === ROLES.SUPER_ADMIN
&& !this.isDisabled(e)) {
const n = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (n <= 1) throw codedError('last_super_admin_demote')
}
const prevStatus = this._status.get(e)
this._status.set(e, status)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(e)
else this._status.set(e, prevStatus)
throw err
}
return true
})
}
// === 首次部署 bootstrap ===
/**
@ -187,28 +368,28 @@ export class RolesStore {
* 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap
*/
async bootstrapFirstUser(empNo) {
// 用锁保证原子性
this._firstBootLock = this._firstBootLock.then(async () => {
return this._withMutationLock(async () => {
if (this._roles.size === 0) {
this._roles.set(empNo, ROLES.SUPER_ADMIN)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.SUPER_ADMIN, bootstrapped: true }
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
this._dirty = true
await this.flush()
return { role: ROLES.USER, bootstrapped: false }
}
return { role: this._roles.get(empNo), bootstrapped: false }
})
return this._firstBootLock
}
// === 角色查询 ===
getRole(empNo) {
if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN
return this._roles.get(empNo) || ROLES.USER
return (this._transactionSnapshot?.roles || this._roles).get(empNo) || ROLES.USER
}
/**
@ -217,7 +398,7 @@ export class RolesStore {
*/
isViewAllSessionsEnabled(empNo) {
if (!empNo) return false
const prefs = this._prefs.get(String(empNo))
const prefs = (this._transactionSnapshot?.prefs || this._prefs).get(String(empNo))
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
return !!prefs.viewAllSessions
}
@ -244,6 +425,14 @@ export class RolesStore {
return true
}
async setViewAllSessionsDurable(empNo, enabled) {
return this._withMutationLock(async () => {
this.setViewAllSessions(empNo, enabled)
await this.flush()
return true
})
}
/** 角色 + 个人偏好 → 有效权限 */
resolvePermissions(empNo, role) {
const id = empNo != null ? String(empNo) : ''
@ -255,16 +444,16 @@ export class RolesStore {
}
hasRole(empNo) {
return this._roles.has(empNo)
return (this._transactionSnapshot?.roles || this._roles).has(empNo)
}
getAll() {
return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
return Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
}
/** 角色表是否为空(用于 bootstrap) */
isEmpty() {
return this._roles.size === 0
return (this._transactionSnapshot?.roles || this._roles).size === 0
}
/**
@ -276,7 +465,7 @@ export class RolesStore {
const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50))
const q = String(opts.q || '').trim().toLowerCase()
let rows = Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role }))
let rows = Array.from((this._transactionSnapshot?.roles || this._roles).entries()).map(([empNo, role]) => ({ empNo, role }))
if (q) {
rows = rows.filter((r) => String(r.empNo).toLowerCase().includes(q)
|| String(ROLE_LABELS[r.role] || r.role).toLowerCase().includes(q))
@ -304,86 +493,258 @@ export class RolesStore {
return n
}
/** A07: count only active (non-disabled) accounts with the given role. */
async countActiveByRole(role) {
let n = 0
for (const [empNo, r] of this._roles) {
if (r !== role) continue
if (this.isDisabled(empNo)) continue
n++
}
return n
}
// === 角色管理 (admin 级) ===
/**
* 设置用户角色
* 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有)
* 保护性 invariant: 至少保留 1 个 active super_admin(若表中曾有)
*/
async setRole(empNo, newRole, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_role')
}
// invariant: 不能让系统变成 0 个 super_admin(身份仍保留)
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
// SEC-26: management API may only assign assignable roles — never fallback_admin.
const assignable = new Set([ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.USER])
if (!assignable.has(newRole)) {
throw codedError('invalid_role_params')
}
this._roles.set(empNo, newRole)
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_role')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN
&& !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_demote')
}
}
const prev = this._roles.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.set(empNo, newRole)
// Re-activating via setRole clears disabled when admin assigns a role.
if (this._status.get(empNo) === ACCOUNT_STATUS.DISABLED) {
this._status.set(empNo, ACCOUNT_STATUS.ACTIVE)
}
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prev === undefined) this._roles.delete(empNo)
else this._roles.set(empNo, prev)
// A07: roll back status as well as role on persist failure.
if (prevStatus === undefined) this._status.delete(empNo)
else this._status.set(empNo, prevStatus)
throw err
}
return true
})
}
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* R12: disable account (preferred) — keeps record so open registration cannot re-add.
* Pass { hard: true } to delete role entry entirely.
*/
async removeUser(empNo, currentAdminRole, opts = {}) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_remove_user')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_remove_user')
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN && !this.isDisabled(empNo)) {
const superAdmins = await this.countActiveByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw codedError('last_super_admin_delete')
}
}
}
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._markDirty()
return true
if (opts.hard === true) {
const prevRole = this._roles.get(empNo)
const prevPrefs = this._prefs.get(empNo)
const prevStatus = this._status.get(empNo)
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._status.delete(empNo)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevRole !== undefined) this._roles.set(empNo, prevRole)
if (prevPrefs !== undefined) this._prefs.set(empNo, prevPrefs)
if (prevStatus !== undefined) this._status.set(empNo, prevStatus)
throw err
}
} else {
const key = String(empNo)
const prevStatus = this._status.get(key)
this._status.set(key, ACCOUNT_STATUS.DISABLED)
this._dirty = true
try {
await this.flush()
} catch (err) {
if (prevStatus === undefined) this._status.delete(key)
else this._status.set(key, prevStatus)
throw err
}
}
return true
})
}
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
/**
* 确保用户存在 (如果不存在设为 user);disabled 账号不会被自动复活。
* F03: await flush before success — no silent delayed-only persist.
*/
async ensureUser(empNo, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_add_user')
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._markDirty()
if (this.isDisabled(empNo)) {
throw codedError('account_disabled')
}
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_add_user')
if (this.isDisabled(empNo)) throw codedError('account_disabled')
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
this._status.set(String(empNo), ACCOUNT_STATUS.ACTIVE)
this._dirty = true
try {
await this.flush()
} catch (err) {
this._roles.delete(empNo)
this._status.delete(String(empNo))
throw err
}
}
return true
})
}
// === Fallback Administrator ===
setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
throw codedError('forbidden_set_fallback')
_ensureCommittedFallback() {
if (!this._committedFallback) {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
if (!password || password.length < 6) {
throw codedError('password_too_short')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
return true
}
clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(currentAdminRole)) {
_restoreCommittedFallback() {
this._ensureCommittedFallback()
this._fallbackPasswordHash = this._committedFallback.hash
this._fallbackNeedsRehash = this._committedFallback.needs
this._fallbackCredVersion = this._committedFallback.ver
}
_captureCommittedFallback() {
this._committedFallback = {
hash: this._fallbackPasswordHash,
needs: this._fallbackNeedsRehash,
ver: this._fallbackCredVersion || 0,
}
}
/**
* D05/D06/F03: persist against last-committed snapshot.
* Failed writes restore the last successful commit (not a concurrent peer's
* uncommitted value), including credVersion.
*/
async setFallbackPassword(password, currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_set_fallback')
}
if (String(password) === 'Admin@123') {
throw codedError('password_too_common')
}
if (typeof password !== 'string' || password.length < 10) {
throw codedError('password_too_short')
}
if (Buffer.byteLength(password) > 1024) throw codedError('password_invalid')
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_set_fallback')
const newHash = await hashPasswordAsync(password)
this._fallbackPasswordHash = newHash
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
async clearFallbackPassword(currentAdminRole) {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) {
throw codedError('forbidden_clear_fallback')
}
this._fallbackPasswordHash = null
this._markDirty()
return true
return this._withMutationLock(async () => {
if (!isAdminClass(typeof currentAdminRole === 'function' ? currentAdminRole() : currentAdminRole)) throw codedError('forbidden_clear_fallback')
this._fallbackPasswordHash = null
this._fallbackNeedsRehash = false
this._fallbackCredVersion++
this._dirty = true
await this.flush()
this._captureCommittedFallback()
return true
})
}
/** Auth material version for password-fallback sessions (F02). */
getFallbackCredVersion() {
return this._transactionSnapshot?.ver ?? this._fallbackCredVersion ?? 0
}
isFallbackEnabled() {
return this._fallbackPasswordHash !== null
return (this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash) !== null
}
/** Runtime path: asynchronous bounded KDF and recheck after a concurrent rotation. */
async verifyFallbackAsync(password, ip) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) return false
const hash = this._transactionSnapshot ? this._transactionSnapshot.hash : this._fallbackPasswordHash
const version = this.getFallbackCredVersion()
if (!hash) return false
const now = Date.now()
for (const [key, value] of this._fallbackRateLimit) {
if (value.resetAt <= now) this._fallbackRateLimit.delete(key)
}
let bucket = this._fallbackRateLimit.get(ip)
if (!bucket) {
if (this._fallbackRateLimit.size >= 5000) return false
this._fallbackRateLimit.set(ip, bucket = { count: 0, resetAt: now + 60_000 })
}
if (++bucket.count > 5) return false
const ok = String(hash).startsWith('scrypt$')
? await verifyScryptAsync(password, hash) : verifyPasswordHash(password, hash)
if (!ok) return false
// Serialize the final check with all durable credential changes.
return this._withMutationLock(async () => {
if (this._fallbackPasswordHash !== hash || this._fallbackCredVersion !== version) return false
if (!String(hash).startsWith('scrypt$')) {
this._fallbackPasswordHash = await hashPasswordAsync(password)
this._fallbackNeedsRehash = false
this._dirty = true
await this.flush()
this._captureCommittedFallback()
}
return true
})
}
/**
@ -405,6 +766,16 @@ export class RolesStore {
return false // rate limited
}
return hashPassword(password) === this._fallbackPasswordHash
const ok = verifyPasswordHash(password, this._fallbackPasswordHash)
if (ok && (this._fallbackNeedsRehash || !String(this._fallbackPasswordHash).startsWith('scrypt$'))) {
try {
this._fallbackPasswordHash = hashPasswordScrypt(password)
this._fallbackNeedsRehash = false
this._markDirty()
} catch { /* keep legacy until next save */ }
}
return ok
}
}
export { ACCOUNT_STATUS, hashPasswordScrypt, verifyPasswordHash, LEGACY_DEFAULT_SHA256 }