From 207c88ef7e2c47125958cf100b8b4310dcdb57fc Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 17 Jan 2026 18:13:49 +0100 Subject: [PATCH] refactor: each org has its own restic password --- app/drizzle/0036_create-default-org.sql | 1 - app/drizzle/0037_create-default-member.sql | 1 - app/drizzle/0039_backfill-entities-org-id.sql | 13 +- app/lib/auth.ts | 14 +- app/server/core/config.ts | 37 ++++- app/server/db/schema.ts | 6 +- app/server/modules/backups/backups.service.ts | 5 +- app/server/modules/lifecycle/migrations.ts | 8 +- .../migrations/00001-retag-snapshots.ts | 2 +- .../00002-isolate-restic-passwords.ts | 129 ++++++++++++++++++ .../repositories/repositories.service.ts | 47 ++++--- app/server/utils/crypto.ts | 46 ++++++- app/server/utils/restic.ts | 87 +++++++----- 13 files changed, 316 insertions(+), 80 deletions(-) create mode 100644 app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts diff --git a/app/drizzle/0036_create-default-org.sql b/app/drizzle/0036_create-default-org.sql index 9fd7365e..87b82c57 100644 --- a/app/drizzle/0036_create-default-org.sql +++ b/app/drizzle/0036_create-default-org.sql @@ -1,4 +1,3 @@ --- Custom SQL migration file, put your code below! -- INSERT INTO organization (id, name, slug, created_at) SELECT 'default-org-' || u.id as id, diff --git a/app/drizzle/0037_create-default-member.sql b/app/drizzle/0037_create-default-member.sql index 321c98ea..64867d37 100644 --- a/app/drizzle/0037_create-default-member.sql +++ b/app/drizzle/0037_create-default-member.sql @@ -1,4 +1,3 @@ --- Custom SQL migration file, put your code below! -- INSERT INTO member (id, organization_id, user_id, role, created_at) SELECT 'default-mem-' || u.id as id, diff --git a/app/drizzle/0039_backfill-entities-org-id.sql b/app/drizzle/0039_backfill-entities-org-id.sql index 0b5ee01a..a69b76a0 100644 --- a/app/drizzle/0039_backfill-entities-org-id.sql +++ b/app/drizzle/0039_backfill-entities-org-id.sql @@ -1,22 +1,15 @@ --- Backfill organization_id for volumes, backups, notifications and repositories --- Uses the first organization found in the database - --- Update volumes_table UPDATE volumes_table SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1) WHERE organization_id IS NULL; - --- Update backup_schedules_table +--> statement-breakpoint UPDATE backup_schedules_table SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1) WHERE organization_id IS NULL; - --- Update notification_destinations_table +--> statement-breakpoint UPDATE notification_destinations_table SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1) WHERE organization_id IS NULL; - --- Update repositories_table +--> statement-breakpoint UPDATE repositories_table SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1) WHERE organization_id IS NULL; diff --git a/app/lib/auth.ts b/app/lib/auth.ts index b6472207..ace02e3d 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -8,11 +8,11 @@ import { import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins"; import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user"; -import { cryptoUtils } from "~/server/utils/crypto"; -import { db } from "~/server/db/db"; -import { member, organization as organizationTable } from "~/server/db/schema"; -import { config } from "~/server/core/config"; import { eq } from "drizzle-orm"; +import { config } from "../server/core/config"; +import { db } from "../server/db/db"; +import { cryptoUtils } from "../server/utils/crypto"; +import { organization as organizationTable, member } from "../server/db/schema"; export type AuthMiddlewareContext = MiddlewareContext>; @@ -45,6 +45,11 @@ const createBetterAuth = (secret: string) => after: async (user) => { const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4); + const resticPassword = cryptoUtils.generateResticPassword(); + const metadata = { + resticPassword: await cryptoUtils.sealSecret(resticPassword), + }; + await db.transaction(async (tx) => { const orgId = Bun.randomUUIDv7(); @@ -53,6 +58,7 @@ const createBetterAuth = (secret: string) => slug: slug, id: orgId, createdAt: new Date(), + metadata, }); await tx.insert(member).values({ diff --git a/app/server/core/config.ts b/app/server/core/config.ts index 70ed7e21..d3532c43 100644 --- a/app/server/core/config.ts +++ b/app/server/core/config.ts @@ -34,6 +34,7 @@ const envSchema = type({ APP_VERSION: "string = 'dev'", TRUSTED_ORIGINS: "string?", DISABLE_RATE_LIMITING: 'string = "false"', + ZEROBYTE_APP_SECRET: "32 <= string <= 256", }).pipe((s) => ({ __prod__: s.NODE_ENV === "production", environment: s.NODE_ENV, @@ -45,13 +46,35 @@ const envSchema = type({ appVersion: s.APP_VERSION, trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()), disableRateLimiting: s.DISABLE_RATE_LIMITING === "true", + appSecret: s.ZEROBYTE_APP_SECRET, })); const parseConfig = (env: unknown) => { const result = envSchema(env); if (result instanceof type.errors) { - console.error(`Environment variable validation failed: ${result.toString()}`); + if (!process.env.ZEROBYTE_APP_SECRET) { + const errorMessage = [ + "", + "================================================================================", + "ZEROBYTE_APP_SECRET is not configured.", + "", + "This secret is required for encrypting sensitive data in the database.", + "", + "To generate a new secret, run:", + " openssl rand -hex 32", + "", + "Then set the ZEROBYTE_APP_SECRET environment variable with the generated value.", + "", + "IMPORTANT: Store this secret securely and back it up. If lost, encrypted data", + "in the database will be unrecoverable.", + "================================================================================", + "", + ].join("\n"); + + console.error(errorMessage); + } + console.error(`Environment variable validation failed: ${result.summary}`); throw new Error("Invalid environment variables"); } @@ -59,3 +82,15 @@ const parseConfig = (env: unknown) => { }; export const config = parseConfig(process.env); + +export const getAppSecret = (): string => { + if (!config.appSecret) { + throw new Error("ZEROBYTE_APP_SECRET is not configured"); + } + + if (config.appSecret.length < 32) { + throw new Error("ZEROBYTE_APP_SECRET must be at least 32 characters long"); + } + + return config.appSecret; +}; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 4ae84513..3923e6d0 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -112,6 +112,10 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export type OrganizationMetadata = { + resticPassword: string; +}; + export const organization = sqliteTable( "organization", { @@ -120,7 +124,7 @@ export const organization = sqliteTable( slug: text("slug").notNull().unique(), logo: text("logo"), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), - metadata: text("metadata"), + metadata: text("metadata", { mode: "json" }).$type(), }, (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)], ); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 7f6247b4..5292867e 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -306,6 +306,7 @@ const executeBackup = async (scheduleId: number, organizationId: string, manual const result = await restic.backup(repository.config, volumePath, { ...backupOptions, compressionMode: repository.compressionMode ?? "auto", + organizationId, onProgress: (progress) => { serverEvents.emit("backup:progress", { scheduleId, @@ -493,7 +494,7 @@ const runForget = async (scheduleId: number, organizationId: string, repositoryI logger.info(`running retention policy (forget) for schedule ${scheduleId}`); const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`); try { - await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId }); + await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }); cache.delByPrefix(`snapshots:${repository.id}:`); } finally { releaseLock(); @@ -626,7 +627,7 @@ const copyToMirrors = async ( const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`); try { - await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId }); + await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId }); cache.delByPrefix(`snapshots:${mirror.repository.id}:`); } finally { releaseSource(); diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts index 83c0edb8..ddc333be 100644 --- a/app/server/modules/lifecycle/migrations.ts +++ b/app/server/modules/lifecycle/migrations.ts @@ -1,10 +1,10 @@ -import { db } from "~/server/db/db"; import { logger } from "../../utils/logger"; import { v00001 } from "./migrations/00001-retag-snapshots"; -import { usersTable } from "~/server/db/schema"; +import { v00002 } from "./migrations/00002-isolate-restic-passwords"; import { sql } from "drizzle-orm"; import { eq } from "drizzle-orm"; -import { appMetadataTable } from "../../db/schema"; +import { appMetadataTable, usersTable } from "../../db/schema"; +import { db } from "../../db/db"; const MIGRATION_KEY_PREFIX = "migration:"; @@ -38,7 +38,7 @@ type MigrationEntity = { dependsOn?: string[]; }; -const registry: MigrationEntity[] = [v00001]; +const registry: MigrationEntity[] = [v00001, v00002]; export const runMigrations = async () => { const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); diff --git a/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts index c156112c..b4d1e0b4 100644 --- a/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts +++ b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts @@ -13,7 +13,7 @@ const migrateTag = async ( scheduleName: string, ): Promise => { const repoUrl = buildRepoUrl(repository.config); - const env = await buildEnv(repository.config); + const env = await buildEnv(repository.config, "org-id"); const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag]; diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts new file mode 100644 index 00000000..870bfaa7 --- /dev/null +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -0,0 +1,129 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../../db/db"; +import { organization, repositoriesTable, volumesTable, notificationDestinationsTable } from "../../../db/schema"; +import { logger } from "../../../utils/logger"; +import { toMessage } from "~/server/utils/errors"; +import { cryptoUtils } from "~/server/utils/crypto"; +import type { RepositoryConfig } from "~/schemas/restic"; +import type { BackendConfig } from "~/schemas/volumes"; +import type { NotificationConfig } from "~/schemas/notifications"; +import { RESTIC_PASS_FILE } from "~/server/core/constants"; + +/** + * Migration: Isolate Restic Passwords + * + * This migration performs two critical tasks: + * 1. Assigns unique restic passwords to each organization (using the legacy password for existing orgs) + * 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new ZEROBYTE_APP_SECRET + * + * This allows per-organization encryption key isolation while ensuring + * database encryption is decoupled from restic repository passwords. + */ + +type MigrationError = { name: string; error: string }; + +const rekeySecrets = async (config: Record): Promise> => { + const rekeyedConfig: Record = { ...config }; + + for (const [key, value] of Object.entries(rekeyedConfig)) { + if (typeof value === "string" && cryptoUtils.isEncrypted(value)) { + const decrypted = await cryptoUtils.legacyDecrypt(value); + rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted); + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + rekeyedConfig[key] = await rekeySecrets(value as Record); + } + } + + return rekeyedConfig; +}; + +const execute = async () => { + const errors: MigrationError[] = []; + + // Step 1: Read the legacy restic passfile + const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); + + if (!legacyPassword) { + logger.info("No legacy restic passfile found, skipping migration"); + return { success: true, errors: [] }; + } + + // Step 2: Assign restic passwords to all existing organizations + const organizations = await db.query.organization.findMany({}); + + for (const org of organizations) { + try { + const currentMetadata = org.metadata; + + if (!currentMetadata?.resticPassword) { + const newMetadata = { + ...currentMetadata, + resticPassword: await cryptoUtils.sealSecret(legacyPassword), + }; + + await db.update(organization).set({ metadata: newMetadata }).where(eq(organization.id, org.id)); + + logger.info(`Assigned restic password to organization: ${org.name}`); + } + } catch (err) { + errors.push({ name: `org:${org.name}`, error: toMessage(err) }); + } + } + + // Step 3: Re-key all repository secrets + const repositories = await db.query.repositoriesTable.findMany({}); + + for (const repo of repositories) { + try { + const rekeyedConfig = (await rekeySecrets(repo.config)) as RepositoryConfig; + + await db.update(repositoriesTable).set({ config: rekeyedConfig }).where(eq(repositoriesTable.id, repo.id)); + + logger.info(`Re-keyed secrets for repository: ${repo.name}`); + } catch (err) { + errors.push({ name: `repo:${repo.name}`, error: toMessage(err) }); + } + } + + // Step 4: Re-key all volume secrets + const volumes = await db.query.volumesTable.findMany({}); + + for (const volume of volumes) { + try { + const rekeyedConfig = (await rekeySecrets(volume.config)) as BackendConfig; + + await db.update(volumesTable).set({ config: rekeyedConfig }).where(eq(volumesTable.id, volume.id)); + + logger.info(`Re-keyed secrets for volume: ${volume.name}`); + } catch (err) { + errors.push({ name: `volume:${volume.name}`, error: toMessage(err) }); + } + } + + // Step 5: Re-key all notification secrets + const notifications = await db.query.notificationDestinationsTable.findMany({}); + + for (const notification of notifications) { + try { + const rekeyedConfig = (await rekeySecrets(notification.config)) as NotificationConfig; + + await db + .update(notificationDestinationsTable) + .set({ config: rekeyedConfig }) + .where(eq(notificationDestinationsTable.id, notification.id)); + + logger.info(`Re-keyed secrets for notification: ${notification.name}`); + } catch (err) { + errors.push({ name: `notification:${notification.name}`, error: toMessage(err) }); + } + } + + return { success: errors.length === 0, errors }; +}; + +export const v00002 = { + execute, + id: "00002-isolate-restic-passwords", + type: "critical" as const, + dependsOn: ["00001-retag-snapshots"], +}; diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index bd8cb336..0777781a 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -9,19 +9,19 @@ import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; import { cache } from "../../utils/cache"; import { repoMutex } from "../../core/repository-mutex"; +import { type } from "arktype"; import { repositoryConfigSchema, type CompressionMode, type OverwriteMode, type RepositoryConfig, } from "~/schemas/restic"; -import { type } from "arktype"; const findRepository = async (idOrShortId: string, organizationId: string) => { return await db.query.repositoriesTable.findFirst({ where: and( or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)), - eq(repositoriesTable.organizationId, organizationId) + eq(repositoriesTable.organizationId, organizationId), ), }); }; @@ -72,7 +72,12 @@ const encryptConfig = async (config: RepositoryConfig): Promise { +const createRepository = async ( + name: string, + config: RepositoryConfig, + organizationId: string, + compressionMode?: CompressionMode, +) => { const id = crypto.randomUUID(); const shortId = generateShortId(); @@ -105,13 +110,13 @@ const createRepository = async (name: string, config: RepositoryConfig, organiza if (config.isExistingRepository) { const result = await restic - .snapshots(encryptedConfig) + .snapshots(encryptedConfig, { organizationId }) .then(() => ({ error: null })) .catch((error) => ({ error })); error = result.error; } else { - const initResult = await restic.init(encryptedConfig); + const initResult = await restic.init(encryptedConfig, organizationId); error = initResult.error; } @@ -181,9 +186,9 @@ const listSnapshots = async (id: string, organizationId: string, backupId?: stri let snapshots = []; if (backupId) { - snapshots = await restic.snapshots(repository.config, { tags: [backupId] }); + snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId }); } else { - snapshots = await restic.snapshots(repository.config); + snapshots = await restic.snapshots(repository.config, { organizationId }); } cache.set(cacheKey, snapshots); @@ -212,7 +217,7 @@ const listSnapshotFiles = async (id: string, organizationId: string, snapshotId: const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`); try { - const result = await restic.ls(repository.config, snapshotId, path); + const result = await restic.ls(repository.config, snapshotId, organizationId, path); if (!result.snapshot) { throw new NotFoundError("Snapshot not found or empty"); @@ -260,7 +265,7 @@ const restoreSnapshot = async ( const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`); try { - const result = await restic.restore(repository.config, snapshotId, target, options); + const result = await restic.restore(repository.config, snapshotId, target, { ...options, organizationId }); return { success: true, @@ -286,7 +291,7 @@ const getSnapshotDetails = async (id: string, organizationId: string, snapshotId if (!snapshots) { const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`); try { - snapshots = await restic.snapshots(repository.config); + snapshots = await restic.snapshots(repository.config, { organizationId }); cache.set(cacheKey, snapshots); } finally { releaseLock(); @@ -311,7 +316,7 @@ const checkHealth = async (repositoryId: string, organizationId: string) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, "check"); try { - const { hasErrors, error } = await restic.check(repository.config); + const { hasErrors, error } = await restic.check(repository.config, { organizationId }); await db .update(repositoriesTable) @@ -337,7 +342,7 @@ const doctorRepository = async (id: string, organizationId: string) => { const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = []; - const unlockResult = await restic.unlock(repository.config).then( + const unlockResult = await restic.unlock(repository.config, organizationId).then( (result) => ({ success: true, message: result.message, error: null }), (error) => ({ success: false, message: null, error: toMessage(error) }), ); @@ -351,7 +356,7 @@ const doctorRepository = async (id: string, organizationId: string) => { const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor"); try { - const checkResult = await restic.check(repository.config, { readData: false }).then( + const checkResult = await restic.check(repository.config, { readData: false, organizationId }).then( (result) => result, (error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }), ); @@ -364,7 +369,7 @@ const doctorRepository = async (id: string, organizationId: string) => { }); if (checkResult.hasErrors) { - const repairResult = await restic.repairIndex(repository.config).then( + const repairResult = await restic.repairIndex(repository.config, organizationId).then( (result) => ({ success: true, output: result.output, error: null }), (error) => ({ success: false, output: null, error: toMessage(error) }), ); @@ -376,7 +381,7 @@ const doctorRepository = async (id: string, organizationId: string) => { error: repairResult.error, }); - const recheckResult = await restic.check(repository.config, { readData: false }).then( + const recheckResult = await restic.check(repository.config, { readData: false, organizationId }).then( (result) => result, (error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }), ); @@ -426,7 +431,7 @@ const deleteSnapshot = async (id: string, organizationId: string, snapshotId: st const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`); try { - await restic.deleteSnapshot(repository.config, snapshotId); + await restic.deleteSnapshot(repository.config, snapshotId, organizationId); cache.delByPrefix(`snapshots:${repository.id}:`); cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); } finally { @@ -443,7 +448,7 @@ const deleteSnapshots = async (id: string, organizationId: string, snapshotIds: const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`); try { - await restic.deleteSnapshots(repository.config, snapshotIds); + await restic.deleteSnapshots(repository.config, snapshotIds, organizationId); cache.delByPrefix(`snapshots:${repository.id}:`); for (const snapshotId of snapshotIds) { cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); @@ -467,7 +472,7 @@ const tagSnapshots = async ( const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`); try { - await restic.tagSnapshots(repository.config, snapshotIds, tags); + await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId); cache.delByPrefix(`snapshots:${repository.id}:`); for (const snapshotId of snapshotIds) { cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`); @@ -477,7 +482,11 @@ const tagSnapshots = async ( } }; -const updateRepository = async (id: string, organizationId: string, updates: { name?: string; compressionMode?: CompressionMode }) => { +const updateRepository = async ( + id: string, + organizationId: string, + updates: { name?: string; compressionMode?: CompressionMode }, +) => { const existing = await findRepository(id, organizationId); if (!existing) { diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index fb86a7e3..c380a03d 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { RESTIC_PASS_FILE } from "../core/constants"; +import { getAppSecret } from "../core/config"; import { isNodeJSErrnoException } from "./fs"; import { promisify } from "node:util"; @@ -84,7 +85,7 @@ const resolveFileSecret = async (ref: string): Promise => { }; /** - * Given a string, encrypts it using a randomly generated salt. + * Given a string, encrypts it using a randomly generated salt and the APP_SECRET. * Returns the input unchanged if it's empty or already encrypted. */ const encrypt = async (data: string) => { @@ -96,7 +97,7 @@ const encrypt = async (data: string) => { return data; } - const secret = await Bun.file(RESTIC_PASS_FILE).text(); + const secret = getAppSecret(); const salt = crypto.randomBytes(16); const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); @@ -110,7 +111,7 @@ const encrypt = async (data: string) => { }; /** - * Given an encrypted string, decrypts it using the salt stored in the string. + * Given an encrypted string, decrypts it using the salt stored in the string and the APP_SECRET. * Returns the input unchanged if it's not encrypted (for backward compatibility). */ const decrypt = async (encryptedData: string) => { @@ -118,6 +119,36 @@ const decrypt = async (encryptedData: string) => { return encryptedData; } + const secret = getAppSecret(); + + const parts = encryptedData.split(":").slice(1); // Remove prefix + const saltHex = parts.shift() as string; + const salt = Buffer.from(saltHex, "hex"); + + const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); + + const iv = Buffer.from(parts.shift() as string, "hex"); + const encrypted = Buffer.from(parts.shift() as string, "hex"); + const tag = Buffer.from(parts.shift() as string, "hex"); + const decipher = crypto.createDecipheriv(algorithm, key, iv); + + decipher.setAuthTag(tag); + + let decrypted = decipher.update(encrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return decrypted.toString(); +}; + +/** + * Decrypts data using the legacy restic passfile. + * Used during migration to re-key existing secrets. + */ +const legacyDecrypt = async (encryptedData: string): Promise => { + if (!isEncrypted(encryptedData)) { + return encryptedData; + } + const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); const parts = encryptedData.split(":").slice(1); // Remove prefix @@ -187,15 +218,22 @@ const sealSecret = async (value: string): Promise => { }; async function deriveSecret(label: string) { - const masterSecret = await Bun.file(RESTIC_PASS_FILE).text(); + const masterSecret = getAppSecret(); const derivedKey = await hkdf("sha256", masterSecret, "", label, 32); return Buffer.from(derivedKey).toString("hex"); } +function generateResticPassword(): string { + return crypto.randomBytes(32).toString("hex"); +} + export const cryptoUtils = { resolveSecret, sealSecret, deriveSecret, + legacyDecrypt, + generateResticPassword, + isEncrypted, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index dd5f7b64..d4ac1f72 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -4,6 +4,7 @@ import path from "node:path"; import os from "node:os"; import { throttle } from "es-toolkit"; import { type } from "arktype"; +import { eq } from "drizzle-orm"; import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants"; import { config as appConfig } from "../core/config"; import { logger } from "./logger"; @@ -12,6 +13,8 @@ import type { RetentionPolicy } from "../modules/backups/backups.dto"; import { safeSpawn, exec } from "./spawn"; import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic"; import { ResticError } from "./errors"; +import { db } from "../db/db"; +import { organization } from "../db/schema"; const backupOutputSchema = type({ message_type: "'summary'", @@ -105,7 +108,7 @@ export const buildRepoUrl = (config: RepositoryConfig): string => { } }; -export const buildEnv = async (config: RepositoryConfig) => { +export const buildEnv = async (config: RepositoryConfig, organizationId: string) => { const env: Record = { RESTIC_CACHE_DIR, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", @@ -118,7 +121,23 @@ export const buildEnv = async (config: RepositoryConfig) => { await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); env.RESTIC_PASSWORD_FILE = passwordFilePath; } else { - env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE; + const org = await db.query.organization.findFirst({ + where: eq(organization.id, organizationId), + }); + + if (!org) { + throw new Error(`Organization ${organizationId} not found`); + } + + const metadata = org.metadata; + if (!metadata?.resticPassword) { + throw new Error(`Restic password not configured for organization ${organizationId}`); + } else { + const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword); + const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); + await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } } switch (config.backend) { @@ -219,14 +238,14 @@ export const buildEnv = async (config: RepositoryConfig) => { return env; }; -const init = async (config: RepositoryConfig) => { +const init = async (config: RepositoryConfig, organizationId: string) => { await ensurePassfile(); const repoUrl = buildRepoUrl(config); logger.info(`Initializing restic repository at ${repoUrl}...`); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); const args = ["init", "--repo", repoUrl]; addCommonArgs(args, env, config); @@ -259,7 +278,8 @@ export type BackupProgress = typeof backupProgressSchema.infer; const backup = async ( config: RepositoryConfig, source: string, - options?: { + options: { + organizationId: string; exclude?: string[]; excludeIfPresent?: string[]; include?: string[]; @@ -271,7 +291,7 @@ const backup = async ( }, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, options?.organizationId); const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"]; @@ -410,7 +430,8 @@ const restore = async ( config: RepositoryConfig, snapshotId: string, target: string, - options?: { + options: { + organizationId: string; include?: string[]; exclude?: string[]; excludeXattr?: string[]; @@ -419,7 +440,7 @@ const restore = async ( }, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, options.organizationId); const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; @@ -493,11 +514,11 @@ const restore = async ( return result; }; -const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => { - const { tags } = options; +const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => { + const { tags, organizationId } = options; const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); const args = ["--repo", repoUrl, "snapshots"]; @@ -527,9 +548,13 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } return result; }; -const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => { +const forget = async ( + config: RepositoryConfig, + options: RetentionPolicy, + extra: { tag: string; organizationId: string }, +) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, extra.organizationId); const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; @@ -569,9 +594,9 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: return { success: true }; }; -const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => { +const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for deletion."); @@ -591,17 +616,18 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) return { success: true }; }; -const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { - return deleteSnapshots(config, [snapshotId]); +const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => { + return deleteSnapshots(config, [snapshotId], organizationId); }; const tagSnapshots = async ( config: RepositoryConfig, snapshotIds: string[], tags: { add?: string[]; remove?: string[]; set?: string[] }, + organizationId: string, ) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for tagging."); @@ -667,9 +693,9 @@ const lsSnapshotInfoSchema = type({ message_type: "'snapshot'", }); -const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => { +const ls = async (config: RepositoryConfig, snapshotId: string, organizationId: string, path?: string) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; @@ -723,9 +749,9 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) = return { snapshot, nodes }; }; -const unlock = async (config: RepositoryConfig) => { +const unlock = async (config: RepositoryConfig, organizationId: string) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); const args = ["unlock", "--repo", repoUrl, "--remove-all"]; addCommonArgs(args, env, config); @@ -742,9 +768,9 @@ const unlock = async (config: RepositoryConfig) => { return { success: true, message: "Repository unlocked successfully" }; }; -const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => { +const check = async (config: RepositoryConfig, options: { readData?: boolean; organizationId: string }) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, options.organizationId); const args: string[] = ["--repo", repoUrl, "check"]; @@ -780,9 +806,9 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean }) }; }; -const repairIndex = async (config: RepositoryConfig) => { +const repairIndex = async (config: RepositoryConfig, organizationId: string) => { const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const env = await buildEnv(config, organizationId); const args = ["repair", "index", "--repo", repoUrl]; addCommonArgs(args, env, config); @@ -808,16 +834,13 @@ const repairIndex = async (config: RepositoryConfig) => { const copy = async ( sourceConfig: RepositoryConfig, destConfig: RepositoryConfig, - options: { - tag?: string; - snapshotId?: string; - }, + options: { organizationId: string; tag?: string; snapshotId?: string }, ) => { const sourceRepoUrl = buildRepoUrl(sourceConfig); const destRepoUrl = buildRepoUrl(destConfig); - const sourceEnv = await buildEnv(sourceConfig); - const destEnv = await buildEnv(destConfig); + const sourceEnv = await buildEnv(sourceConfig, options.organizationId); + const destEnv = await buildEnv(destConfig, options.organizationId); const env: Record = { ...sourceEnv,