Skip to content

Commit 9e53b1e

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. Uses SOPS's idiomatic in-place encryption convention: secrets.env is encrypted in place with 'sops -e -i secrets.env' and the encrypted file itself is safe to commit. To edit later: 'sops secrets.env' opens $EDITOR with decrypted contents and re-encrypts on save. The feature is only active when /app/secrets.env is present at container start; existing deployments behave exactly as before. If secrets.env is present but NOT SOPS-encrypted (detected via the sops_version marker), the entrypoint refuses to start — that shape is almost always a packaging mistake. - Dockerfile: pin sops v3.13.1 (~10MB static Go binary), arch-aware - docker/entrypoint.sh: wrap gosu with 'sops exec-env' when encrypted secrets.env exists; fail loudly if sops is missing, no SOPS_AGE_KEY [_FILE] is set, or the file is plaintext - .sops.yaml: creation rule with placeholder age public key - secrets.env.example: documents the in-place encryption workflow - .gitignore: ignore transient decrypt artifacts only — encrypted secrets.env is intentionally committable - SECURITY.md: new 'Encrypting Secrets At Rest' section with the age-keygen -> 'sops -e -i' -> compose-mount workflow Verified end-to-end with the entrypoint exec-ing a sentinel-loaded container: positive (encrypted file mounted, key bound) -> sentinel in env negative (no secrets.env) -> entrypoint unchanged refuse (plaintext secrets.env present) -> exit 1 with hint Proposed in #233. Naming chosen to avoid colliding with the existing runtime Bitwarden integration at routes/vault_routes.py.
1 parent c075abc commit 9e53b1e

6 files changed

Lines changed: 141 additions & 0 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ venv/
1515
.env.bak.*
1616
!.env.example
1717

18+
# SOPS-encrypted secrets are committable (secrets.env, encrypted in
19+
# place). Only the transient artifacts left over from manual edit/
20+
# decrypt sessions should be ignored — never let a fully-plaintext
21+
# secrets file slip through. See SECURITY.md.
22+
secrets.env.dec
23+
secrets.env.bak
24+
secrets.env.plain
25+
1826
# Data — all user data stays local
1927
data/
2028
!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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,57 @@ Security fixes are handled on the default branch until formal releases are cut.
2323
- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality.
2424
- Common internal-only ports are Odysseus `7000`, SearXNG `8080`, ntfy `8091`, ChromaDB `8100`, Ollama `11434`, and local model/provider APIs such as `8000-8020`.
2525

26+
## Encrypting Secrets At Rest (Optional)
27+
28+
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.
29+
30+
This is **opt-in** — the feature is only active when an encrypted `secrets.env` is present at container start. Existing deployments behave exactly as before.
31+
32+
### Setup
33+
34+
1. Install [`age`](https://github.com/FiloSottile/age) on your host and generate a key:
35+
36+
```bash
37+
mkdir -p ~/.config/sops/age
38+
age-keygen -o ~/.config/sops/age/keys.txt
39+
age-keygen -y < ~/.config/sops/age/keys.txt # prints your age public key
40+
```
41+
42+
2. Paste the printed public key into `.sops.yaml` (replace the `age1REPLACE...` placeholder).
43+
44+
3. Copy the example, fill in real secret values, then encrypt in place:
45+
46+
```bash
47+
cp secrets.env.example secrets.env
48+
$EDITOR secrets.env # fill in real values
49+
sops -e -i secrets.env # encrypt in place — file is now safe to commit
50+
```
51+
52+
To edit later: `sops secrets.env` opens `$EDITOR` with decrypted contents and re-encrypts on save.
53+
54+
4. Provide the age **private** key to the container by mounting it and pointing `SOPS_AGE_KEY_FILE` at it (`docker-compose.yml`):
55+
56+
```yaml
57+
services:
58+
odysseus:
59+
volumes:
60+
- ~/.config/sops/age/keys.txt:/run/secrets/sops-age-key:ro
61+
environment:
62+
- SOPS_AGE_KEY_FILE=/run/secrets/sops-age-key
63+
```
64+
65+
At container start, the entrypoint runs `sops exec-env` to inject secrets as env vars JIT — plaintext never touches the container filesystem. If `secrets.env` is present but **not** SOPS-encrypted, the entrypoint refuses to start (it's almost always a packaging mistake).
66+
67+
### What to put in `secrets.env` vs `.env`
68+
69+
Only true secrets belong in `secrets.env`. Non-secret configuration stays in `.env` because it benefits from plaintext diffs and PR review:
70+
71+
| `.env` (plaintext, gitignored) | `secrets.env` (SOPS-encrypted, committable) |
72+
|---|---|
73+
| `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 |
74+
75+
To migrate: move only the secret lines out of your existing `.env` into `secrets.env`, encrypt, and delete those lines from `.env`.
76+
2677
## Publishing A Fork
2778

2879
Before pushing a public fork, run:

docker/entrypoint.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,39 @@ export VLLM_USE_FLASHINFER_SAMPLER="${VLLM_USE_FLASHINFER_SAMPLER:-0}"
8080
# vLLM and helper scripts land here because /app is the non-root user's HOME.
8181
export PATH="/app/.local/bin:$PATH"
8282

83+
# Optional SOPS-encrypted secrets at rest. When /app/secrets.env is
84+
# present it MUST be SOPS-encrypted (detected via the `sops_version`
85+
# trailing line). On encrypted, we exec via `sops exec-env` so secrets
86+
# enter the process as env vars JIT — plaintext never touches the
87+
# container filesystem. On plaintext, we refuse to start — a plaintext
88+
# secrets.env in /app is almost always a packaging mistake. See
89+
# SECURITY.md ("Encrypting Secrets At Rest") for the user-side workflow.
90+
SECRETS_FILE="/app/secrets.env"
91+
if [ -f "$SECRETS_FILE" ]; then
92+
if ! grep -q '^sops_version' "$SECRETS_FILE"; then
93+
echo "entrypoint: $SECRETS_FILE exists but is not SOPS-encrypted; refusing to start" >&2
94+
echo "entrypoint: encrypt it with: sops -e -i secrets.env" >&2
95+
exit 1
96+
fi
97+
if ! command -v sops >/dev/null 2>&1; then
98+
echo "entrypoint: $SECRETS_FILE is SOPS-encrypted but 'sops' is not on PATH" >&2
99+
exit 1
100+
fi
101+
if [ -z "${SOPS_AGE_KEY_FILE:-}" ] && [ -z "${SOPS_AGE_KEY:-}" ]; then
102+
echo "entrypoint: $SECRETS_FILE is SOPS-encrypted but neither SOPS_AGE_KEY_FILE nor SOPS_AGE_KEY is set" >&2
103+
exit 1
104+
fi
105+
# `sops exec-env` takes a single command string (passed to sh -c),
106+
# not argv passthrough — so we single-quote each positional arg to
107+
# preserve word boundaries when sh re-tokenises.
108+
cmd=""
109+
for a in "$@"; do
110+
qa=$(printf '%s' "$a" | sed "s/'/'\\\\''/g")
111+
cmd="$cmd '$qa'"
112+
done
113+
exec gosu "$PUID:$PGID" sops exec-env "$SECRETS_FILE" "exec$cmd"
114+
fi
115+
83116
# Drop root and run the actual app. `gosu` is preferred over `su` /
84117
# `sudo` because it cleans up the process tree (no extra shell layer)
85118
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.

secrets.env.example

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Odysseus — secrets meant for encryption at rest (optional, via SOPS).
2+
#
3+
# Workflow (idiomatic SOPS — file is encrypted in place):
4+
#
5+
# cp secrets.env.example secrets.env
6+
# $EDITOR secrets.env # fill in real values (plaintext, on your host only)
7+
# sops -e -i secrets.env # encrypt in place — file now safe to commit
8+
#
9+
# To edit later: `sops secrets.env` opens $EDITOR with decrypted contents
10+
# and re-encrypts on save. Never commit a non-encrypted secrets.env.
11+
#
12+
# At container start, the entrypoint runs `sops exec-env` to inject these
13+
# values as env vars without writing plaintext to disk. Requires
14+
# SOPS_AGE_KEY_FILE (or SOPS_AGE_KEY) to be set inside the container —
15+
# see SECURITY.md ("Encrypting Secrets At Rest").
16+
#
17+
# Non-secret configuration (APP_PORT, LLM_HOST, SEARXNG_INSTANCE, etc.)
18+
# stays in .env as today — only true secrets belong here.
19+
20+
# OpenAI / provider API keys
21+
# OPENAI_API_KEY=
22+
23+
# Pre-seeded admin password used on first boot (delete after first run).
24+
# ODYSSEUS_ADMIN_PASSWORD=
25+
26+
# SearXNG cookie/CSRF signing key (generate with: openssl rand -hex 32)
27+
# SEARXNG_SECRET=

0 commit comments

Comments
 (0)