208 lines
8.2 KiB
TypeScript
208 lines
8.2 KiB
TypeScript
/**
|
||
* 凭据代理(任务 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;
|
||
}
|
||
} |