mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 06:03:17 +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
48
uds-auth/lib/qr-challenge.js
Normal file
48
uds-auth/lib/qr-challenge.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue