oclaw/uds-auth/lib/skill-credentials.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

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)
}