diff --git a/.dockerignore b/.dockerignore index 9980d983..d2d3fe67 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -* +** !turbo.json !bun.lock diff --git a/.gitignore b/.gitignore index 9f32df66..99880c84 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ CLAUDE.md mutagen.yml.lock notes.md +smb-password.txt diff --git a/README.md b/README.md index 87289812..5780d82b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,25 @@ Repositories are optimized for storage efficiency and data integrity, leveraging To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings. +## Secret references (env:// and file://) + +Any field that is normally stored encrypted in Zerobyte (passwords, tokens, access keys, etc.) also accepts secret references. + +- `env://VAR_NAME` reads the value from `process.env.VAR_NAME` inside the Zerobyte container. +- `file://secret_name` reads the value from `/run/secrets/secret_name` (Docker secrets). + +If you enter a normal value (not starting with `env://` or `file://`), Zerobyte will encrypt it before storing it in the database (values will look like `encv1:...`). + +Examples: + +```yaml +# SMB volume password from an env var +password: env://SMB_PASSWORD + +# S3 secret access key from a Docker secret +secretAccessKey: file://s3-secret-access-key +``` + ### Using rclone for cloud storage Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service. diff --git a/app/app.css b/app/app.css index c12eee55..c84370ed 100644 --- a/app/app.css +++ b/app/app.css @@ -173,3 +173,9 @@ body { --chart-5: oklch(0.645 0.246 16.439); } } + +/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ +[data-secret-input] input[type="password"]::-ms-reveal, +[data-secret-input] input[type="password"]::-ms-clear { + display: none; +} diff --git a/app/client/components/ui/secret-input.tsx b/app/client/components/ui/secret-input.tsx new file mode 100644 index 00000000..540f8f89 --- /dev/null +++ b/app/client/components/ui/secret-input.tsx @@ -0,0 +1,59 @@ +import { Eye, EyeOff } from "lucide-react"; +import type * as React from "react"; +import { useMemo, useState } from "react"; + +import { cn } from "~/client/lib/utils"; +import { Button } from "./button"; +import { Input } from "./input"; + +export const isStoredSecretValue = (value?: string): boolean => { + if (typeof value !== "string" || value.length === 0) { + return false; + } + + return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:"); +}; + +type SecretInputProps = Omit, "type"> + +export const SecretInput = ({ className, value, ...props }: SecretInputProps) => { + const [revealed, setRevealed] = useState(false); + + const showAsPlaintext = useMemo(() => { + if (typeof value !== "string") { + return false; + } + + return isStoredSecretValue(value); + }, [value]); + + const type = useMemo(() => { + if (showAsPlaintext) { + return "text"; + } + return revealed ? "text" : "password"; + }, [showAsPlaintext, revealed]); + + return ( +
+ + {!showAsPlaintext && ( + + )} +
+ ); +}; diff --git a/app/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index dc89d550..2fd240cf 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -14,6 +14,7 @@ import { FormMessage, } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; +import { SecretInput } from "~/client/components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Checkbox } from "~/client/components/ui/checkbox"; import { notificationConfigSchema } from "~/schemas/notifications"; @@ -213,7 +214,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Password (Optional) - + @@ -415,7 +416,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue App Token - + Application token from Gotify. @@ -510,7 +511,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Password (Optional) - + Password for server authentication, if required. @@ -567,7 +568,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue API Token - + Application API token from your Pushover application. @@ -627,7 +628,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Bot Token - + Telegram bot token. Get this from BotFather when you create your bot. diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index dc99c6d4..2f18a482 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -16,6 +16,7 @@ import { FormMessage, } from "../../../components/ui/form"; import { Input } from "../../../components/ui/input"; +import { SecretInput } from "../../../components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { useSystemInfo } from "~/client/hooks/use-system-info"; @@ -241,7 +242,11 @@ export const CreateRepositoryForm = ({ Repository Password - + The password used to encrypt this repository. It will be stored securely. diff --git a/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx index de2442fe..bdce82ee 100644 --- a/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -52,7 +53,7 @@ export const AzureRepositoryForm = ({ form }: Props) => { Account Key - + Azure Storage account key for authentication. diff --git a/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx index b4cfa242..94d480a6 100644 --- a/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -68,7 +69,7 @@ export const R2RepositoryForm = ({ form }: Props) => { Secret Access Key - + R2 API token Secret Access Key (shown once when creating token). diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx index 5721c1db..401b099c 100644 --- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -66,7 +67,7 @@ export const RestRepositoryForm = ({ form }: Props) => { Password (Optional) - + Password for REST server authentication. diff --git a/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx index 16082434..40328c4a 100644 --- a/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -66,7 +67,7 @@ export const S3RepositoryForm = ({ form }: Props) => { Secret Access Key - + S3 secret access key for authentication. diff --git a/app/client/modules/volumes/components/volume-forms/smb-form.tsx b/app/client/modules/volumes/components/volume-forms/smb-form.tsx index 7f995c46..b46fcef3 100644 --- a/app/client/modules/volumes/components/volume-forms/smb-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/smb-form.tsx @@ -9,6 +9,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; type Props = { @@ -67,7 +68,7 @@ export const SMBForm = ({ form }: Props) => { Password - + Password for SMB authentication. diff --git a/app/client/modules/volumes/components/volume-forms/webdav-form.tsx b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx index 1a399ed7..3d43892b 100644 --- a/app/client/modules/volumes/components/volume-forms/webdav-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx @@ -9,6 +9,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; type Props = { form: UseFormReturn; @@ -66,7 +67,7 @@ export const WebDAVForm = ({ form }: Props) => { Password (Optional) - + Password for WebDAV authentication (optional). diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts index 774e10fd..f5500c29 100644 --- a/app/server/modules/backends/smb/smb-backend.ts +++ b/app/server/modules/backends/smb/smb-backend.ts @@ -34,7 +34,7 @@ const mount = async (config: BackendConfig, path: string) => { const run = async () => { await fs.mkdir(path, { recursive: true }); - const password = await cryptoUtils.decrypt(config.password); + const password = await cryptoUtils.resolveSecret(config.password); const source = `//${config.server}/${config.share}`; const options = [ diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index 2467736d..5de5f05d 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -50,7 +50,7 @@ const mount = async (config: BackendConfig, path: string) => { : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; if (config.username && config.password) { - const password = await cryptoUtils.decrypt(config.password); + const password = await cryptoUtils.resolveSecret(config.password); const secretsFile = "/etc/davfs2/secrets"; const secretsContent = `${source} ${config.username} ${password}\n`; await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index b25b79f6..0b951a96 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise = { ...config }; if (config.customPassword) { - encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); + encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword); } switch (config.backend) { case "s3": case "r2": - encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); - encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); + encryptedConfig.accessKeyId = await cryptoUtils.sealSecret(config.accessKeyId); + encryptedConfig.secretAccessKey = await cryptoUtils.sealSecret(config.secretAccessKey); break; case "gcs": - encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson); + encryptedConfig.credentialsJson = await cryptoUtils.sealSecret(config.credentialsJson); break; case "azure": - encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); + encryptedConfig.accountKey = await cryptoUtils.sealSecret(config.accountKey); break; case "rest": if (config.username) { - encryptedConfig.username = await cryptoUtils.encrypt(config.username); + encryptedConfig.username = await cryptoUtils.sealSecret(config.username); } if (config.password) { - encryptedConfig.password = await cryptoUtils.encrypt(config.password); + encryptedConfig.password = await cryptoUtils.sealSecret(config.password); } break; case "sftp": - encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey); + encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey); break; } diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 08ed9279..def5c51f 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -23,12 +23,12 @@ async function encryptSensitiveFields(config: BackendConfig): Promise { return typeof val === "string" && val.startsWith(encryptionPrefix); }; +/** + * Checks if a string looks like a supported secret reference. + * - env://VAR_NAME -> reads process.env.VAR_NAME + * - file://name -> reads a file /run/secrets/name + */ +const isSecretReference = (val?: string): boolean => { + return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix)); +}; + +/** + * Resolves an environment variable secret reference. + */ +const resolveEnvSecret = (ref: string): string => { + const name = ref.slice(envSecretPrefix.length); + if (!name) { + throw new Error("env:// reference is missing variable name"); + } + + const value = process.env[name]; + if (value === undefined) { + throw new Error(`Environment variable not set: ${name}`); + } + + return value; +}; + +/** + * Resolves a file-based secret reference. + * Reads the secret from /run/secrets/{name} + */ +const resolveFileSecret = async (ref: string): Promise => { + const secretName = ref.slice(fileSecretPrefix.length); + if (!secretName) { + throw new Error("file:// reference is missing secret name"); + } + + const normalizedName = secretName.replace(/^\/+/, ""); + if (!normalizedName) { + throw new Error("file:// reference is missing secret name"); + } + if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) { + throw new Error("Invalid secret reference: secret name must be a single path segment"); + } + + const resolvedPath = path.join("/run/secrets", normalizedName); + + try { + const content = await fs.readFile(resolvedPath, "utf-8"); + return content.trimEnd(); + } catch (error) { + if (isNodeJSErrnoException(error)) { + if (error.code === "ENOENT") { + throw new Error(`Secret file not found: ${resolvedPath}`); + } + if (error.code === "EACCES") { + throw new Error(`Permission denied reading secret file: ${resolvedPath}`); + } + } + throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`); + } +}; + /** * Given a string, encrypts it using a randomly generated salt. * Returns the input unchanged if it's empty or already encrypted. @@ -35,7 +103,7 @@ const encrypt = async (data: string) => { const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const tag = cipher.getAuthTag(); - return `${encryptionPrefix}:${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; + return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; }; /** @@ -68,8 +136,54 @@ const decrypt = async (encryptedData: string) => { return decrypted.toString(); }; -export const cryptoUtils = { - encrypt, - decrypt, - isEncrypted, +/** + * Resolves secret references and encrypted database values. + * + * - encv1:... -> decrypt + * - env://VAR -> read process.env.VAR + * - file://name -> read /run/secrets/name + * - otherwise returns value unchanged + */ +const resolveSecret = async (value: string): Promise => { + if (!value) { + return value; + } + + if (isEncrypted(value)) { + return decrypt(value); + } + + if (value.startsWith(envSecretPrefix)) { + return resolveEnvSecret(value); + } + + if (value.startsWith(fileSecretPrefix)) { + return resolveFileSecret(value); + } + + return value; +}; + +/** + * Prepares a secret value for storage. + * + * - env://... and file://... are stored as-is (references) + * - encv1:... is stored as-is (already encrypted) + * - otherwise encrypt before storing + */ +const sealSecret = async (value: string): Promise => { + if (!value) { + return value; + } + + if (isEncrypted(value) || isSecretReference(value)) { + return value; + } + + return encrypt(value); +}; + +export const cryptoUtils = { + resolveSecret, + sealSecret, }; diff --git a/app/server/utils/fs.ts b/app/server/utils/fs.ts new file mode 100644 index 00000000..82bad477 --- /dev/null +++ b/app/server/utils/fs.ts @@ -0,0 +1,8 @@ +export const isNodeJSErrnoException = (error: unknown): error is NodeJS.ErrnoException => { + return ( + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as NodeJS.ErrnoException).code === "string" + ); +}; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index a99d94dc..207bec99 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -106,7 +106,7 @@ const buildEnv = async (config: RepositoryConfig) => { }; if (config.isExistingRepository && config.customPassword) { - const decryptedPassword = await cryptoUtils.decrypt(config.customPassword); + const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); @@ -117,17 +117,17 @@ const buildEnv = async (config: RepositoryConfig) => { switch (config.backend) { case "s3": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); break; case "r2": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); env.AWS_REGION = "auto"; env.AWS_S3_FORCE_PATH_STYLE = "true"; break; case "gcs": { - const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson); + const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); env.GOOGLE_PROJECT_ID = config.projectId; @@ -136,7 +136,7 @@ const buildEnv = async (config: RepositoryConfig) => { } case "azure": { env.AZURE_ACCOUNT_NAME = config.accountName; - env.AZURE_ACCOUNT_KEY = await cryptoUtils.decrypt(config.accountKey); + env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey); if (config.endpointSuffix) { env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; } @@ -144,15 +144,15 @@ const buildEnv = async (config: RepositoryConfig) => { } case "rest": { if (config.username) { - env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username); + env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); } if (config.password) { - env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password); + env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password); } break; } case "sftp": { - const decryptedKey = await cryptoUtils.decrypt(config.privateKey); + const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); diff --git a/docker-compose.yml b/docker-compose.yml index 689f167b..5ac4eee7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,11 @@ services: - SYS_ADMIN environment: - NODE_ENV=development + # - SMB_PASSWORD=secret ports: - "4096:4096" + # secrets: + # - smb-password volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -42,3 +45,7 @@ services: - /run/docker/plugins:/run/docker/plugins - /var/run/docker.sock:/var/run/docker.sock - ~/.config/rclone:/root/.config/rclone + +# secrets: +# smb-password: +# file: ./smb-password.txt