broker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { randomUUID } from 'node:crypto'
  2. import { constants, openSync, closeSync, fsyncSync, fstatSync, writeFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { z } from 'zod'
  5. import { HttpError } from '../errors.js'
  6. import { CredentialVaultStore } from './store.js'
  7. import { parse, leaseInput, type LeaseInput } from './schema.js'
  8. import type {CredentialExecutor,CredentialExecutionReceipt} from './executor.js'
  9. import type { CredentialLeaseSummary, CredentialVaultStatus } from '../../shared/credentialVault.js'
  10. interface Lease extends CredentialLeaseSummary {
  11. owner: string; session: string; version: number; epoch: number; runnerInstance: string; runnerEpoch: string
  12. target: LeaseInput['target']; consumed: boolean; controller: AbortController
  13. }
  14. export type CredentialExecutorFixture = CredentialExecutor
  15. export class CredentialVaultBroker {
  16. private sessions = new Map<string, string>()
  17. private epochs = new Map<string, number>()
  18. private leases = new Map<string, Lease>()
  19. private controlEpoch = ''
  20. private timer: ReturnType<typeof setInterval>
  21. constructor(readonly store: CredentialVaultStore, private now = Date.now, private executor?: CredentialExecutor) {
  22. store.onLock = owner => this.revokeOwner(owner)
  23. this.timer = setInterval(() => {
  24. try { this.executor?.assertAvailable?.() } catch { this.disconnect() }
  25. for (const owner of this.sessions.keys()) {
  26. try { store.state(owner) }
  27. catch { store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner) }
  28. }
  29. for (const [id, lease] of this.leases) if (lease.expiresAt <= this.now()) this.revoke(id)
  30. }, 1000); this.timer.unref()
  31. }
  32. private audit(owner: string, action: string, ref?: string, outcome = 'ok') {
  33. // No request body, username, target, secret, transport error or task output.
  34. const row = { at: this.now(), owner, action, ...(ref ? { ref } : {}), outcome }
  35. const path = join(this.store.root, 'audit.jsonl')
  36. let fd: number | undefined
  37. try {
  38. fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600)
  39. const stat = fstatSync(fd)
  40. if (!stat.isFile() || (stat.mode & 0o077) || stat.uid !== process.getuid?.()) throw new Error('密码库审计文件权限不安全')
  41. writeFileSync(fd, JSON.stringify(row) + '\n'); fsyncSync(fd)
  42. } catch {
  43. this.store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner)
  44. throw new HttpError(503, '密码库审计不可用,已锁定并撤销授权', 'vault_audit_unavailable')
  45. } finally { if (fd !== undefined) closeSync(fd) }
  46. }
  47. hello(epoch: string) {
  48. parse(z.string().uuid(), epoch)
  49. if (epoch !== this.controlEpoch) { this.store.lockAll(); this.revokeAll(); this.sessions.clear(); this.controlEpoch = epoch }
  50. return { protocol: 1, execution: this.execution().execution }
  51. }
  52. private execution():Pick<CredentialVaultStatus,'execution'|'reason'>{
  53. if(!this.executor)return {execution:'disabled',reason:'executor_not_enabled'}
  54. try{this.executor.assertAvailable?.();return {execution:this.executor.mode??'dummy-fixture',reason:this.executor.mode==='protected-adapters'?'operator_approved':'dummy_fixture_only'}}
  55. catch{this.disconnect();return {execution:'disabled',reason:'isolation_required'}}
  56. }
  57. private authorize(owner: string, session: string) {
  58. if (this.sessions.get(owner) !== session || !this.store.state(owner).unlocked)
  59. throw new HttpError(423, '请在本次登录的密码管理界面手动解锁', 'vault_locked')
  60. }
  61. status(owner: string, session: string): CredentialVaultStatus {
  62. const execution=this.execution(),state = this.store.state(owner), own = this.sessions.get(owner) === session && state.unlocked
  63. return { protocol: 1, online: true, initialized: state.initialized, unlocked: own,
  64. ...(own ? { unlockExpiresAt: state.unlockExpiresAt } : {}), ...execution,
  65. entries: own ? this.store.list(owner) : [], leases: own ? [...this.leases.values()].filter(l => l.owner === owner).map(({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt }) =>
  66. ({ id, credentialRef, agentId, workId, runnerId, operation, expiresAt })) : [] }
  67. }
  68. revokeOwner(owner: string) {
  69. this.epochs.set(owner, (this.epochs.get(owner) ?? 0) + 1)
  70. for (const [id, l] of this.leases) if (l.owner === owner) this.revoke(id)
  71. }
  72. private revokeAll() { for (const id of [...this.leases.keys()]) this.revoke(id) }
  73. private revoke(id: string) { const l = this.leases.get(id); this.leases.delete(id); l?.controller.abort() }
  74. disconnect() { this.store.lockAll(); this.revokeAll(); this.sessions.clear() }
  75. close() { clearInterval(this.timer); this.disconnect() }
  76. dispatch(epoch: string, owner: string, session: string, command: string, value: unknown): unknown {
  77. parse(z.string().min(1).max(256), owner); parse(z.string().regex(/^[a-f0-9]{64}$/), session)
  78. if (epoch !== this.controlEpoch) throw new HttpError(410, '控制连接已更换,旧授权失效', 'vault_control_changed')
  79. if (command === 'status') return this.status(owner, session)
  80. if (command === 'initialize') { const body = parse(z.object({ password: z.string() }).strict(), value); this.store.initialize(owner, body.password); this.audit(owner, 'initialize'); return this.status(owner, session) }
  81. if (command === 'restore') {
  82. const b = parse(z.object({ archive: z.string(), password: z.string() }).strict(), value)
  83. this.store.restore(owner, b.archive, b.password); this.audit(owner, 'restore'); return this.status(owner, session)
  84. }
  85. if (command === 'unlock') {
  86. const b = parse(z.object({ password: z.string(), seconds: z.number().int().min(30).max(900).default(300) }).strict(), value)
  87. try { this.store.unlock(owner, b.password, b.seconds); this.sessions.set(owner, session); this.audit(owner, 'unlock') }
  88. catch (e) { this.audit(owner, 'unlock', undefined, 'denied'); throw e }
  89. return this.status(owner, session)
  90. }
  91. if (command === 'logout') { if (this.sessions.get(owner) === session) { this.store.lock(owner); this.sessions.delete(owner); this.audit(owner, 'logout') }; return { ok: true } }
  92. if (command === 'lock') { this.store.lock(owner); this.sessions.delete(owner); this.revokeOwner(owner); this.audit(owner, 'lock'); return { ok: true } }
  93. if (command === 'revoke-work') {
  94. const b = parse(z.object({ workId: z.string().uuid() }).strict(), value)
  95. for (const [id, l] of this.leases) if (l.owner === owner && l.workId === b.workId) this.revoke(id)
  96. this.audit(owner, 'revoke-work', b.workId); return { ok: true }
  97. }
  98. this.authorize(owner, session)
  99. if (command === 'add') { const e = this.store.add(owner, value); this.audit(owner, 'add', e.id); return e }
  100. if (command === 'update') { const b = parse(z.object({ id: z.string().uuid(), entry: z.unknown() }).strict(), value); const e = this.store.update(owner, b.id, b.entry); this.audit(owner, 'update', e.id); return e }
  101. if (command === 'remove') { const b = parse(z.object({ id: z.string().uuid() }).strict(), value); this.store.remove(owner, b.id); this.audit(owner, 'remove', b.id); return { ok: true } }
  102. if (command === 'backup') { this.audit(owner, 'backup'); return { archive: this.store.backup(owner) } }
  103. if (command === 'rotate') { const b = parse(z.object({ password: z.string() }).strict(), value); this.store.rotate(owner, b.password); this.audit(owner, 'rotate'); return { ok: true } }
  104. if (command === 'grant') {
  105. const b = parse(leaseInput, value)
  106. const entry = this.store.list(owner).find(e => e.id === b.credentialRef)
  107. if (!entry || JSON.stringify(entry.target) !== JSON.stringify(b.target) || (entry.target.kind === 'website') !== (b.operation === 'website.login'))
  108. throw new HttpError(403, '凭据目标或操作不匹配', 'vault_target_forbidden')
  109. if(entry.usage&&(entry.usage.kind==='website.form'?'website.login':entry.usage.kind)!==b.operation)throw new HttpError(403,'操作不属于用户批准的凭据使用计划','vault_usage_forbidden')
  110. if ([...this.leases.values()].filter(l => l.owner === owner).length >= 64) throw new HttpError(429, '任务授权数量已达到上限', 'vault_lease_limit')
  111. const { seconds: _seconds, target: _target, runnerInstance: _instance, runnerEpoch: _epoch, ...publicInput } = b
  112. const result: CredentialLeaseSummary = { ...publicInput, id: randomUUID(), expiresAt: Math.min(this.now() + b.seconds * 1000, this.store.state(owner).unlockExpiresAt!) }
  113. this.leases.set(result.id, { ...result, owner, session, version: entry.revision, target: entry.target, runnerInstance: b.runnerInstance,
  114. runnerEpoch: b.runnerEpoch, epoch: this.epochs.get(owner) ?? 0, consumed: false, controller: new AbortController() })
  115. this.audit(owner, 'grant', result.id); return result
  116. }
  117. if (command === 'revoke') {
  118. const b = parse(z.object({ id: z.string().uuid() }).strict(), value)
  119. if (this.leases.get(b.id)?.owner !== owner) throw new HttpError(404, '授权不存在', 'vault_lease_missing')
  120. this.revoke(b.id); this.audit(owner, 'revoke', b.id); return { ok: true }
  121. }
  122. throw new HttpError(404, '密码库操作不存在', 'vault_command_unknown')
  123. }
  124. async execute(epoch: string, owner: string, session: string, id: string, input: LeaseInput) {
  125. const b = parse(leaseInput, input), l = this.leases.get(id)
  126. const check = () => {
  127. if (epoch !== this.controlEpoch || !l || this.leases.get(id) !== l || l.owner !== owner || l.epoch !== (this.epochs.get(owner) ?? 0)
  128. || l.expiresAt <= this.now() || l.controller.signal.aborted) throw new HttpError(410, '凭据任务授权已失效', 'vault_lease_revoked')
  129. this.authorize(owner, l.session)
  130. if (session !== l.session) throw new HttpError(403, '执行请求不属于解锁会话', 'vault_scope_forbidden')
  131. if (['agentId', 'workId', 'runnerId', 'runnerInstance', 'runnerEpoch', 'credentialRef', 'operation'].some(k => l[k as keyof Lease] !== b[k as keyof LeaseInput])
  132. || JSON.stringify(l.target) !== JSON.stringify(b.target)) throw new HttpError(403, '凭据任务范围不匹配', 'vault_scope_forbidden')
  133. const entry = this.store.list(owner).find(e => e.id === l.credentialRef)
  134. if (entry?.revision !== l.version) throw new HttpError(410, '凭据版本已变化', 'vault_lease_revoked')
  135. }
  136. check()
  137. if (!this.executor) throw new HttpError(409, '受保护的浏览器与 SSH 执行器尚未启用,请人工接管', 'vault_executor_not_enabled')
  138. this.executor.assertAvailable?.()
  139. if (l!.consumed) throw new HttpError(409, '此授权已提交,不能重复执行', 'vault_operation_uncertain')
  140. l!.consumed = true
  141. this.audit(owner, 'execute-started', id)
  142. try {
  143. const result=await this.store.withSecret(owner, l!.credentialRef, entry => this.executor!.execute(entry, b, l!.controller.signal))
  144. check()
  145. if(result===undefined&&this.executor.mode==='protected-adapters')throw new Error('protected executor receipt missing')
  146. const receipt:CredentialExecutionReceipt=result===undefined?{status:'complete',operation:b.operation}:parse(z.union([
  147. z.object({status:z.literal('complete'),operation:leaseInput.shape.operation,exitCode:z.number().int().optional(),bytes:z.number().int().nonnegative().optional(),sha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict(),
  148. z.object({status:z.literal('manual_takeover_required'),reason:z.enum(['unsupported_form_flow','usage_policy_required']),submitted:z.boolean()}).strict(),
  149. ]),result)
  150. if(receipt.status==='complete'&&receipt.operation!==b.operation)throw new Error('receipt scope changed')
  151. this.audit(owner, receipt.status==='complete'?'execute-complete':'execute-manual', id)
  152. return receipt // No raw executor output, URL, exception, Cookie or secret.
  153. } catch(error) {
  154. this.audit(owner, 'execute-ended', id, 'not-confirmed')
  155. if(error instanceof HttpError&&['vault_host_key_rejected','vault_usage_forbidden','vault_isolation_required'].includes(error.code))throw error
  156. throw new HttpError(409, '操作结果未确认,授权不再允许重试,请人工核对', 'vault_operation_uncertain')
  157. } finally { this.revoke(id) }
  158. }
  159. connectionClosed(owner:string,session:string,id:string){const l=this.leases.get(id);if(l?.owner===owner&&l.session===session)this.revoke(id)}
  160. }