oclaw/uds-auth/lib/profile-security.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

74 lines
3.4 KiB
JavaScript

import { lstat, chmod } from 'node:fs/promises'
import { resolve } from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { SECURITY_STATE_FILES } from './state-migration.js'
const run = promisify(execFile)
/** Restrict the owned profile, including legacy files with explicit broad ACLs. */
export async function protectProfileDirectory(dataDir) {
const root = resolve(dataDir)
const dir = await lstat(root)
if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error('profile_directory_unsafe')
const files = []
for (const name of [...SECURITY_STATE_FILES, '.uds-auth-migrated-from-install']) {
const path = resolve(root, name)
try {
const stat = await lstat(path)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('profile_state_file_unsafe')
files.push(name)
} catch (err) { if (err.code !== 'ENOENT') throw err }
}
if (process.platform !== 'win32') {
await chmod(root, 0o700)
for (const name of files) await chmod(resolve(root, name), 0o600)
return { platform: process.platform, directoryMode: '0700', fileMode: '0600', files: files.length }
}
// LiteralPath + child-only environment avoids shell/path interpolation.
const script = `
$ErrorActionPreference = 'Stop'
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$system = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$paths = @($env:UDS_ACL_ROOT)
$names = ConvertFrom-Json $env:UDS_ACL_FILES
foreach ($name in $names) { $paths += Join-Path $env:UDS_ACL_ROOT $name }
foreach ($path in $paths) {
$isDir = [System.IO.Directory]::Exists($path)
if ($isDir) {
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
} else {
$acl = [System.Security.AccessControl.FileSecurity]::new()
$inherit = [System.Security.AccessControl.InheritanceFlags]::None
}
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner($sid)
foreach ($principal in @($sid, $system)) {
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($principal, 'FullControl', $inherit, 'None', 'Allow')
$acl.AddAccessRule($rule)
}
if ($isDir) {
[System.IO.Directory]::SetAccessControl($path, $acl)
$check = [System.IO.Directory]::GetAccessControl($path)
} else {
[System.IO.File]::SetAccessControl($path, $acl)
$check = [System.IO.File]::GetAccessControl($path)
}
if (!$check.AreAccessRulesProtected) { throw 'profile_acl_inheritance' }
$rules = $check.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
if ($rules.Count -ne 2) { throw 'profile_acl_rule_count' }
foreach ($rule in $rules) {
if ($rule.IdentityReference.Value -notin @($sid.Value, $system.Value) -or $rule.AccessControlType -ne 'Allow') { throw 'profile_acl_unexpected_principal' }
}
}
@{ platform = 'win32'; protected = $true; principals = @('current-user', 'SYSTEM'); files = $names.Count } | ConvertTo-Json -Compress
`
try {
const { stdout } = await run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
windowsHide: true, timeout: 30_000, env: { ...process.env, UDS_ACL_ROOT: root, UDS_ACL_FILES: JSON.stringify(files) },
})
return JSON.parse(stdout.trim())
} catch (cause) {
throw Object.assign(new Error('profile_acl_failed'), { code: 'profile_acl_failed', cause })
}
}