Spaces:
Paused
Paused
| 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"; | |
| import { resolveDefaultSecretsKeyFilePath } from "../home-paths.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 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(): 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)); | |
| }, | |
| }; | |