/** * 凭据代理(任务 A4.3)。 * * 四条规则(架构设计 4.1): * 1. 静态加密:库里只有密文,明文只在这一层里解开 * 2. 按执行取用:凭据只在一次 execution 的有效期内可用,模型与工具参数里永远没有它 * 3. 不进日志:登记过的明文在 redact() 里统一打码 * 4. 不落参数:参数只留摘要(digestParams),凭据走 ExecutionContext 的句柄通道 */ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { Database } from '../storage/db.js'; import type { IdentitySubject } from '../identity/context.js'; export interface SealedSecret { iv: string; ciphertext: string; tag: string; } /** 一次执行的凭据句柄:绑定 executionId 与有效期,过期或被撤销后 read() 返回 undefined */ export interface ExecutionGrant { grantId: string; executionId: string; providerId: string; accountId: string; expiresAt: number; /** 取凭据明文;越期返回 undefined,调用方不能缓存结果再复用 */ read(): Record | undefined; } export interface CredentialBrokerOptions { db?: Database; /** 主密钥(32 字节);不传则用 keyFile 里的密钥,仍没有就生成 */ masterKey?: Buffer; keyFile?: string; now?: () => number; defaultTtlMs?: number; } interface VaultRow { credential_iv: string | null; credential_ciphertext: string | null; credential_tag: string | null; } const DEFAULT_KEY_FILE = 'secret.key'; const DEFAULT_TTL_MS = 5 * 60_000; /** 主密钥文件:与配置同目录(默认 ~/.aps/secret.key),权限尽量收紧 */ function loadMasterKey(file: string, provided?: Buffer): Buffer { if (provided && provided.length === 32) return provided; if (existsSync(file)) { const key = Buffer.from(readFileSync(file, 'utf8').trim(), 'base64'); if (key.length === 32) return key; } const key = randomBytes(32); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, key.toString('base64'), { encoding: 'utf8', mode: 0o600 }); return key; } export class CredentialBroker { private readonly masterKey: Buffer; private readonly now: () => number; private readonly defaultTtlMs: number; private readonly grants = new Map(); /** 已知明文:只用于日志脱敏,不参与鉴权 */ private readonly known = new Set(); constructor(private readonly options: CredentialBrokerOptions = {}) { this.masterKey = loadMasterKey(options.keyFile ?? DEFAULT_KEY_FILE, options.masterKey); this.now = options.now ?? Date.now; this.defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS; } /** 写入(覆盖)某个平台用户在某个系统里的凭据;密文进库,明文只在内存里短暂经过 */ put(subject: IdentitySubject, providerId: string, secret: Record): void { const plaintext = JSON.stringify(secret); const sealed = this.seal(plaintext); if (this.options.db) { this.options.db .prepare( `update identity_bindings set credential_ref = ?, credential_iv = ?, credential_ciphertext = ?, credential_tag = ?, updated_at = ? where user_id = ? and tenant_id = ? and provider_id = ?`, ) .run(providerId, sealed.iv, sealed.ciphertext, sealed.tag, this.now(), subject.userId, subject.tenantId, providerId); } else { this.memory.set(`${subject.tenantId}:${subject.userId}:${providerId}`, sealed); } for (const value of Object.values(secret)) this.known.add(value); this.known.add(plaintext); } remove(subject: IdentitySubject, providerId: string): void { if (this.options.db) { this.options.db .prepare( `update identity_bindings set credential_ref = null, credential_iv = null, credential_ciphertext = null, credential_tag = null, updated_at = ? where user_id = ? and tenant_id = ? and provider_id = ?`, ) .run(this.now(), subject.userId, subject.tenantId, providerId); } else { this.memory.delete(`${subject.tenantId}:${subject.userId}:${providerId}`); } } has(subject: IdentitySubject, providerId: string): boolean { return this.sealedFor(subject, providerId) !== undefined; } /** 只给用例断言「库里是密文」用,业务代码不该调它 */ sealedFor(subject: IdentitySubject, providerId: string): SealedSecret | undefined { if (this.options.db) { const row = this.options.db .prepare('select credential_iv, credential_ciphertext, credential_tag from identity_bindings where user_id = ? and tenant_id = ? and provider_id = ?') .get(subject.userId, subject.tenantId, providerId) as VaultRow | undefined; if (!row || row.credential_ciphertext === null || row.credential_iv === null || row.credential_tag === null) return undefined; return { iv: row.credential_iv, ciphertext: row.credential_ciphertext, tag: row.credential_tag }; } return this.memory.get(`${subject.tenantId}:${subject.userId}:${providerId}`); } /** 签发一次性执行授权:只有这次 execution 能用,过期或撤销即失效 */ issue(input: { subject: IdentitySubject; providerId: string; accountId: string; executionId: string; ttlMs?: number }): ExecutionGrant { const key = `${input.subject.tenantId}:${input.subject.userId}:${input.providerId}`; const executor = this.now; const expiresAt = this.now() + (input.ttlMs ?? this.defaultTtlMs); const grant: ExecutionGrant = { grantId: `grant-${randomUUID()}`, executionId: input.executionId, providerId: input.providerId, accountId: input.accountId, expiresAt, read: () => { if (executor() > expiresAt) return undefined; const sealed = this.sealedByKey(key); if (!sealed) return undefined; const plaintext = this.open(sealed); if (plaintext === undefined) return undefined; try { const parsed = JSON.parse(plaintext) as unknown; return parsed !== null && typeof parsed === 'object' ? (parsed as Record) : undefined; } catch { return undefined; } }, }; this.grants.set(grant.grantId, grant); return grant; } revoke(grantId: string): void { this.grants.delete(grantId); } /** 通用密封:待确认动作的参数要跨重启存活,但库里不能有明文 */ sealText(text: string): SealedSecret { return this.seal(text); } openText(sealed: SealedSecret): string | undefined { return this.open(sealed); } /** 日志脱敏:把登记过的明文换成 *** */ redact(text: string): string { let output = text; for (const secret of this.known) { if (secret.length >= 4) output = output.split(secret).join('***'); } return output; } private readonly memory = new Map(); private sealedByKey(key: string): SealedSecret | undefined { if (!this.options.db) return this.memory.get(key); const [tenantId, userId, providerId] = key.split(':'); if (tenantId === undefined || userId === undefined || providerId === undefined) return undefined; return this.sealedFor({ tenantId, userId }, providerId); } private seal(plaintext: string): SealedSecret { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', this.masterKey, iv); const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); return { iv: iv.toString('base64'), ciphertext: ciphertext.toString('base64'), tag: cipher.getAuthTag().toString('base64') }; } private open(sealed: SealedSecret): string | undefined { try { const decipher = createDecipheriv('aes-256-gcm', this.masterKey, Buffer.from(sealed.iv, 'base64')); decipher.setAuthTag(Buffer.from(sealed.tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(sealed.ciphertext, 'base64')), decipher.final()]).toString('utf8'); } catch { // 密钥换过或密文被改过:当作没有凭据,绝不降级成明文 return undefined; } } /** 列出当前还活着的授权数量(观测用) */ activeGrants(): number { return this.grants.size; } }