feat: add encrypted workspace secret store
This commit is contained in:
@@ -0,0 +1,311 @@
|
|||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
fchmodSync,
|
||||||
|
fsyncSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
|
||||||
|
const STORE_ERROR = "Workspace secret store is unavailable.";
|
||||||
|
const DEFAULT_MAX_SECRET_BYTES = 64 * 1024;
|
||||||
|
const ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
|
||||||
|
|
||||||
|
interface EncryptedEntry {
|
||||||
|
workspaceId: string;
|
||||||
|
requirementId: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultDocument {
|
||||||
|
version: 1;
|
||||||
|
generation: number;
|
||||||
|
entries: Record<string, EncryptedEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSecretMaterialization {
|
||||||
|
files: ReadonlyMap<string, string>;
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSecretStoreOptions {
|
||||||
|
root: string;
|
||||||
|
runtimeRoot?: string;
|
||||||
|
installationId: string;
|
||||||
|
maxSecretBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIdentifier(value: string, label: string): void {
|
||||||
|
if (!ID_PATTERN.test(value)) throw new Error(`Invalid ${label}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(workspaceId: string, requirementId: string): string {
|
||||||
|
return Buffer.from(`${workspaceId}\0${requirementId}`, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function directorySync(path: string): void {
|
||||||
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||||
|
chmodSync(path, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(path: string): void {
|
||||||
|
const fd = openSync(path, constants.O_RDONLY);
|
||||||
|
try {
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicPrivateWrite(path: string, contents: string | Buffer): void {
|
||||||
|
const parent = join(path, "..");
|
||||||
|
const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}`);
|
||||||
|
const fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
||||||
|
try {
|
||||||
|
fchmodSync(fd, 0o600);
|
||||||
|
writeFileSync(fd, contents);
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
renameSync(temporary, path);
|
||||||
|
chmodSync(path, 0o600);
|
||||||
|
syncDirectory(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyVault(): VaultDocument {
|
||||||
|
return { version: 1, generation: 0, entries: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceSecretStore {
|
||||||
|
private readonly root: string;
|
||||||
|
private readonly runtimeRoot: string;
|
||||||
|
private readonly installationId: string;
|
||||||
|
private readonly maxSecretBytes: number;
|
||||||
|
private readonly keyPath: string;
|
||||||
|
private readonly vaultPath: string;
|
||||||
|
|
||||||
|
constructor(options: WorkspaceSecretStoreOptions) {
|
||||||
|
if (!options.installationId.trim()) throw new Error("Installation identifier is required.");
|
||||||
|
this.root = options.root;
|
||||||
|
this.runtimeRoot = options.runtimeRoot ?? join(options.root, "runtime");
|
||||||
|
this.installationId = options.installationId;
|
||||||
|
this.maxSecretBytes = options.maxSecretBytes ?? DEFAULT_MAX_SECRET_BYTES;
|
||||||
|
this.keyPath = join(this.root, "master.key");
|
||||||
|
this.vaultPath = join(this.root, "vault.json");
|
||||||
|
directorySync(this.root);
|
||||||
|
directorySync(this.runtimeRoot);
|
||||||
|
this.ensureInitialized();
|
||||||
|
}
|
||||||
|
|
||||||
|
configured(workspaceId: string): string[] {
|
||||||
|
assertIdentifier(workspaceId, "workspace identifier");
|
||||||
|
const vault = this.readVault();
|
||||||
|
return Object.values(vault.entries)
|
||||||
|
.filter((entry) => entry.workspaceId === workspaceId)
|
||||||
|
.map((entry) => entry.requirementId)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
has(workspaceId: string, requirementId: string): boolean {
|
||||||
|
this.assertIds(workspaceId, requirementId);
|
||||||
|
return this.readVault().entries[entryKey(workspaceId, requirementId)] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
generation(workspaceId: string): number {
|
||||||
|
assertIdentifier(workspaceId, "workspace identifier");
|
||||||
|
const vault = this.readVault();
|
||||||
|
return Object.values(vault.entries).some((entry) => entry.workspaceId === workspaceId)
|
||||||
|
? vault.generation
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
put(workspaceId: string, requirementId: string, value: string): void {
|
||||||
|
this.putMany(workspaceId, { [requirementId]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
putMany(workspaceId: string, values: Readonly<Record<string, string>>): void {
|
||||||
|
assertIdentifier(workspaceId, "workspace identifier");
|
||||||
|
const items = Object.entries(values);
|
||||||
|
if (items.length === 0) throw new Error("At least one workspace secret is required.");
|
||||||
|
for (const [requirementId, value] of items) {
|
||||||
|
assertIdentifier(requirementId, "secret requirement identifier");
|
||||||
|
const size = Buffer.byteLength(value, "utf8");
|
||||||
|
if (size === 0) throw new Error("Workspace secrets cannot be empty.");
|
||||||
|
if (size > this.maxSecretBytes) throw new Error("Workspace secret exceeds the size limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = this.readVault();
|
||||||
|
const key = this.readKey();
|
||||||
|
for (const [requirementId, value] of items) {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
cipher.setAAD(this.additionalData(workspaceId, requirementId));
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||||
|
vault.entries[entryKey(workspaceId, requirementId)] = {
|
||||||
|
workspaceId,
|
||||||
|
requirementId,
|
||||||
|
iv: iv.toString("base64"),
|
||||||
|
tag: cipher.getAuthTag().toString("base64"),
|
||||||
|
ciphertext: ciphertext.toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
vault.generation += 1;
|
||||||
|
this.writeVault(vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
forget(workspaceId: string, requirementId: string): void {
|
||||||
|
this.assertIds(workspaceId, requirementId);
|
||||||
|
const vault = this.readVault();
|
||||||
|
const key = entryKey(workspaceId, requirementId);
|
||||||
|
if (vault.entries[key] === undefined) return;
|
||||||
|
delete vault.entries[key];
|
||||||
|
vault.generation += 1;
|
||||||
|
this.writeVault(vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
materialize(
|
||||||
|
workspaceId: string,
|
||||||
|
requirementIds: readonly string[],
|
||||||
|
): WorkspaceSecretMaterialization {
|
||||||
|
assertIdentifier(workspaceId, "workspace identifier");
|
||||||
|
for (const requirementId of requirementIds) {
|
||||||
|
assertIdentifier(requirementId, "secret requirement identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory: string | undefined;
|
||||||
|
try {
|
||||||
|
const vault = this.readVault();
|
||||||
|
const key = this.readKey();
|
||||||
|
directory = mkdtempSync(join(this.runtimeRoot, "lease-"));
|
||||||
|
chmodSync(directory, 0o700);
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
for (const requirementId of [...new Set(requirementIds)]) {
|
||||||
|
const entry = vault.entries[entryKey(workspaceId, requirementId)];
|
||||||
|
if (entry === undefined) continue;
|
||||||
|
if (entry.workspaceId !== workspaceId || entry.requirementId !== requirementId) {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
"aes-256-gcm",
|
||||||
|
key,
|
||||||
|
Buffer.from(entry.iv, "base64"),
|
||||||
|
);
|
||||||
|
decipher.setAAD(this.additionalData(workspaceId, requirementId));
|
||||||
|
decipher.setAuthTag(Buffer.from(entry.tag, "base64"));
|
||||||
|
const plaintext = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(entry.ciphertext, "base64")),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
const path = join(directory, randomBytes(16).toString("hex"));
|
||||||
|
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o400);
|
||||||
|
try {
|
||||||
|
fchmodSync(fd, 0o400);
|
||||||
|
writeFileSync(fd, plaintext);
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
plaintext.fill(0);
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
files.set(requirementId, path);
|
||||||
|
}
|
||||||
|
let released = false;
|
||||||
|
const leasedDirectory = directory;
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
release: () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
rmSync(leasedDirectory, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
if (directory !== undefined) rmSync(directory, { recursive: true, force: true });
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertIds(workspaceId: string, requirementId: string): void {
|
||||||
|
assertIdentifier(workspaceId, "workspace identifier");
|
||||||
|
assertIdentifier(requirementId, "secret requirement identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
private additionalData(workspaceId: string, requirementId: string): Buffer {
|
||||||
|
return Buffer.from(`${this.installationId}\0${workspaceId}\0${requirementId}`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialized(): void {
|
||||||
|
try {
|
||||||
|
readFileSync(this.keyPath);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "ENOENT") throw new Error(STORE_ERROR);
|
||||||
|
try {
|
||||||
|
atomicPrivateWrite(this.keyPath, randomBytes(32));
|
||||||
|
} catch (writeError) {
|
||||||
|
if ((writeError as NodeJS.ErrnoException).code !== "EEXIST") throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
readFileSync(this.vaultPath);
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "ENOENT") throw new Error(STORE_ERROR);
|
||||||
|
atomicPrivateWrite(this.vaultPath, `${JSON.stringify(emptyVault())}\n`);
|
||||||
|
}
|
||||||
|
this.readKey();
|
||||||
|
this.readVault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readKey(): Buffer {
|
||||||
|
try {
|
||||||
|
const key = readFileSync(this.keyPath);
|
||||||
|
if (key.length !== 32) throw new Error(STORE_ERROR);
|
||||||
|
chmodSync(this.keyPath, 0o600);
|
||||||
|
return key;
|
||||||
|
} catch {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readVault(): VaultDocument {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(this.vaultPath, "utf8")) as Partial<VaultDocument>;
|
||||||
|
if (parsed.version !== 1 || !Number.isSafeInteger(parsed.generation) ||
|
||||||
|
parsed.generation! < 0 || typeof parsed.entries !== "object" || parsed.entries === null) {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
for (const [key, entry] of Object.entries(parsed.entries)) {
|
||||||
|
if (entry === null || typeof entry !== "object" ||
|
||||||
|
typeof entry.workspaceId !== "string" || typeof entry.requirementId !== "string" ||
|
||||||
|
typeof entry.iv !== "string" || typeof entry.tag !== "string" ||
|
||||||
|
typeof entry.ciphertext !== "string" ||
|
||||||
|
key !== entryKey(entry.workspaceId, entry.requirementId)) {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chmodSync(this.vaultPath, 0o600);
|
||||||
|
return parsed as VaultDocument;
|
||||||
|
} catch {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeVault(vault: VaultDocument): void {
|
||||||
|
try {
|
||||||
|
atomicPrivateWrite(this.vaultPath, `${JSON.stringify(vault)}\n`);
|
||||||
|
} catch {
|
||||||
|
throw new Error(STORE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-secret-store-"));
|
||||||
|
const runtimeRoot = mkdtempSync(join(tmpdir(), "thoth-workspace-secret-runtime-"));
|
||||||
|
roots.push(root, runtimeRoot);
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
runtimeRoot,
|
||||||
|
store: new WorkspaceSecretStore({
|
||||||
|
root,
|
||||||
|
runtimeRoot,
|
||||||
|
installationId: "installation-test",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkspaceSecretStore", () => {
|
||||||
|
test("persists ciphertext and exposes status without exposing plaintext", () => {
|
||||||
|
const { root, store } = fixture();
|
||||||
|
const secret = "correct horse battery staple";
|
||||||
|
|
||||||
|
store.put("psd-clinical", "dwh.password", secret);
|
||||||
|
|
||||||
|
expect(store.has("psd-clinical", "dwh.password")).toBe(true);
|
||||||
|
expect(store.configured("psd-clinical")).toEqual(["dwh.password"]);
|
||||||
|
const vault = readFileSync(join(root, "vault.json"), "utf8");
|
||||||
|
expect(vault).not.toContain(secret);
|
||||||
|
expect(statSync(join(root, "vault.json")).mode & 0o777).toBe(0o600);
|
||||||
|
expect(statSync(join(root, "master.key")).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blind replacement changes the materialized value and forget removes it", () => {
|
||||||
|
const { store } = fixture();
|
||||||
|
store.put("psd-clinical", "dwh.password", "old-value");
|
||||||
|
store.put("psd-clinical", "dwh.password", "new-value");
|
||||||
|
|
||||||
|
const lease = store.materialize("psd-clinical", ["dwh.password"]);
|
||||||
|
const path = lease.files.get("dwh.password");
|
||||||
|
expect(path).toBeDefined();
|
||||||
|
expect(readFileSync(path!, "utf8")).toBe("new-value");
|
||||||
|
expect(statSync(path!).mode & 0o777).toBe(0o400);
|
||||||
|
lease.release();
|
||||||
|
expect(existsSync(path!)).toBe(false);
|
||||||
|
|
||||||
|
store.forget("psd-clinical", "dwh.password");
|
||||||
|
expect(store.has("psd-clinical", "dwh.password")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("materializes only requested secrets and cleans the whole lease directory", () => {
|
||||||
|
const { runtimeRoot, store } = fixture();
|
||||||
|
store.putMany("psd-clinical", {
|
||||||
|
"dwh.password": "warehouse-password",
|
||||||
|
"evidence.api_key": "evidence-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
const lease = store.materialize("psd-clinical", ["evidence.api_key"]);
|
||||||
|
expect([...lease.files.keys()]).toEqual(["evidence.api_key"]);
|
||||||
|
expect(readFileSync(lease.files.get("evidence.api_key")!, "utf8")).toBe("evidence-key");
|
||||||
|
expect(statSync(runtimeRoot).mode & 0o777).toBe(0o700);
|
||||||
|
const directory = join(lease.files.get("evidence.api_key")!, "..");
|
||||||
|
lease.release();
|
||||||
|
expect(existsSync(directory)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed with a sanitized error when the encrypted vault is tampered", () => {
|
||||||
|
const { root, store } = fixture();
|
||||||
|
const secret = "must-never-appear-in-errors";
|
||||||
|
store.put("psd-clinical", "dwh.password", secret);
|
||||||
|
|
||||||
|
const path = join(root, "vault.json");
|
||||||
|
const document = JSON.parse(readFileSync(path, "utf8")) as {
|
||||||
|
entries: Record<string, { ciphertext: string }>;
|
||||||
|
};
|
||||||
|
const record = Object.values(document.entries)[0]!;
|
||||||
|
record.ciphertext = Buffer.from("tampered").toString("base64");
|
||||||
|
writeFileSync(path, JSON.stringify(document), { mode: 0o600 });
|
||||||
|
|
||||||
|
expect(() => store.materialize("psd-clinical", ["dwh.password"]))
|
||||||
|
.toThrow("Workspace secret store is unavailable.");
|
||||||
|
try {
|
||||||
|
store.materialize("psd-clinical", ["dwh.password"]);
|
||||||
|
} catch (error) {
|
||||||
|
expect(String(error)).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid identifiers and oversized values", () => {
|
||||||
|
const { store } = fixture();
|
||||||
|
expect(() => store.put("../workspace", "dwh.password", "secret")).toThrow();
|
||||||
|
expect(() => store.put("psd-clinical", "../password", "secret")).toThrow();
|
||||||
|
expect(() => store.put("psd-clinical", "dwh.password", "x".repeat(65_537))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user