mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 07:13:20 +08:00
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>
48 lines
2.5 KiB
JavaScript
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 }
|
|
}
|