oclaw/uds-auth/lib/utils/password-kdf.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
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>
2026-10-12 02:11:09 +08:00

47 lines
1.8 KiB
JavaScript

import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
// A shared bound across password login, rotation and sealed-box unlock. Reject
// overload instead of keeping unbounded request/password buffers in memory.
const MAX_RUNNING = 2
const MAX_WAITING = 8
let running = 0
const waiting = []
export async function derivePasswordKey(password, salt, length = 32) {
if (typeof password !== 'string' || Buffer.byteLength(password) > 1024) {
throw Object.assign(new Error('password_invalid'), { code: 'password_invalid' })
}
if (running >= MAX_RUNNING) {
if (waiting.length >= MAX_WAITING) {
throw Object.assign(new Error('kdf_busy'), { code: 'kdf_busy' })
}
await new Promise(resolve => waiting.push(resolve))
} else running++
try {
return await new Promise((resolve, reject) => {
scrypt(password, salt, length, { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 },
(err, key) => err ? reject(err) : resolve(key))
})
} finally {
const next = waiting.shift()
if (next) next()
else running--
}
}
export async function hashPasswordAsync(password) {
const salt = randomBytes(16)
const key = await derivePasswordKey(password, salt)
return `scrypt$16384$8$1$${salt.toString('base64url')}$${key.toString('base64url')}`
}
export async function verifyScryptAsync(password, stored) {
const parts = String(stored).split('$')
// Never accept attacker-controlled KDF work factors or output sizes.
if (parts.length !== 6 || parts.slice(0, 4).join('$') !== 'scrypt$16384$8$1') return false
const salt = Buffer.from(parts[4], 'base64url')
const expected = Buffer.from(parts[5], 'base64url')
if (salt.length !== 16 || expected.length !== 32) return false
const actual = await derivePasswordKey(password, salt)
return timingSafeEqual(actual, expected)
}