Add decrypt-to-unlock local admin via sealed env box.

Replace auto-grant-on-env with AES-GCM box + passphrase unlock, rebuild fallback sessions after restart, and document seal script usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 22:16:21 +08:00
parent 922abaa89e
commit c26dc68f77
10 changed files with 583 additions and 44 deletions

View file

@ -28,6 +28,14 @@ Bundled skill: [skills/uds-skill-auth](skills/uds-skill-auth). Handoff notes: [d
Loopback agent APIs: `GET|POST /uds-auth/agent-credentials`, `POST /uds-auth/outbound`.
### Local admin unlock (optional, decrypt-to-login)
1. `node scripts/seal-local-admin.mjs "your-passphrase"`
2. Set printed `UDS_AUTH_LOCAL_ADMIN_BOX=...` on the Harness process (ciphertext only)
3. Login panel → “Unlock with local key” → enter passphrase
Env alone does **not** grant admin. Legacy `UDS_AUTH_LOCAL_ADMIN_KEY` is ignored.
## Layout
| Piece | Path | Role |

View file

@ -41,6 +41,11 @@ originSystemCode: ''
- `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭)
- **本机密钥解锁(可选,解密才登录)**:
1. 生成密封盒:`node scripts/seal-local-admin.mjs "你的口令"`
2. 把输出的 `UDS_AUTH_LOCAL_ADMIN_BOX=...` 设到 **Harness 进程环境**(这是密文,不是口令)
3. 登录面板 →「本机密钥解锁」→ 输入口令;**必须解密成功才有 admin**
仅设置环境变量、不知道口令 → **无法登录**。旧变量 `UDS_AUTH_LOCAL_ADMIN_KEY` 已忽略。
- **ACL**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则
## Skill 认证(给他人改造 skill 时)

View file

@ -54,12 +54,13 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
for (const name of [
'UDS_FALLBACK_USER',
'UDS_FALLBACK_UI',
'UDS_LOCAL_ADMIN',
'PORTALSSOUser',
'PORTALSSOCookie',
'ZTEDPGSSOUser',
'ZTEDPGSSOCookie',
]) {
const httpOnly = name === 'UDS_FALLBACK_USER'
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN'
const base = httpOnly
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')

View file

@ -72,6 +72,10 @@ window.__ModuleLoader__.load({
"ui.fallbackLink": "UAC 不可用?应急账号登录",
"ui.fallbackLogin": "应急登录",
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
"ui.localKeyLink": "本机密钥解锁",
"ui.localKeyLogin": "本机密钥解锁",
"ui.localKeyDetail": "用密封盒口令解密后登录(仅设环境变量不会自动登录)",
"ui.localKey": "解密密钥",
"ui.username": "用户名",
"ui.password": "密码",
"ui.login": "登录",
@ -109,6 +113,10 @@ window.__ModuleLoader__.load({
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
"err.password_too_short": "密码至少 6 位",
"err.local_admin_not_configured": "未配置本机管理员密封盒",
"err.key_required": "请输入解密密钥",
"err.decrypt_failed": "密钥无法解密,登录失败",
"err.rate_limited": "尝试过多,请稍后再试",
"err.config_not_ready": "配置未初始化",
"err.request_failed": "请求失败",
"err.method_not_allowed": "方法不允许",
@ -142,7 +150,8 @@ window.__ModuleLoader__.load({
"ok.user_removed": "{empNo} 已删除",
"ok.fallback_password_set": "应急管理员密码已设置",
"ok.fallback_password_cleared": "应急管理员密码已清除",
"ok.fallback_login": "应急管理员登录成功"
"ok.fallback_login": "应急管理员登录成功",
"ok.local_admin_unlock": "本机密钥解锁成功"
},
"en": {
"role.super_admin": "Super admin",
@ -195,6 +204,10 @@ window.__ModuleLoader__.load({
"ui.fallbackLink": "UAC down? Emergency account",
"ui.fallbackLogin": "Emergency login",
"ui.fallbackDetail": "Use when UAC / QR is unavailable",
"ui.localKeyLink": "Unlock with local key",
"ui.localKeyLogin": "Local key unlock",
"ui.localKeyDetail": "Decrypt the sealed box with your passphrase (env alone does nothing)",
"ui.localKey": "Decryption key",
"ui.username": "Username",
"ui.password": "Password",
"ui.login": "Sign in",
@ -232,6 +245,10 @@ window.__ModuleLoader__.load({
"err.last_super_admin_demote": "At least one super admin is required; cannot demote the last one",
"err.last_super_admin_delete": "At least one super admin is required; cannot delete the last one",
"err.password_too_short": "Password must be at least 6 characters",
"err.local_admin_not_configured": "Local admin sealed box is not configured",
"err.key_required": "Decryption key required",
"err.decrypt_failed": "Key could not decrypt — sign-in failed",
"err.rate_limited": "Too many attempts, try later",
"err.config_not_ready": "Config not initialized",
"err.request_failed": "Request failed",
"err.method_not_allowed": "Method not allowed",
@ -265,7 +282,8 @@ window.__ModuleLoader__.load({
"ok.user_removed": "{empNo} removed",
"ok.fallback_password_set": "Emergency admin password set",
"ok.fallback_password_cleared": "Emergency admin password cleared",
"ok.fallback_login": "Emergency admin signed in"
"ok.fallback_login": "Emergency admin signed in",
"ok.local_admin_unlock": "Local admin unlocked"
}
}
const UDS_HOST_ARIA = {
@ -462,7 +480,7 @@ window.__ModuleLoader__.load({
}
function clearAuthCookies() {
const names = ['PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI']
const names = ['PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI', 'UDS_LOCAL_ADMIN']
for (const key of names) {
// Match both Secure and non-Secure variants; HttpOnly ones need server clear.
document.cookie = encodeURIComponent(key) + '=; Max-Age=0; Path=/; SameSite=Lax'
@ -992,8 +1010,10 @@ function reloadAfterLogin() {
// Default true (server enables fallback by default). If status fetch fails
// while QR is also down, keep the emergency link visible so admins can still sign in.
const [fallbackEnabled, setFallbackEnabled] = useState(true)
const [localKeyEnabled, setLocalKeyEnabled] = useState(false)
const [fbUser, setFbUser] = useState('administrator')
const [fbPass, setFbPass] = useState('')
const [localKey, setLocalKey] = useState('')
const [fbBusy, setFbBusy] = useState(false)
const [fbErr, setFbErr] = useState('')
const qrRef = useRef({ key: null, value: null, timer: null, timeout: null, deadline: 0 })
@ -1213,6 +1233,26 @@ function reloadAfterLogin() {
}
}, [fbUser, fbPass, refreshUser])
const submitLocalKey = useCallback(async () => {
setFbBusy(true)
setFbErr('')
try {
await fetchJson('/uds-auth/api/local-admin/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: localKey }),
})
setLocalKey('')
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} catch (err) {
setFbErr(apiMessage(err) || t('err.decrypt_failed'))
} finally {
setFbBusy(false)
}
}, [localKey, refreshUser])
useEffect(() => {
// Restore session after login reload / refresh — cookie alone does not set the badge.
// (Previously me ran only inside startQr, so a click on「未登录」was required.)
@ -1223,6 +1263,9 @@ function reloadAfterLogin() {
fetchJson('/uds-auth/api/fallback/status')
.then((st) => setFallbackEnabled(!!st.enabled))
.catch(() => { /* keep default true — do not hide emergency login */ })
fetchJson('/uds-auth/api/local-admin/status')
.then((st) => setLocalKeyEnabled(!!st.enabled))
.catch(() => { setLocalKeyEnabled(false) })
return () => { stopQr() }
}, [refreshUser, stopQr])
@ -1344,6 +1387,42 @@ function reloadAfterLogin() {
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
}, t('ui.fallbackLink')),
localKeyEnabled && h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('localKey'); setFbErr(''); setLocalKey('') },
}, t('ui.localKeyLink')),
)
: loginMode === 'localKey'
? h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, t('ui.localKeyLogin')),
h('div', { className: 'uds-auth-info-detail' }, t('ui.localKeyDetail')),
),
h('div', { className: 'uds-auth-fallback' },
h('label', { htmlFor: 'uds-local-key' }, t('ui.localKey')),
h('input', {
id: 'uds-local-key',
type: 'password',
value: localKey,
onChange: (e) => setLocalKey(e.target.value),
autoComplete: 'current-password',
onKeyDown: (e) => { if (e.key === 'Enter') submitLocalKey() },
}),
fbErr && h('div', { className: 'uds-auth-settings-msg err' }, fbErr),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: '100%', margin: '12px 0 0' },
disabled: fbBusy || !localKey,
onClick: submitLocalKey,
}, fbBusy ? t('ui.loggingIn') : t('ui.login')),
),
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => setLoginMode('qr'),
}, t('ui.backToQr')),
)
: h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },

View file

@ -67,6 +67,10 @@ export const MESSAGES = {
'ui.fallbackLink': 'UAC 不可用?应急账号登录',
'ui.fallbackLogin': '应急登录',
'ui.fallbackDetail': 'UAC / 扫码不可用时使用',
'ui.localKeyLink': '本机密钥解锁',
'ui.localKeyLogin': '本机密钥解锁',
'ui.localKeyDetail': '用密封盒口令解密后登录(仅设环境变量不会自动登录)',
'ui.localKey': '解密密钥',
'ui.username': '用户名',
'ui.password': '密码',
'ui.login': '登录',
@ -136,6 +140,10 @@ export const MESSAGES = {
'err.upstream_failed': '上游请求失败',
'err.skill_credentials_not_ready': 'skill 凭证未就绪',
'err.outbound_not_ready': 'outbound 未就绪',
'err.local_admin_not_configured': '未配置本机管理员密封盒',
'err.key_required': '请输入解密密钥',
'err.decrypt_failed': '密钥无法解密,登录失败',
'err.rate_limited': '尝试过多,请稍后再试',
// API success
'ok.logged_out': '已退出登录',
@ -146,6 +154,7 @@ export const MESSAGES = {
'ok.fallback_password_set': '应急管理员密码已设置',
'ok.fallback_password_cleared': '应急管理员密码已清除',
'ok.fallback_login': '应急管理员登录成功',
'ok.local_admin_unlock': '本机密钥解锁成功',
},
en: {
'role.super_admin': 'Super admin',
@ -200,6 +209,10 @@ export const MESSAGES = {
'ui.fallbackLink': 'UAC down? Emergency account',
'ui.fallbackLogin': 'Emergency login',
'ui.fallbackDetail': 'Use when UAC / QR is unavailable',
'ui.localKeyLink': 'Unlock with local key',
'ui.localKeyLogin': 'Local key unlock',
'ui.localKeyDetail': 'Decrypt the sealed box with your passphrase (env alone does nothing)',
'ui.localKey': 'Decryption key',
'ui.username': 'Username',
'ui.password': 'Password',
'ui.login': 'Sign in',
@ -266,6 +279,10 @@ export const MESSAGES = {
'err.upstream_failed': 'upstream failed',
'err.skill_credentials_not_ready': 'skill credentials not ready',
'err.outbound_not_ready': 'outbound not ready',
'err.local_admin_not_configured': 'Local admin sealed box is not configured',
'err.key_required': 'Decryption key required',
'err.decrypt_failed': 'Key could not decrypt — sign-in failed',
'err.rate_limited': 'Too many attempts, try later',
'ok.logged_out': 'Signed out',
'ok.config_saved': 'Config saved',
@ -275,6 +292,7 @@ export const MESSAGES = {
'ok.fallback_password_set': 'Emergency admin password set',
'ok.fallback_password_cleared': 'Emergency admin password cleared',
'ok.fallback_login': 'Emergency admin signed in',
'ok.local_admin_unlock': 'Local admin unlocked',
},
}

View file

@ -10,6 +10,16 @@ import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { createHash, randomBytes } from 'node:crypto'
import {
logLocalAdminStatus,
appendLocalAdminCookie,
isLocalAdminBoxConfigured,
readLocalAdminBox,
openLocalAdminBox,
allowUnlockAttempt,
buildLocalAdminUserContext,
LOCAL_ADMIN_BOX_ENV,
} from './local-admin.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
@ -454,6 +464,30 @@ function isHttpsRequest(req) {
return xf === 'https'
}
function setFallbackAdminCookies(req, res, extraCookies = []) {
const fbMaxAge = 7 * 24 * 60 * 60
const secure = isHttpsRequest(req)
const partsUser = [
'UDS_FALLBACK_USER=administrator',
`Max-Age=${fbMaxAge}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
]
const partsUi = [
'UDS_FALLBACK_UI=administrator',
`Max-Age=${fbMaxAge}`,
'Path=/',
'SameSite=Lax',
]
if (secure) {
partsUser.push('Secure')
partsUi.push('Secure')
}
const list = [partsUser.join('; '), partsUi.join('; '), ...extraCookies]
res.setHeader('Set-Cookie', list)
}
async function handleFallbackLogin(req, res) {
let body = ''
for await (const chunk of req) body += chunk
@ -488,29 +522,7 @@ async function handleFallbackLogin(req, res) {
await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
await ensureUserWorkspace(empNo)
// 给浏览器设 cookie,让后续请求 auth-middleware 能识别
const fbMaxAge = Math.floor(INTERNAL.session.cookieMaxAge / 1000)
res.setHeader('Set-Cookie', (() => {
const secure = isHttpsRequest(req)
const partsUser = [
'UDS_FALLBACK_USER=administrator',
`Max-Age=${fbMaxAge}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
]
const partsUi = [
'UDS_FALLBACK_UI=administrator',
`Max-Age=${fbMaxAge}`,
'Path=/',
'SameSite=Lax',
]
if (secure) {
partsUser.push('Secure')
partsUi.push('Secure')
}
return [partsUser.join('; '), partsUi.join('; ')]
})())
setFallbackAdminCookies(req, res)
sendOkMsg(res, req, 'fallback_login', null, userContext, {
success: true,
@ -519,6 +531,62 @@ async function handleFallbackLogin(req, res) {
})
}
/**
* Decrypt UDS_AUTH_LOCAL_ADMIN_BOX with operator passphrase → admin session.
* Env ciphertext alone never grants login.
*/
async function handleLocalAdminUnlock(req, res) {
if (!isLocalAdminBoxConfigured()) {
return sendErr(res, req, 404, 'local_admin_not_configured')
}
const ip = req.socket?.remoteAddress || 'unknown'
if (!allowUnlockAttempt(ip)) {
return sendErr(res, req, 429, 'rate_limited')
}
let body = ''
for await (const chunk of req) body += chunk
let parsed
try { parsed = JSON.parse(body) } catch { parsed = {} }
const key = String(parsed.key || parsed.passphrase || parsed.password || '').trim()
if (!key) {
return sendErr(res, req, 400, 'key_required')
}
const opened = openLocalAdminBox(readLocalAdminBox(), key)
if (!opened.ok) {
return sendErr(res, req, 401, 'decrypt_failed')
}
const empNo = opened.empNo
const userContext = buildLocalAdminUserContext()
await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
await ensureUserWorkspace(empNo)
// Build local-admin session cookie into the same Set-Cookie batch.
const fakeRes = {
headersSent: false,
_cookies: [],
getHeader(name) {
if (String(name).toLowerCase() === 'set-cookie') return this._cookies
return undefined
},
setHeader(name, value) {
if (String(name).toLowerCase() === 'set-cookie') {
this._cookies = Array.isArray(value) ? value : [value]
}
},
}
appendLocalAdminCookie(fakeRes, req)
setFallbackAdminCookies(req, res, fakeRes._cookies)
sendOkMsg(res, req, 'local_admin_unlock', null, userContext, {
success: true,
empNo,
role: 'fallback_admin',
})
}
/** 运行时配置文件路径 — 持久化 _currentConfig 让重启后不丢 */
const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
@ -663,6 +731,16 @@ function handleRequest(req, res) {
if (url === '/api/fallback/status' && method === 'GET') {
return sendJSON(res, 200, { enabled: _rolesStore.isFallbackEnabled() })
}
if (url === '/api/local-admin/status' && method === 'GET') {
return sendJSON(res, 200, {
enabled: isLocalAdminBoxConfigured(),
env: LOCAL_ADMIN_BOX_ENV,
})
}
if (url === '/api/local-admin/unlock' && method === 'POST') {
await handleLocalAdminUnlock(req, res)
return
}
// 以下都需要登录态
if (!ctx2.empNo) {
@ -931,6 +1009,7 @@ async function initServices(ctx, config) {
const rolesFile = resolve(__dirname, '..', 'roles.json')
_rolesStore = new RolesStore({ rolesFile })
await _rolesStore.init()
logLocalAdminStatus(ctx.logger || console)
_sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') })
await _sessionAcl.init()

251
uds-auth/lib/local-admin.js Normal file
View file

@ -0,0 +1,251 @@
/**
* Local admin via sealed box (decrypt-to-unlock).
*
* Env holds only ciphertext:
* UDS_AUTH_LOCAL_ADMIN_BOX=<base64url sealed blob>
*
* 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"
*/
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
timingSafeEqual,
createHash,
} from 'node:crypto'
import { ROLES, computePermissions, DEFAULT_FALLBACK_USERNAME } from './roles.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_COOKIE = 'UDS_LOCAL_ADMIN'
export const LOCAL_ADMIN_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
const MAGIC = 'uds-local-admin-v1'
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const KEY_LEN = 32
const SALT_LEN = 16
const IV_LEN = 12
const MIN_PASSPHRASE_LEN = 12
function b64urlEncode(buf) {
return Buffer.from(buf).toString('base64url')
}
function b64urlDecode(str) {
return Buffer.from(String(str), 'base64url')
}
function deriveKey(passphrase, salt) {
return scryptSync(passphrase, salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: 64 * 1024 * 1024,
})
}
/**
* Seal plaintext capability with passphrase → env-safe box string.
* @param {string} passphrase
* @returns {string}
*/
export function sealLocalAdminBox(passphrase) {
const pw = String(passphrase || '')
if (pw.length < MIN_PASSPHRASE_LEN) {
throw new Error(`passphrase must be at least ${MIN_PASSPHRASE_LEN} characters`)
}
const salt = randomBytes(SALT_LEN)
const iv = randomBytes(IV_LEN)
const key = deriveKey(pw, salt)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const plaintext = Buffer.from(`${MAGIC}|${DEFAULT_FALLBACK_USERNAME}`, 'utf8')
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()])
const tag = cipher.getAuthTag()
// version(1) | salt | iv | tag | ciphertext
const out = Buffer.concat([Buffer.from([1]), salt, iv, tag, enc])
return b64urlEncode(out)
}
/**
* @param {string} box
* @param {string} passphrase
* @returns {{ ok: true, empNo: string } | { ok: false, reason: string }}
*/
export function openLocalAdminBox(box, passphrase) {
const pw = String(passphrase || '')
if (!box || !pw) return { ok: false, reason: 'missing' }
let raw
try {
raw = b64urlDecode(box)
} catch {
return { ok: false, reason: 'bad_box' }
}
if (raw.length < 1 + SALT_LEN + IV_LEN + 16 + 1) {
return { ok: false, reason: 'bad_box' }
}
const version = raw[0]
if (version !== 1) return { ok: false, reason: 'bad_version' }
let o = 1
const salt = raw.subarray(o, o + SALT_LEN); o += SALT_LEN
const iv = raw.subarray(o, o + IV_LEN); o += IV_LEN
const tag = raw.subarray(o, o + 16); o += 16
const enc = raw.subarray(o)
try {
const key = deriveKey(pw, salt)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const plain = Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
const [magic, empNo] = plain.split('|')
if (magic !== MAGIC || empNo !== DEFAULT_FALLBACK_USERNAME) {
return { ok: false, reason: 'bad_payload' }
}
return { ok: true, empNo: DEFAULT_FALLBACK_USERNAME }
} catch {
return { ok: false, reason: 'decrypt_failed' }
}
}
export function readLocalAdminBox() {
return String(process.env[LOCAL_ADMIN_BOX_ENV] || '').trim() || null
}
export function isLocalAdminBoxConfigured() {
const box = readLocalAdminBox()
if (!box) return false
try {
const raw = b64urlDecode(box)
return raw.length > 40 && raw[0] === 1
} catch {
return false
}
}
/** Session cookie token after successful unlock (HMAC of box+empNo, not the passphrase). */
export function localAdminSessionToken(box = readLocalAdminBox()) {
if (!box) return null
return createHash('sha256').update(`sess:${box}`).digest('hex')
}
function safeEqualStr(a, b) {
if (a == null || b == null) return false
const ha = createHash('sha256').update(String(a)).digest()
const hb = createHash('sha256').update(String(b)).digest()
return timingSafeEqual(ha, hb)
}
function parseCookie(header, name) {
if (!header || typeof header !== 'string') return null
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx < 0) continue
if (part.slice(0, idx).trim() !== name) continue
try {
return decodeURIComponent(part.slice(idx + 1).trim())
} catch {
return part.slice(idx + 1).trim()
}
}
return null
}
/**
* After unlock, browser holds UDS_LOCAL_ADMIN session token (not the passphrase).
* This only proves a prior successful decrypt on this browser — does not skip decrypt on first login.
*/
export function requestHasLocalAdminSession(req) {
const expect = localAdminSessionToken()
if (!expect) return false
const got = parseCookie(req?.headers?.cookie || '', LOCAL_ADMIN_COOKIE)
return !!(got && safeEqualStr(got, expect))
}
export function buildLocalAdminUserContext() {
const now = new Date().toISOString()
return {
empNo: DEFAULT_FALLBACK_USERNAME,
userId: DEFAULT_FALLBACK_USERNAME,
username: 'Local Admin',
displayName: 'Local Admin',
isAuthenticated: true,
role: ROLES.FALLBACK_ADMIN,
authMode: 'local-admin-unlock',
authenticatedAt: now,
lastActiveAt: now,
sessionCreatedAt: now,
}
}
export function buildLocalAdminIdentity(rolesStore) {
const empNo = DEFAULT_FALLBACK_USERNAME
const role = rolesStore?.getRole?.(empNo) || ROLES.FALLBACK_ADMIN
return {
empNo,
role,
permissions: computePermissions(role),
userContext: buildLocalAdminUserContext(),
kind: 'fallback',
}
}
export function appendLocalAdminCookie(res, req) {
const token = localAdminSessionToken()
if (!token || !res || res.headersSent) return
const secure = !!(req?.socket?.encrypted)
|| String(req?.headers?.['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase() === 'https'
const parts = [
`${LOCAL_ADMIN_COOKIE}=${token}`,
`Max-Age=${LOCAL_ADMIN_COOKIE_MAX_AGE}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
]
if (secure) parts.push('Secure')
const prev = res.getHeader('Set-Cookie')
const next = parts.join('; ')
if (!prev) res.setHeader('Set-Cookie', next)
else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, next])
else res.setHeader('Set-Cookie', [String(prev), next])
}
export function logLocalAdminStatus(logger = console) {
if (!isLocalAdminBoxConfigured()) {
if (process.env[LOCAL_ADMIN_ENV]) {
const log = logger?.warn?.bind(logger) || console.warn
log(
`[uds-auth] ${LOCAL_ADMIN_ENV} is ignored.`
+ ` Use ${LOCAL_ADMIN_BOX_ENV} (sealed ciphertext) + unlock with passphrase.`,
)
}
return
}
const log = logger?.info?.bind(logger) || console.info
log(
`[uds-auth] local admin box configured (${LOCAL_ADMIN_BOX_ENV}).`
+ ' Unlock requires decrypting with your passphrase — env alone does not grant login.',
)
}
/** Simple in-memory rate limit for unlock attempts. */
const unlockBuckets = new Map()
export function allowUnlockAttempt(ip) {
const now = Date.now()
let b = unlockBuckets.get(ip)
if (!b || now > b.resetAt) {
b = { count: 0, resetAt: now + 15 * 60 * 1000 }
unlockBuckets.set(ip, b)
}
b.count += 1
return b.count <= 10
}

View file

@ -10,7 +10,8 @@ import { searchUserByEmpNoToken } from '../uds/user-search.js'
* (带 X-Emp-No / X-Auth-Value)直连内网拉用户详情;成功才建会话。
* 出站请求绕过 HTTP(S)_PROXY。
*
* 兜底登录:仅认可已由 /api/fallback/login 写好的 administrator 会话。
* 兜底登录:仅认可已由 /api/fallback/login 或 /api/local-admin/unlock 写好的
* administrator 会话;重启后若仅有 UDS_FALLBACK_USER cookie,会重建内存会话。
*
* 可选 onSkillCredentials(empNo, token):UI 会话写入成功后并行写入 skill 凭证缓存。
*/
@ -91,6 +92,25 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
return false
}
async function attachUser(ctx, empNo, userContext, kind, { persist = false } = {}) {
if (persist || slidingExpiration) {
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(
empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
}
if (userContext.token) {
try { onSkillCredentials?.(empNo, userContext.token) } catch { /* ignore */ }
}
const role = await resolveRole(empNo, kind)
ctx.userContext = userContext
ctx.empNo = empNo
ctx.role = role
ctx.permissions = computePermissions(role)
}
async function authMiddleware(ctx, next) {
const { req } = ctx
const cookieHeader = req.headers.cookie || ''
@ -125,26 +145,26 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
}
if (userContext) {
if (slidingExpiration) {
userContext.lastActiveAt = new Date().toISOString()
await sessionStore.setex(
extracted.empNo,
Math.floor(cookieMaxAge / 1000),
userContext,
)
}
if (userContext.token) {
try { onSkillCredentials?.(extracted.empNo, userContext.token) } catch { /* ignore */ }
}
const role = await resolveRole(extracted.empNo, extracted.kind)
ctx.userContext = userContext
ctx.empNo = extracted.empNo
ctx.role = role
ctx.permissions = computePermissions(role)
await attachUser(ctx, extracted.empNo, userContext, extracted.kind)
return next()
}
// Fallback cookie survives process restart; memory session does not — rebuild.
if (extracted.kind === 'fallback') {
const empNo = extracted.empNo || 'administrator'
userContext = {
empNo,
userId: empNo,
username: 'Fallback Administrator',
displayName: 'Fallback Administrator',
isAuthenticated: true,
role: ROLES.FALLBACK_ADMIN,
authMode: 'fallback-cookie',
authenticatedAt: new Date().toISOString(),
lastActiveAt: new Date().toISOString(),
sessionCreatedAt: new Date().toISOString(),
}
await attachUser(ctx, empNo, userContext, 'fallback', { persist: true })
return next()
}

View file

@ -0,0 +1,25 @@
#!/usr/bin/env node
/**
* Generate UDS_AUTH_LOCAL_ADMIN_BOX for local decrypt-to-unlock admin.
*
* Usage:
* node scripts/seal-local-admin.mjs "your-passphrase-here"
*
* Then set the printed env on the Harness process (keep the passphrase offline).
*/
import { sealLocalAdminBox, LOCAL_ADMIN_BOX_ENV } from '../lib/local-admin.js'
const passphrase = process.argv[2]
if (!passphrase) {
console.error('Usage: node scripts/seal-local-admin.mjs "<passphrase>"')
process.exit(1)
}
try {
const box = sealLocalAdminBox(passphrase)
console.log(`# Keep the passphrase secret. Only the box goes into the process env.`)
console.log(`${LOCAL_ADMIN_BOX_ENV}=${box}`)
} catch (err) {
console.error(err.message || err)
process.exit(1)
}

View file

@ -0,0 +1,53 @@
import { describe, it, after } from 'node:test'
import assert from 'node:assert/strict'
import {
LOCAL_ADMIN_BOX_ENV,
LOCAL_ADMIN_ENV,
sealLocalAdminBox,
openLocalAdminBox,
isLocalAdminBoxConfigured,
readLocalAdminBox,
} from '../lib/local-admin.js'
describe('local-admin sealed box', () => {
const prevBox = process.env[LOCAL_ADMIN_BOX_ENV]
const prevKey = process.env[LOCAL_ADMIN_ENV]
after(() => {
if (prevBox === undefined) delete process.env[LOCAL_ADMIN_BOX_ENV]
else process.env[LOCAL_ADMIN_BOX_ENV] = prevBox
if (prevKey === undefined) delete process.env[LOCAL_ADMIN_ENV]
else process.env[LOCAL_ADMIN_ENV] = prevKey
})
it('seal + open with correct passphrase', () => {
const passphrase = 'my-local-secret-key'
const box = sealLocalAdminBox(passphrase)
assert.ok(box.length > 40)
const ok = openLocalAdminBox(box, passphrase)
assert.equal(ok.ok, true)
assert.equal(ok.empNo, 'administrator')
})
it('wrong passphrase fails decrypt', () => {
const box = sealLocalAdminBox('correct-passphrase-xx')
const bad = openLocalAdminBox(box, 'wrong-passphrase-yyy')
assert.equal(bad.ok, false)
assert.equal(bad.reason, 'decrypt_failed')
})
it('env box alone does not imply auto-login; only configures unlock', () => {
delete process.env[LOCAL_ADMIN_BOX_ENV]
assert.equal(isLocalAdminBoxConfigured(), false)
const box = sealLocalAdminBox('another-strong-key')
process.env[LOCAL_ADMIN_BOX_ENV] = box
assert.equal(isLocalAdminBoxConfigured(), true)
assert.equal(readLocalAdminBox(), box)
// Old KEY env must not unlock without decrypt
process.env[LOCAL_ADMIN_ENV] = 'aaaaaaaaaaaaaaaa'
const stillNeedDecrypt = openLocalAdminBox(box, process.env[LOCAL_ADMIN_ENV])
assert.equal(stillNeedDecrypt.ok, false)
})
})