aps-agent/server/core/credentials/broker.ts

208 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 凭据代理(任务 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<string, string> | 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<string, ExecutionGrant>();
/** 已知明文:只用于日志脱敏,不参与鉴权 */
private readonly known = new Set<string>();
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<string, string>): 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<string, string>) : 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<string, SealedSecret>();
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;
}
}