oclaw/uds-auth/lib/qr-challenge.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

48 lines
2.5 KiB
JavaScript

import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
const digest = value => createHash('sha256').update(String(value || '')).digest()
export class QrChallengeStore {
constructor({ ttlMs = 120_000, maxEntries = 5000 } = {}) {
this.rows = new Map()
this.ttlMs = ttlMs
this.maxEntries = maxEntries
}
create({ loginSystemCode, originSystemCode }) {
for (const [key, row] of this.rows) if (row.expiresAt <= Date.now()) this.rows.delete(key)
if (this.rows.size >= this.maxEntries) throw Object.assign(new Error('qr_capacity'), { code: 'qr_capacity' })
const qrCodeKey = randomBytes(16).toString('hex')
const qrCodeValue = randomBytes(16).toString('hex')
// The QR contains only public scan coordinates, never the polling browser proof.
const browserBinding = randomBytes(32).toString('hex')
const expiresAt = Date.now() + this.ttlMs
this.rows.set(qrCodeKey, { qrCodeValue, bindingHash: digest(browserBinding), expiresAt,
loginSystemCode, originSystemCode, consumed: false })
return { qrCodeKey, qrCodeValue, browserBinding, expiresAt, loginSystemCode, originSystemCode,
qrCodeStr: `TwoDIMAuth:${qrCodeKey}:${qrCodeValue}` }
}
peek(key, value, binding) {
const row = this.rows.get(String(key || ''))
if (!row) return { ok: false, reason: 'qr_unknown' }
if (row.expiresAt <= Date.now()) { this.rows.delete(String(key)); return { ok: false, reason: 'qr_expired' } }
if (row.consumed) return { ok: false, reason: 'qr_replay' }
if (!timingSafeEqual(digest(value), digest(row.qrCodeValue))) return { ok: false, reason: 'qr_mismatch' }
if (typeof binding !== 'string' || !/^[a-f0-9]{64}$/.test(binding)
|| !timingSafeEqual(digest(binding), row.bindingHash)) return { ok: false, reason: 'qr_binding_required' }
return { ok: true, row }
}
consume(key, value, binding) {
const result = this.peek(key, value, binding)
if (!result.ok) return result
result.row.consumed = true
return { ok: true }
}
}
/** Known UAC contract: outer code + bo.code and credentials in other. */
export function qrLoginCredentials(payload) {
if (String(payload?.code?.code ?? payload?.code) !== '0000' || String(payload?.bo?.code) !== '0000') return null
const empNo = String(payload?.other?.account || payload?.other?.empNo || '').trim()
const token = String(payload?.other?.token || payload?.other?.authValue || '').trim()
if (!empNo || !token || empNo.length > 128 || token.length > 16384) return null
return { empNo, token }
}