From fba999e8e9661d2a474dc918773309f026d1e656 Mon Sep 17 00:00:00 2001 From: Paperclip Bot Date: Sat, 6 Jun 2026 04:12:00 +0000 Subject: [PATCH] Fix legacy local secret key path fallback --- .../local-encrypted-provider.test.ts | 67 +++++++++++++++++++ .../src/secrets/local-encrypted-provider.ts | 22 +++++- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/local-encrypted-provider.test.ts diff --git a/server/src/__tests__/local-encrypted-provider.test.ts b/server/src/__tests__/local-encrypted-provider.test.ts new file mode 100644 index 00000000..9d53004c --- /dev/null +++ b/server/src/__tests__/local-encrypted-provider.test.ts @@ -0,0 +1,67 @@ +import { randomBytes } from "node:crypto"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; + +describe("localEncryptedProvider legacy key-path fallback", () => { + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + const tmpDirs: string[] = []; + + afterEach(() => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + if (previousMasterKey === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey; + for (const dir of tmpDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prefers the legacy $HOME/instances key when PAPERCLIP_HOME is unset", async () => { + const homeDir = path.join(os.tmpdir(), `paperclip-legacy-key-${randomBytes(6).toString("hex")}`); + tmpDirs.push(homeDir); + process.env.HOME = homeDir; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + + const legacyKeyPath = path.join(homeDir, "instances", "default", "secrets", "master.key"); + const modernKeyPath = path.join(homeDir, ".paperclip", "instances", "default", "secrets", "master.key"); + mkdirSync(path.dirname(legacyKeyPath), { recursive: true }); + mkdirSync(path.dirname(modernKeyPath), { recursive: true }); + writeFileSync(legacyKeyPath, randomBytes(32).toString("base64"), "utf8"); + writeFileSync(modernKeyPath, randomBytes(32).toString("base64"), "utf8"); + + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = legacyKeyPath; + const prepared = await localEncryptedProvider.createSecret({ value: "forgejo-token" }); + + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + await expect( + localEncryptedProvider.resolveVersion({ + material: prepared.material, + externalRef: prepared.externalRef, + providerConfig: null, + context: { + companyId: "company-1", + secretId: "secret-1", + version: 1, + }, + }), + ).resolves.toBe("forgejo-token"); + + await expect(localEncryptedProvider.healthCheck()).resolves.toMatchObject({ + provider: "local_encrypted", + details: { + keyFilePath: legacyKeyPath, + }, + }); + }); +}); diff --git a/server/src/secrets/local-encrypted-provider.ts b/server/src/secrets/local-encrypted-provider.ts index e19ccc47..e0f01912 100644 --- a/server/src/secrets/local-encrypted-provider.ts +++ b/server/src/secrets/local-encrypted-provider.ts @@ -1,7 +1,8 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js"; +import { resolveDefaultSecretsKeyFilePath, resolvePaperclipInstanceId } from "../home-paths.js"; import type { PreparedSecretVersion, SecretProviderHealthCheck, @@ -21,7 +22,24 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial { function resolveMasterKeyFilePath() { const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim()); - return resolveDefaultSecretsKeyFilePath(); + const preferredDefault = resolveDefaultSecretsKeyFilePath(); + if (process.env.PAPERCLIP_HOME?.trim()) return preferredDefault; + + // Backwards compatibility for deployments that historically stored instance + // data directly under $HOME/instances/ and later restarted without an + // explicit PAPERCLIP_HOME. Prefer the legacy key when it already exists so + // previously encrypted secrets remain decryptable. + const legacyDefault = path.resolve( + os.homedir(), + "instances", + resolvePaperclipInstanceId(), + "secrets", + "master.key", + ); + if (legacyDefault !== preferredDefault && existsSync(legacyDefault)) { + return legacyDefault; + } + return preferredDefault; } function decodeMasterKey(raw: string): Buffer | null {