Skip to content

Commit 1b9f506

Browse files
committed
Support optional encrypted-at-rest secrets via SOPS
Adds opt-in SOPS support so deployers who want it can encrypt true secrets (OPENAI_API_KEY, ODYSSEUS_ADMIN_PASSWORD, SEARXNG_SECRET, MCP OAuth secrets, IMAP passwords, etc.) at rest instead of leaving them plaintext in .env. Non-secret config (APP_PORT, LLM_HOST, etc.) stays in .env so PR diffs remain reviewable. The feature is only active when /app/secrets.env.enc is present at container start; existing deployments behave exactly as before. - Dockerfile: pin sops v3.13.1 (~10MB static Go binary), arch-aware - docker/entrypoint.sh: wrap gosu with 'sops exec-env' when the encrypted file exists; fail loudly if sops is missing or no SOPS_AGE_KEY[_FILE] is set - .sops.yaml: creation rule with placeholder age public key - secrets.env.example: documents which env vars are expected as secrets - .gitignore: ignore plaintext secrets.env, allow .example and .enc - SECURITY.md: new 'Encrypting Secrets At Rest' section with the age-keygen / sops -e / compose-mount workflow Proposed in #233. Naming chosen to avoid colliding with the existing runtime Bitwarden integration at routes/vault_routes.py.
1 parent 1ce00b5 commit 1b9f506

6 files changed

Lines changed: 123 additions & 0 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ venv/
1414
.env
1515
!.env.example
1616

17+
# SOPS encrypted-at-rest secrets. Plaintext stays local; the .enc file
18+
# and the .example template are safe to commit. See SECURITY.md.
19+
secrets.env
20+
!secrets.env.example
21+
!secrets.env.enc
22+
1723
# Data — all user data stays local
1824
data/
1925
!services/hwfit/data/

.sops.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SOPS encryption configuration for Odysseus secrets at rest.
2+
#
3+
# Before encrypting, copy this file to .sops.yaml.local (or edit in place)
4+
# and replace the placeholder below with your own age public key.
5+
#
6+
# Generate a key with:
7+
# age-keygen -o ~/.config/sops/age/keys.txt
8+
# age-keygen -y < ~/.config/sops/age/keys.txt # prints the public key
9+
#
10+
# See SECURITY.md ("Encrypting Secrets At Rest") for the full workflow.
11+
creation_rules:
12+
- path_regex: secrets\.env(\.enc)?$
13+
age: age1REPLACE_WITH_YOUR_AGE_PUBLIC_KEY

Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
2020
gosu \
2121
&& rm -rf /var/lib/apt/lists/*
2222

23+
# Optional opt-in: SOPS for encrypted-at-rest secrets (see SECURITY.md).
24+
# Small static Go binary; only invoked when /app/secrets.env.enc is present
25+
# at container start. Pinned by version; arch-aware via dpkg.
26+
ARG SOPS_VERSION=3.13.1
27+
RUN arch="$(dpkg --print-architecture)" \
28+
&& curl -fsSL -o /usr/local/bin/sops \
29+
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.${arch}" \
30+
&& chmod +x /usr/local/bin/sops
31+
2332
WORKDIR /app
2433

2534
# Install Python deps first (layer cache)

SECURITY.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,56 @@ Security fixes are handled on the default branch until formal releases are cut.
1919
- Rotate API keys, webhook secrets, and Odysseus API tokens if they appear in logs, screenshots, demos, or shared chats.
2020
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality.
2121

22+
## Encrypting Secrets At Rest (Optional)
23+
24+
By default, secret values (API keys, admin password seed, SearXNG signing key, etc.) live in `.env` as plaintext. The file is gitignored, but any process that reads it (container exec, host backup, snapshot) reveals every secret. For deployments where that's a concern, Odysseus supports [SOPS](https://github.com/getsops/sops) with age keys to encrypt secrets at rest.
25+
26+
This is **opt-in** — the feature is only active when `secrets.env.enc` is present at container start. Existing deployments behave exactly as before.
27+
28+
### Setup
29+
30+
1. Install [`age`](https://github.com/FiloSottile/age) on your host and generate a key:
31+
32+
```bash
33+
mkdir -p ~/.config/sops/age
34+
age-keygen -o ~/.config/sops/age/keys.txt
35+
age-keygen -y < ~/.config/sops/age/keys.txt # prints your age public key
36+
```
37+
38+
2. Paste the printed public key into `.sops.yaml` (replace the `age1REPLACE...` placeholder).
39+
40+
3. Copy the example, fill in real secret values, then encrypt:
41+
42+
```bash
43+
cp secrets.env.example secrets.env
44+
$EDITOR secrets.env
45+
sops -e secrets.env > secrets.env.enc
46+
shred -u secrets.env # destroy plaintext; keep only secrets.env.enc
47+
```
48+
49+
4. Provide the age **private** key to the container by mounting it and pointing `SOPS_AGE_KEY_FILE` at it (`docker-compose.yml`):
50+
51+
```yaml
52+
services:
53+
odysseus:
54+
volumes:
55+
- ~/.config/sops/age/keys.txt:/run/secrets/sops-age-key:ro
56+
environment:
57+
- SOPS_AGE_KEY_FILE=/run/secrets/sops-age-key
58+
```
59+
60+
At container start, the entrypoint runs `sops exec-env` to inject secrets as env vars JIT — plaintext never touches the container filesystem.
61+
62+
### What to put in `secrets.env` vs `.env`
63+
64+
Only true secrets belong in `secrets.env`. Non-secret configuration stays in `.env` because it benefits from plaintext diffs and PR review:
65+
66+
| `.env` (plaintext, gitignored) | `secrets.env.enc` (encrypted, committable) |
67+
|---|---|
68+
| `APP_PORT`, `LLM_HOST`, `SEARXNG_INSTANCE`, `ALLOWED_ORIGINS`, `CHROMADB_BIND`, `LOCALHOST_BYPASS` | `OPENAI_API_KEY`, `ODYSSEUS_ADMIN_PASSWORD`, `SEARXNG_SECRET`, `INTERNAL_TOOL_TOKEN`, MCP OAuth client secrets, IMAP passwords |
69+
70+
To migrate: move only the secret lines out of your existing `.env` into `secrets.env`, encrypt, and delete those lines from `.env`.
71+
2272
## Publishing A Fork
2373

2474
Before pushing a public fork, run:

docker/entrypoint.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ for cu in /app/.local/lib/python*/site-packages/nvidia/cu13; do
6464
fi
6565
done
6666

67+
# Optional SOPS-encrypted secrets at rest. When /app/secrets.env.enc is
68+
# present, decrypt it via `sops exec-env` so secrets are injected as env
69+
# vars JIT and plaintext never touches the container filesystem. See
70+
# SECURITY.md ("Encrypting Secrets At Rest") for the user-side workflow.
71+
SECRETS_ENC="/app/secrets.env.enc"
72+
if [ -f "$SECRETS_ENC" ]; then
73+
if ! command -v sops >/dev/null 2>&1; then
74+
echo "entrypoint: $SECRETS_ENC present but 'sops' is not on PATH" >&2
75+
exit 1
76+
fi
77+
if [ -z "${SOPS_AGE_KEY_FILE:-}" ] && [ -z "${SOPS_AGE_KEY:-}" ]; then
78+
echo "entrypoint: $SECRETS_ENC present but neither SOPS_AGE_KEY_FILE nor SOPS_AGE_KEY is set" >&2
79+
exit 1
80+
fi
81+
# POSIX re-exec idiom: sh -c 'exec "$@"' rebuilds argv cleanly so
82+
# uvicorn still receives the original positional args intact.
83+
exec gosu "$PUID:$PGID" sops exec-env "$SECRETS_ENC" 'exec "$@"' -- "$@"
84+
fi
85+
6786
# Drop root and run the actual app. `gosu` is preferred over `su` /
6887
# `sudo` because it cleans up the process tree (no extra shell layer)
6988
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.

secrets.env.example

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Odysseus — secrets meant for encryption at rest (optional, via SOPS).
2+
#
3+
# Workflow:
4+
# 1. Copy this file to secrets.env (gitignored)
5+
# 2. Fill in real values
6+
# 3. Encrypt with: sops -e secrets.env > secrets.env.enc
7+
# 4. Shred plaintext: shred -u secrets.env
8+
# 5. Commit secrets.env.enc if you want to sync across machines
9+
#
10+
# At container start, the entrypoint runs `sops exec-env` to inject these
11+
# values as env vars without ever writing plaintext to disk. Requires
12+
# SOPS_AGE_KEY_FILE (or SOPS_AGE_KEY) to be set inside the container.
13+
#
14+
# See SECURITY.md ("Encrypting Secrets At Rest") for the full guide.
15+
#
16+
# Non-secret configuration (APP_PORT, LLM_HOST, SEARXNG_INSTANCE, etc.)
17+
# stays in .env as today — only true secrets belong here.
18+
19+
# OpenAI / provider API keys
20+
# OPENAI_API_KEY=
21+
22+
# Pre-seeded admin password used on first boot (delete after first run).
23+
# ODYSSEUS_ADMIN_PASSWORD=
24+
25+
# SearXNG cookie/CSRF signing key (generate with: openssl rand -hex 32)
26+
# SEARXNG_SECRET=

0 commit comments

Comments
 (0)