From 605fb1c27c954bc8d7ec1e994b4129f1601fa474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Mon, 15 Dec 2025 10:48:35 +0100 Subject: [PATCH] secret reference core support --- README.md | 19 ++++ app/app.css | 6 + app/client/components/ui/secret-input.tsx | 61 ++++++++++ app/server/utils/crypto.ts | 130 +++++++++++++++++++++- docker-compose.yml | 7 ++ 5 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 app/client/components/ui/secret-input.tsx diff --git a/README.md b/README.md index 257edb0..6b9eb07 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,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 c12eee5..c84370e 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 0000000..98617fb --- /dev/null +++ b/app/client/components/ui/secret-input.tsx @@ -0,0 +1,61 @@ +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"> & { + isDirty?: boolean; +}; + +export const SecretInput = ({ className, isDirty, value, ...props }: SecretInputProps) => { + const [revealed, setRevealed] = useState(false); + + const showAsPlaintext = useMemo(() => { + if (typeof value !== "string") { + return false; + } + + return isStoredSecretValue(value) && !isDirty; + }, [isDirty, value]); + + const type = useMemo(() => { + if (showAsPlaintext) { + return "text"; + } + return revealed ? "text" : "password"; + }, [showAsPlaintext, revealed]); + + return ( +
+ + {!showAsPlaintext && ( + + )} +
+ ); +}; diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index 3b47591..a9abe4d 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -1,9 +1,15 @@ import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; import { RESTIC_PASS_FILE } from "../core/constants"; const algorithm = "aes-256-gcm" as const; const keyLength = 32; -const encryptionPrefix = "encv1"; +const encryptionPrefix = "encv1:"; + +const envSecretPrefix = "env://"; +const fileSecretPrefix = "file://"; +const dockerSecretsBasePath = "/run/secrets"; /** * Checks if a given string is encrypted by looking for the encryption prefix. @@ -12,6 +18,72 @@ const isEncrypted = (val?: string): boolean => { 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"); + } + + // Docker secrets live on a Linux path. Use POSIX path semantics even when developing on Windows. + const resolvedPath = path.posix.resolve(dockerSecretsBasePath, normalizedName); + const allowedPrefix = dockerSecretsBasePath.endsWith("/") ? dockerSecretsBasePath : `${dockerSecretsBasePath}/`; + if (!resolvedPath.startsWith(allowedPrefix)) { + throw new Error("Invalid secret reference: path escapes secrets directory"); + } + + try { + const content = await fs.readFile(resolvedPath, "utf-8"); + // Docker secrets commonly have a trailing newline. + return content.trimEnd(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Secret file not found: ${resolvedPath}`); + } + if ((error as NodeJS.ErrnoException).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 +107,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 +140,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/docker-compose.yml b/docker-compose.yml index 689f167..92fc271 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,11 @@ services: - SYS_ADMIN environment: - NODE_ENV=development + # - SMB_PASSWORD=${SMB_PASSWORD} ports: - "4096:4096" + # secrets: + # - s3-secret-access-key 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: +# s3-secret-access-key: +# file: ./s3-secret-access-key.txt