mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 23:50:49 +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>
176 lines
5.2 KiB
JavaScript
176 lines
5.2 KiB
JavaScript
/**
|
|
* Skill credential cache — separate from UI sessionStore.
|
|
* SEC-17: upstream tokens encrypted at rest (AES-256-GCM); plaintext only in memory.
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
import { dirname } from 'node:path'
|
|
import { loadOrCreateSecretKey, sealSecret, openSecret } from './utils/secret-box.js'
|
|
import { atomicWriteJson } from './utils/atomic-write.js'
|
|
|
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
export class SkillCredentialCache {
|
|
/**
|
|
* @param {{ file?: string, ttlMs?: number, logger?: Console, dataDir?: string }} opts
|
|
*/
|
|
constructor(opts = {}) {
|
|
this._file = opts.file || null
|
|
this._dataDir = opts.dataDir || (opts.file ? dirname(opts.file) : null)
|
|
this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
|
|
this._logger = opts.logger || console
|
|
/** @type {Map<string, { token: string, updatedAt: string, expiresAt: number }>} */
|
|
this._map = new Map()
|
|
this._dirty = false
|
|
this._flushTimer = null
|
|
/** @type {Buffer|null} */
|
|
this._key = null
|
|
}
|
|
|
|
async init() {
|
|
let hasEncryptedData = false
|
|
if (this._file) {
|
|
try {
|
|
const peek = await readFile(this._file, 'utf-8')
|
|
hasEncryptedData = /tokenEnc/.test(peek) && peek.trim().length > 2
|
|
} catch { /* absent */ }
|
|
}
|
|
if (this._dataDir) {
|
|
try {
|
|
this._key = await loadOrCreateSecretKey(this._dataDir, { hasEncryptedData })
|
|
} catch (err) {
|
|
this._logger?.error?.('[uds-auth] secret key unavailable:', err.message)
|
|
throw err
|
|
}
|
|
}
|
|
if (!this._file) return
|
|
try {
|
|
const raw = await readFile(this._file, 'utf-8')
|
|
const data = JSON.parse(raw)
|
|
const entries = data?.entries && typeof data.entries === 'object' ? data.entries : data
|
|
if (!entries || typeof entries !== 'object') return
|
|
const now = Date.now()
|
|
let decryptFailures = 0
|
|
for (const [empNo, row] of Object.entries(entries)) {
|
|
if (!row) continue
|
|
const expiresAt = Number(row.expiresAt) || (now + this._ttlMs)
|
|
if (expiresAt <= now) continue
|
|
let token = null
|
|
if (row.tokenEnc && this._key) {
|
|
token = openSecret(this._key, row.tokenEnc)
|
|
if (!token) decryptFailures++
|
|
} else if (row.token) {
|
|
// Legacy plaintext — accept once, rewrite encrypted on next flush.
|
|
token = String(row.token)
|
|
this._dirty = true
|
|
}
|
|
if (!token) continue
|
|
this._map.set(String(empNo), {
|
|
token,
|
|
updatedAt: row.updatedAt || new Date().toISOString(),
|
|
expiresAt,
|
|
})
|
|
}
|
|
if (decryptFailures > 0) {
|
|
this._logger?.error?.(
|
|
'[uds-auth] skill credential decrypt failed for',
|
|
decryptFailures,
|
|
'entries — check secret key (not silently replaced)',
|
|
)
|
|
}
|
|
if (this._dirty) this._scheduleFlush()
|
|
} catch {
|
|
/* optional file */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} empNo
|
|
* @param {string} token
|
|
*/
|
|
set(empNo, token) {
|
|
if (!empNo || !token) return
|
|
if (String(empNo).startsWith('__')) return
|
|
if (empNo === 'administrator') return
|
|
const now = Date.now()
|
|
this._map.set(String(empNo), {
|
|
token: String(token),
|
|
updatedAt: new Date().toISOString(),
|
|
expiresAt: now + this._ttlMs,
|
|
})
|
|
this._scheduleFlush()
|
|
}
|
|
|
|
/**
|
|
* @param {string} empNo
|
|
* @returns {{ empNo: string, token: string, updatedAt: string } | null}
|
|
*/
|
|
get(empNo) {
|
|
if (!empNo) return null
|
|
const key = String(empNo)
|
|
const row = this._map.get(key)
|
|
if (!row) return null
|
|
if (row.expiresAt && Date.now() > row.expiresAt) {
|
|
this._map.delete(key)
|
|
this._scheduleFlush()
|
|
return null
|
|
}
|
|
return { empNo: key, token: row.token, updatedAt: row.updatedAt }
|
|
}
|
|
|
|
/**
|
|
* @param {string} empNo
|
|
*/
|
|
delete(empNo) {
|
|
if (!empNo) return
|
|
if (this._map.delete(String(empNo))) this._scheduleFlush()
|
|
}
|
|
|
|
_scheduleFlush() {
|
|
if (!this._file) return
|
|
this._dirty = true
|
|
if (this._flushTimer) return
|
|
this._flushTimer = setTimeout(() => {
|
|
this._flushTimer = null
|
|
void this._flush()
|
|
}, 250)
|
|
if (typeof this._flushTimer.unref === 'function') this._flushTimer.unref()
|
|
}
|
|
|
|
async _flush() {
|
|
if (!this._file || !this._dirty) return
|
|
this._dirty = false
|
|
if (!this._key) {
|
|
this._logger?.error?.('[uds-auth] skillCredentialCache flush skipped: no key')
|
|
this._dirty = true
|
|
return
|
|
}
|
|
const entries = {}
|
|
for (const [empNo, row] of this._map.entries()) {
|
|
entries[empNo] = {
|
|
tokenEnc: sealSecret(this._key, row.token),
|
|
updatedAt: row.updatedAt,
|
|
expiresAt: row.expiresAt,
|
|
}
|
|
}
|
|
try {
|
|
await atomicWriteJson(this._file, { entries, v: 2 }, { mode: 0o600 })
|
|
} catch (err) {
|
|
this._dirty = true
|
|
this._logger?.warn?.('[uds-auth] skillCredentialCache flush failed:', err.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isLoopbackAddress(addr) {
|
|
if (!addr) return false
|
|
const a = String(addr).replace(/^::ffff:/i, '')
|
|
return a === '127.0.0.1' || a === '::1' || a === 'localhost'
|
|
}
|
|
|
|
/**
|
|
* Real socket peer only — never trust X-Forwarded-For / X-Real-IP (SEC-05).
|
|
*/
|
|
export function requestIsLoopback(req) {
|
|
const ra = req?.socket?.remoteAddress
|
|
return isLoopbackAddress(ra)
|
|
}
|