Files
paperclip/server/src/secrets/local-encrypted-provider.ts
Forgotten 11901ae5d8 Implement secrets service with local encryption, redaction, and runtime resolution
Add AES-256-GCM local encrypted secrets provider with auto-generated
master key, stub providers for AWS/GCP/Vault, and a secrets service
that normalizes adapter configs (converting sensitive inline values to
secret refs in strict mode) and resolves secret refs back to plain
values at runtime. Extract redaction utilities from agent routes into
shared module. Redact sensitive values in activity logs, config
revisions, and approval payloads. Block rollback of revisions
containing redacted secrets. Filter hidden issues from list queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:43:52 -06:00

136 lines
4.1 KiB
TypeScript

import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
import path from "node:path";
import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js";
import { badRequest } from "../errors.js";
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
scheme: "local_encrypted_v1";
iv: string;
tag: string;
ciphertext: string;
}
function resolveMasterKeyFilePath() {
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
return path.resolve(process.cwd(), "data/secrets/master.key");
}
function decodeMasterKey(raw: string): Buffer | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return Buffer.from(trimmed, "hex");
}
try {
const decoded = Buffer.from(trimmed, "base64");
if (decoded.length === 32) return decoded;
} catch {
// ignored
}
if (Buffer.byteLength(trimmed, "utf8") === 32) {
return Buffer.from(trimmed, "utf8");
}
return null;
}
function loadOrCreateMasterKey(): Buffer {
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKeyRaw && envKeyRaw.trim().length > 0) {
const fromEnv = decodeMasterKey(envKeyRaw);
if (!fromEnv) {
throw badRequest(
"Invalid PAPERCLIP_SECRETS_MASTER_KEY (expected 32-byte base64, 64-char hex, or raw 32-char string)",
);
}
return fromEnv;
}
const keyPath = resolveMasterKeyFilePath();
if (existsSync(keyPath)) {
const raw = readFileSync(keyPath, "utf8");
const decoded = decodeMasterKey(raw);
if (!decoded) {
throw badRequest(`Invalid secrets master key at ${keyPath}`);
}
return decoded;
}
const dir = path.dirname(keyPath);
mkdirSync(dir, { recursive: true });
const generated = randomBytes(32);
writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 });
try {
chmodSync(keyPath, 0o600);
} catch {
// best effort
}
return generated;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
scheme: "local_encrypted_v1",
iv: iv.toString("base64"),
tag: tag.toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
}
function decryptValue(masterKey: Buffer, material: LocalEncryptedMaterial): string {
const iv = Buffer.from(material.iv, "base64");
const tag = Buffer.from(material.tag, "base64");
const ciphertext = Buffer.from(material.ciphertext, "base64");
const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plain.toString("utf8");
}
function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncryptedMaterial {
if (
value &&
typeof value === "object" &&
value.scheme === "local_encrypted_v1" &&
typeof value.iv === "string" &&
typeof value.tag === "string" &&
typeof value.ciphertext === "string"
) {
return value as LocalEncryptedMaterial;
}
throw badRequest("Invalid local_encrypted secret material");
}
export const localEncryptedProvider: SecretProviderModule = {
id: "local_encrypted",
descriptor: {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
},
async createVersion(input) {
const masterKey = loadOrCreateMasterKey();
return {
material: encryptValue(masterKey, input.value),
valueSha256: sha256Hex(input.value),
externalRef: null,
};
},
async resolveVersion(input) {
const masterKey = loadOrCreateMasterKey();
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
},
};