mirror of
https://github.com/nicotsx/zerobyte.git
synced 2025-12-23 21:47:47 -05:00
secret reference core support
This commit is contained in:
19
README.md
19
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
61
app/client/components/ui/secret-input.tsx
Normal file
61
app/client/components/ui/secret-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user