mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 00:33:19 +08:00
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>
This commit is contained in:
parent
fa4d5d6d97
commit
df97c5e042
72 changed files with 8436 additions and 1192 deletions
47
uds-auth/lib/utils/password-kdf.js
Normal file
47
uds-auth/lib/utils/password-kdf.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue