mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 00:43:28 +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
|
|
@ -1,15 +1,16 @@
|
|||
/**
|
||||
* Local admin via sealed box (decrypt-to-unlock).
|
||||
*
|
||||
* Env holds only ciphertext:
|
||||
* UDS_AUTH_LOCAL_ADMIN_BOX=<base64url sealed blob>
|
||||
* Ciphertext sources (first match wins):
|
||||
* 1) UDS_AUTH_LOCAL_ADMIN_BOX env
|
||||
* 2) $UDS_AUTH_DATA_DIR/local-admin.box or ~/.uds-auth/local-admin.box
|
||||
*
|
||||
* Operator keeps the passphrase offline. Unlock API tries AES-GCM open;
|
||||
* success → administrator session. Setting/replacing env alone does not
|
||||
* log anyone in — the key must decrypt the box.
|
||||
*
|
||||
* Generate a box:
|
||||
* node -e "import('./lib/local-admin.js').then(m => console.log(m.sealLocalAdminBox(process.argv[1])))" -- "your-passphrase"
|
||||
* node scripts/seal-local-admin.mjs
|
||||
*/
|
||||
import {
|
||||
createCipheriv,
|
||||
|
|
@ -19,15 +20,27 @@ import {
|
|||
timingSafeEqual,
|
||||
createHash,
|
||||
} from 'node:crypto'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { ROLES, computePermissions, DEFAULT_FALLBACK_USERNAME } from './roles.js'
|
||||
import { derivePasswordKey } from './utils/password-kdf.js'
|
||||
|
||||
export const LOCAL_ADMIN_BOX_ENV = 'UDS_AUTH_LOCAL_ADMIN_BOX'
|
||||
/** @deprecated presence of KEY no longer grants access; use BOX + unlock */
|
||||
export const LOCAL_ADMIN_ENV = 'UDS_AUTH_LOCAL_ADMIN_KEY'
|
||||
export const LOCAL_ADMIN_BOX_FILENAME = 'local-admin.box'
|
||||
|
||||
export const LOCAL_ADMIN_COOKIE = 'UDS_LOCAL_ADMIN'
|
||||
export const LOCAL_ADMIN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
|
||||
|
||||
/** Profile dir used when env box is missing (Desktop Host often lacks User env). */
|
||||
export function resolveLocalAdminBoxFile(dataDir) {
|
||||
const root = String(dataDir || process.env.UDS_AUTH_DATA_DIR || '').trim()
|
||||
|| join(homedir(), '.uds-auth')
|
||||
return resolve(root, LOCAL_ADMIN_BOX_FILENAME)
|
||||
}
|
||||
|
||||
const MAGIC = 'uds-local-admin-v1'
|
||||
const SCRYPT_N = 16384
|
||||
const SCRYPT_R = 8
|
||||
|
|
@ -116,8 +129,42 @@ export function openLocalAdminBox(box, passphrase) {
|
|||
}
|
||||
}
|
||||
|
||||
function readLocalAdminBoxFile() {
|
||||
try {
|
||||
const path = resolveLocalAdminBoxFile()
|
||||
if (!existsSync(path)) return null
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const line = String(raw || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#'))
|
||||
if (!line) return null
|
||||
// Allow either bare box or KEY=value lines copied from seal CLI output.
|
||||
const m = line.match(/^(?:export\s+)?UDS_AUTH_LOCAL_ADMIN_BOX=(.+)$/)
|
||||
return String(m ? m[1] : line).trim().replace(/^["']|["']$/g, '') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readLocalAdminBox() {
|
||||
return String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim() || null
|
||||
const fromEnv = String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim()
|
||||
if (fromEnv) return fromEnv
|
||||
return readLocalAdminBoxFile()
|
||||
}
|
||||
|
||||
/** Runtime unlock uses the same bounded, asynchronous KDF as password login. */
|
||||
export async function openLocalAdminBoxAsync(box, passphrase) {
|
||||
if (!box || typeof passphrase !== 'string' || !passphrase) return { ok: false, reason: 'missing' }
|
||||
const raw = b64urlDecode(box)
|
||||
if (raw.length < 46 || raw.length > 256 || raw[0] !== 1) return { ok: false, reason: 'bad_box' }
|
||||
const key = await derivePasswordKey(passphrase, raw.subarray(1, 17))
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(17, 29))
|
||||
decipher.setAuthTag(raw.subarray(29, 45))
|
||||
const plain = Buffer.concat([decipher.update(raw.subarray(45)), decipher.final()]).toString('utf8')
|
||||
return plain === `${MAGIC}|${DEFAULT_FALLBACK_USERNAME}`
|
||||
? { ok: true, empNo: DEFAULT_FALLBACK_USERNAME } : { ok: false, reason: 'bad_payload' }
|
||||
} catch {
|
||||
return { ok: false, reason: 'decrypt_failed' }
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalAdminBoxConfigured() {
|
||||
|
|
@ -197,7 +244,7 @@ export function buildLocalAdminIdentity(rolesStore) {
|
|||
role,
|
||||
permissions,
|
||||
userContext: buildLocalAdminUserContext(),
|
||||
kind: 'fallback',
|
||||
kind: 'sealed_box',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue