secret reference core support

This commit is contained in:
Jakub Trávník
2025-12-15 10:48:35 +01:00
parent 47f64b51af
commit 605fb1c27c
5 changed files with 217 additions and 6 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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<React.ComponentProps<typeof Input>, "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 (
<div className="relative" data-secret-input>
<Input
{...props}
value={value}
type={type}
className={cn(!showAsPlaintext && "pr-10", className)}
/>
{!showAsPlaintext && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? "Hide secret" : "Show secret"}
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
)}
</div>
);
};

View File

@@ -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<string> => {
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<string> => {
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<string> => {
if (!value) {
return value;
}
if (isEncrypted(value) || isSecretReference(value)) {
return value;
}
return encrypt(value);
};
export const cryptoUtils = {
resolveSecret,
sealSecret,
};

View File

@@ -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