mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
136 lines
4.1 KiB
TypeScript
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));
|
||
|
|
},
|
||
|
|
};
|