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:
32
server/src/secrets/external-stub-providers.ts
Normal file
32
server/src/secrets/external-stub-providers.ts
Normal 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");
|
||||
135
server/src/secrets/local-encrypted-provider.ts
Normal file
135
server/src/secrets/local-encrypted-provider.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
30
server/src/secrets/provider-registry.ts
Normal file
30
server/src/secrets/provider-registry.ts
Normal 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);
|
||||
}
|
||||
22
server/src/secrets/types.ts
Normal file
22
server/src/secrets/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user