Encrypt cloud CLI credential store

This commit is contained in:
Dotta committed 2026-05-19 14:57:49 -05:00
1 parent cee803688c
commit 45ee787937
4 files changed
+176 -6

No files matched your search

+31 -1
View File
@@ -14,7 +14,7 @@ import {
normalizedContentHash,
type LocalUpstreamExportBundle,
} from "../commands/client/cloud-transfer.js";
import { getCloudConnection } from "../commands/client/cloud-store.js";
import { getCloudConnection, resolveCloudConnectionStorePath } from "../commands/client/cloud-store.js";
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
@@ -75,6 +75,11 @@ describe("cloud CLI helpers", () => {
expect(connection.accessToken).toBe("upt_test");
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
const rawStore = fs.readFileSync(resolveCloudConnectionStorePath(), "utf8");
expect(rawStore).not.toContain("upt_test");
expect(rawStore).not.toContain("PRIVATE KEY");
expect(rawStore).toContain("accessTokenMaterial");
expect(rawStore).toContain("privateKeyMaterial");
});
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
@@ -116,6 +121,31 @@ describe("cloud CLI helpers", () => {
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
});
it("uses the manifest-only request shape for CLI previews", async () => {
const bundle = await buildTestBundle();
const calls: Array<{ path: string; body: unknown }> = [];
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: "https://cloud.example.test",
paperclipCompanyId: "target-company-1",
fetch: async (url, init) => {
const parsed = new URL(String(url));
calls.push({ path: parsed.pathname, body: init?.body ? JSON.parse(String(init.body)) as unknown : {} });
return jsonResponse({ warnings: [], conflicts: [] });
},
});
await coordinator.preview(bundle);
expect(calls).toHaveLength(1);
expect(calls[0]?.path).toBe("/api/companies/target-company-1/upstream-imports/preview");
expect(calls[0]?.body).toMatchObject({
manifest: bundle.manifest,
previewShape: "manifest_only",
conflictKeysBySource: {},
});
expect(calls[0]?.body).not.toHaveProperty("entities");
});
});
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
+125 -3
View File
@@ -1,3 +1,4 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolvePaperclipInstanceRoot } from "../../config/home.js";
@@ -39,6 +40,13 @@ interface CloudConnectionStore {
currentConnectionId?: string;
}
interface EncryptedCloudCredential {
scheme: "local_encrypted_v1";
iv: string;
tag: string;
ciphertext: string;
}
function defaultStore(): CloudConnectionStore {
return {
version: 1,
@@ -76,7 +84,7 @@ export function writeCloudConnectionStore(
storePath = resolveCloudConnectionStorePath(),
): void {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
fs.writeFileSync(storePath, `${JSON.stringify(serializeStore(store), null, 2)}\n`, { mode: 0o600 });
}
export function upsertCloudConnection(
@@ -130,11 +138,11 @@ function normalizeConnection(value: unknown): CloudConnection | null {
const targetHost = stringValue(record.targetHost);
const stackId = stringValue(record.stackId);
const targetCompanyId = stringValue(record.targetCompanyId);
const accessToken = stringValue(record.accessToken);
const accessToken = readCredential(record, "accessToken", "accessTokenMaterial");
const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
? record.token as CloudConnectionTokenRecord
: null;
const privateKeyPem = stringValue(record.privateKeyPem);
const privateKeyPem = readCredential(record, "privateKeyPem", "privateKeyMaterial");
const sourcePublicKey = stringValue(record.sourcePublicKey);
const sourceInstanceId = stringValue(record.sourceInstanceId);
const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
@@ -175,3 +183,117 @@ function stringValue(value: unknown): string | null {
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
}
function readCredential(record: Record<string, unknown>, plaintextKey: string, materialKey: string): string | null {
const material = record[materialKey];
if (material && typeof material === "object" && !Array.isArray(material)) {
try {
return decryptCredential(material as Record<string, unknown>);
} catch {
return null;
}
}
return stringValue(record[plaintextKey]);
}
function serializeStore(store: CloudConnectionStore): Record<string, unknown> {
const connections: Record<string, unknown> = {};
for (const [id, connection] of Object.entries(store.connections)) {
connections[id] = serializeConnection(connection);
}
return {
version: 1,
connections,
...(store.currentConnectionId ? { currentConnectionId: store.currentConnectionId } : {}),
};
}
function serializeConnection(connection: CloudConnection): Record<string, unknown> {
const { accessToken, privateKeyPem, ...rest } = connection;
return {
...rest,
accessTokenMaterial: encryptCredential(accessToken),
privateKeyMaterial: encryptCredential(privateKeyPem),
};
}
function encryptCredential(value: string): EncryptedCloudCredential {
const iv = randomBytes(12);
const key = loadOrCreateCredentialKey();
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
return {
scheme: "local_encrypted_v1",
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
}
function decryptCredential(material: Record<string, unknown>): string {
if (
material.scheme !== "local_encrypted_v1" ||
typeof material.iv !== "string" ||
typeof material.tag !== "string" ||
typeof material.ciphertext !== "string"
) {
throw new Error("Invalid encrypted cloud credential material");
}
const decipher = createDecipheriv(
"aes-256-gcm",
loadOrCreateCredentialKey(),
Buffer.from(material.iv, "base64"),
);
decipher.setAuthTag(Buffer.from(material.tag, "base64"));
const plain = Buffer.concat([
decipher.update(Buffer.from(material.ciphertext, "base64")),
decipher.final(),
]);
return plain.toString("utf8");
}
function loadOrCreateCredentialKey(): Buffer {
const envKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envKey?.trim()) {
const decoded = decodeCredentialKey(envKey);
if (!decoded) throw new Error("Invalid PAPERCLIP_SECRETS_MASTER_KEY");
return decoded;
}
const keyPath = path.resolve(
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE?.trim() ||
path.join(resolvePaperclipInstanceRoot(), "secrets", "master.key"),
);
if (fs.existsSync(keyPath)) {
const decoded = decodeCredentialKey(fs.readFileSync(keyPath, "utf8"));
if (!decoded) throw new Error(`Invalid secrets master key at ${keyPath}`);
return decoded;
}
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
const generated = randomBytes(32);
fs.writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 });
try {
fs.chmodSync(keyPath, 0o600);
} catch {
// Best effort; the credential store itself is also written 0600.
}
return generated;
}
function decodeCredentialKey(raw: string): Buffer | null {
const trimmed = raw.trim();
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;
}
+17 -1
View File
@@ -144,7 +144,8 @@ export class LocalUpstreamPushCoordinator {
async preview(bundle: LocalUpstreamExportBundle): Promise<unknown> {
return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
manifest: bundle.manifest,
entities: bundle.entities,
previewShape: "manifest_only",
conflictKeysBySource: conflictKeysBySource(bundle.entities),
});
}
@@ -191,6 +192,21 @@ export class LocalUpstreamPushCoordinator {
}
}
function conflictKeysBySource(entities: LocalUpstreamExportEntity[]): Record<string, string[]> {
const output: Record<string, string[]> = {};
for (const entity of entities) {
if (!entity.conflictKeys?.length) continue;
output[sourceEntityKeyString(entity.record.key)] = [...entity.conflictKeys];
}
return output;
}
function sourceEntityKeyString(key: SourceEntityKey): string {
return [key.sourceInstanceId, key.sourceCompanyId, key.sourceEntityType, key.sourceEntityId]
.map((part) => encodeURIComponent(part))
.join("/");
}
export function buildLocalUpstreamExportBundle(
input: BuildLocalUpstreamExportBundleInput,
): LocalUpstreamExportBundle {
+3 -1
View File
@@ -340,7 +340,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
}
return updateRun(row.id, {
status: "cancelled",
activeStep: "push",
activeStep: row.activeStep,
progressPercent: 100,
completedAt: new Date(),
events: [
@@ -500,6 +500,8 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
const completedAt = new Date();
const finalEvents = [
...input.initialEvents,
event(completedAt.toISOString(), "scan", "completed", "Scanned the local company inventory."),
event(completedAt.toISOString(), "preview", "completed", "Generated the transfer manifest."),
event(completedAt.toISOString(), "push", "completed", "Pushed mapped objects without duplicate creation."),
event(completedAt.toISOString(), "verify", "completed", "Verified the cloud import ledger and generated a run report."),
event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."),