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>
This commit is contained in:
Forgotten
2026-02-19 15:43:52 -06:00
parent d26b67ebc3
commit 11901ae5d8
22 changed files with 1083 additions and 61 deletions

View File

@@ -0,0 +1,32 @@
import { unprocessable } from "../errors.js";
import type { SecretProviderModule } from "./types.js";
function unavailableProvider(
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
label: string,
): SecretProviderModule {
return {
id,
descriptor: {
id,
label,
requiresExternalRef: true,
},
async createVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
},
async resolveVersion() {
throw unprocessable(`${id} provider is not configured in this deployment`);
},
};
}
export const awsSecretsManagerProvider = unavailableProvider(
"aws_secrets_manager",
"AWS Secrets Manager",
);
export const gcpSecretManagerProvider = unavailableProvider(
"gcp_secret_manager",
"GCP Secret Manager",
);
export const vaultProvider = unavailableProvider("vault", "HashiCorp Vault");

View File

@@ -0,0 +1,135 @@
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));
},
};

View File

@@ -0,0 +1,30 @@
import type { SecretProvider, SecretProviderDescriptor } from "@paperclip/shared";
import { localEncryptedProvider } from "./local-encrypted-provider.js";
import {
awsSecretsManagerProvider,
gcpSecretManagerProvider,
vaultProvider,
} from "./external-stub-providers.js";
import type { SecretProviderModule } from "./types.js";
import { unprocessable } from "../errors.js";
const providers: SecretProviderModule[] = [
localEncryptedProvider,
awsSecretsManagerProvider,
gcpSecretManagerProvider,
vaultProvider,
];
const providerById = new Map<SecretProvider, SecretProviderModule>(
providers.map((provider) => [provider.id, provider]),
);
export function getSecretProvider(id: SecretProvider): SecretProviderModule {
const provider = providerById.get(id);
if (!provider) throw unprocessable(`Unsupported secret provider: ${id}`);
return provider;
}
export function listSecretProviders(): SecretProviderDescriptor[] {
return providers.map((provider) => provider.descriptor);
}

View File

@@ -0,0 +1,22 @@
import type { SecretProvider, SecretProviderDescriptor } from "@paperclip/shared";
export interface StoredSecretVersionMaterial {
[key: string]: unknown;
}
export interface SecretProviderModule {
id: SecretProvider;
descriptor: SecretProviderDescriptor;
createVersion(input: {
value: string;
externalRef: string | null;
}): Promise<{
material: StoredSecretVersionMaterial;
valueSha256: string;
externalRef: string | null;
}>;
resolveVersion(input: {
material: StoredSecretVersionMaterial;
externalRef: string | null;
}): Promise<string>;
}