Files
PaperClipAI/server/src/secrets/local-encrypted-provider.ts
T
DottaandPaperclip 9c442cf48e feat(connections): deploy and configure Supabase and Vercel (#17217)
Add governed Supabase and Vercel deployment/configuration connectors with vaulted credentials, agent access policies, approvals, audit, and bounded provider operations.

Deliver reviewed REST actions to native agents and guard Vercel preview intent on empty projects. Preserve secret-safe request identity and durable retry behavior.

Verified by current-head CI, fresh Greptile 5/5 review, and real-agent deployment/configuration walkthroughs on disposable provider resources. Test resources and temporary provider tokens were removed after verification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-23 11:37:29 -07:00

314 lines
11 KiB
TypeScript

import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
import type {
PreparedSecretVersion,
SecretProviderHealthCheck,
SecretProviderModule,
SecretProviderValidationResult,
StoredSecretVersionMaterial,
} from "./types.js";
import { badRequest } from "../errors.js";
import { SecretProviderClientError } from "./types.js";
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
scheme: "local_encrypted_v1";
iv: string;
tag: string;
ciphertext: string;
}
export type LocalEncryptedKeyEnv = Partial<Record<
"PAPERCLIP_SECRETS_MASTER_KEY" | "PAPERCLIP_SECRETS_MASTER_KEY_FILE", string | undefined
>>;
function resolveMasterKeyFilePath(env: LocalEncryptedKeyEnv = process.env) {
const fromEnv = env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
return resolveDefaultSecretsKeyFilePath();
}
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(allowCreate = true, env: LocalEncryptedKeyEnv = process.env): Buffer {
const envKeyRaw = 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(env);
if (existsSync(keyPath)) {
enforceKeyFilePermissionsBestEffort(keyPath);
const raw = readFileSync(keyPath, "utf8");
const decoded = decodeMasterKey(raw);
if (!decoded) {
throw badRequest(`Invalid secrets master key at ${keyPath}`);
}
return decoded;
}
if (!allowCreate) throw new Error(`Secrets master key file is missing: ${keyPath}`);
const dir = path.dirname(keyPath);
mkdirSync(dir, { recursive: true });
const generated = randomBytes(32);
const temporaryPath = `${keyPath}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`;
writeFileSync(temporaryPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600, flag: "wx" });
try {
// A hard link publishes a complete file without overwriting a competitor's
// key. Opening the destination with wx would expose an empty/partial file.
try {
linkSync(temporaryPath, keyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
}
} finally {
unlinkSync(temporaryPath);
}
return loadOrCreateMasterKey(false, env);
}
/** Derive a purpose-specific instance secret without exposing or reusing the vault key. */
export function deriveLocalEncryptedInstanceSecret(purpose: string, env: LocalEncryptedKeyEnv = process.env): string {
return Buffer.from(hkdfSync("sha256", loadOrCreateMasterKey(true, env), Buffer.alloc(0),
`paperclip:instance-secret:v1\0${purpose}`, 32)).toString("base64url");
}
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
try {
const mode = statSync(keyPath).mode & 0o777;
if ((mode & 0o077) !== 0) {
chmodSync(keyPath, 0o600);
}
} catch {
// best effort only; health checks surface persistent permission problems.
}
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function prepareManagedVersion(value: string): PreparedSecretVersion {
const masterKey = loadOrCreateMasterKey();
const valueSha256 = sha256Hex(value);
return {
material: encryptValue(masterKey, value),
valueSha256,
fingerprintSha256: valueSha256,
externalRef: null,
};
}
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKeyRaw && envKeyRaw.trim().length > 0) {
if (!decodeMasterKey(envKeyRaw)) {
return {
provider: "local_encrypted",
status: "error",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
};
}
return {
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
backupGuidance: [
"Back up the configured master key separately from the database.",
"A restore needs both the database metadata and the same master key.",
],
details: { keySource: "env" },
};
}
const keyPath = resolveMasterKeyFilePath();
if (!existsSync(keyPath)) {
return {
provider: "local_encrypted",
status: "warn",
message: `Secrets key file does not exist yet: ${keyPath}`,
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
let mode: number | null = null;
try {
mode = statSync(keyPath).mode & 0o777;
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
try {
const raw = readFileSync(keyPath, "utf8");
if (!decodeMasterKey(raw)) {
return {
provider: "local_encrypted",
status: "error",
message: `Invalid key material in ${keyPath}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
} catch (err) {
return {
provider: "local_encrypted",
status: "error",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
details: { keySource: "file", keyFilePath: keyPath },
};
}
const warnings =
mode !== null && (mode & 0o077) !== 0
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
: [];
return {
provider: "local_encrypted",
status: warnings.length > 0 ? "warn" : "ok",
message: `Local encrypted provider configured with key file ${keyPath}`,
warnings,
backupGuidance: [
"Back up the key file together with database backups.",
"The database alone cannot restore local encrypted secret values.",
],
details: { keySource: "file", keyFilePath: keyPath },
};
}
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);
try {
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plain.toString("utf8");
} catch (err) {
const keyFingerprint = createHash("sha256").update(masterKey).digest("hex").slice(0, 12);
throw new Error(
`Secret decryption failed (master key fingerprint: ${keyFingerprint}). ` +
`This usually means the master key does not match the key used to encrypt the secret. ` +
`Original error: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
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() {
return {
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
};
},
async validateConfig(input): Promise<SecretProviderValidationResult> {
const warnings: string[] = [];
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
warnings.push("Strict secret mode should be enabled for authenticated deployments");
}
const health = await inspectLocalEncryptedHealth();
if (health.status === "error") {
throw badRequest(health.message);
}
warnings.push(...(health.warnings ?? []));
return { ok: true, warnings };
},
async createSecret(input) {
return prepareManagedVersion(input.value);
},
async createVersion(input) {
return prepareManagedVersion(input.value);
},
async linkExternalSecret() {
throw badRequest("local_encrypted does not support external reference secrets");
},
async resolveVersion(input) {
try {
const masterKey = loadOrCreateMasterKey(false);
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
} catch (cause) {
// This boundary contains only local key I/O and decryption, never database
// work. Consumers can handle a vault outage without swallowing DB errors.
throw new SecretProviderClientError({
code: "provider_unavailable", provider: "local_encrypted", operation: "resolveVersion",
message: cause instanceof Error ? cause.message : "Local encrypted secret could not be resolved",
cause,
});
}
},
async deleteOrArchive() {
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
},
async healthCheck() {
return inspectLocalEncryptedHealth();
},
};