.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in $OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the service user, created on first use. The design doc planned to move ONE at-rest key into the store. What shipped splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all seven, so one leak opened all of them — and it was named after whichever plugin needed it first, which is why it read as safe to change if you did not run a vault. A core install bootstraps two, jwt and headscale; the rest appear when their plugin first asks. The file IS the secret. No second key unlocks it, because a key beside the store it opens buys nothing. The gain was never secrecy, it is blast radius: bun auto-loads .env into all twenty pm2 processes, so a key there is readable from /proc/<pid>/environ of twenty processes — officer-music held the key that decrypts wallet seed envelopes. Two defects found by testing the store rather than reading it, both of which would have shipped: The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than inheriting the database's mode, and a freshly written key lives in the WAL before checkpoint — so the 0600 on the database was decorative. The 0700 directory covered it, but only until someone loosened the directory. PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set AFTER it. With twelve concurrent openers, six died on that line with SQLITE_BUSY. Every sidecar opens this store at boot, so they open it simultaneously by definition: most of them would have failed to start on a cold boot and none on a warm one. Fixed by ordering the pragmas; re-tested with twelve racing processes, one key, one row. crypto.ts takes a purpose as its first argument now, which the design doc had explicitly promised would not happen — 32 call sites across seven query modules. That promise is corrected in the doc rather than quietly dropped. Also live, not just comments: wallet/upstream.ts gated wallet storage on process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It asks the store now, and the question it answers changed — not "did somebody set a variable" but "can this process open the store", since the key is created on demand. assertSecretsClosed covers the store, its directory and its WAL. The jwt key mints owner tokens, so a member's shell reading it is strictly worse than the .env leak that check was written for. Not typechecked: node_modules is empty and installs are frozen, so the officerdb/secret-store subpath could not be resolved at runtime here — verified that officerdb/types fails identically, so it is the empty tree and not the new export. The store module itself was tested directly: creation, idempotence across processes, hasKey not creating, permissions, and the twelve-way race. Every changed file parses; the setup section runs and degrades correctly when the import is unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
6.2 KiB
Bash
87 lines
6.2 KiB
Bash
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
|
PORT=9000
|
|
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
|
|
|
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
|
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
|
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
|
# per purpose, created on first use. See docs/secret-store.md.
|
|
#
|
|
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
|
|
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
|
|
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
|
|
#
|
|
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
|
|
# unreadable, and for the wallet seed that is unrecoverable.
|
|
|
|
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
|
# Where Officer is reached from a browser. Read by the task API host check and the CalDAV iOS profile
|
|
# builder — the latter is the only thing that hard-requires it, and it demands https.
|
|
# PUBLIC_URL=https://officer.example.com
|
|
|
|
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
|
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
|
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
|
# only loads this file, so `bun dev` against a production .env runs fully hardened.
|
|
# PUBLIC_BUILD_ENV=dev
|
|
|
|
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read.
|
|
# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so
|
|
# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values
|
|
# that had to agree with each other and with the disk became one that cannot disagree.
|
|
|
|
# ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
|
|
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
|
|
# and never sees them. An unset upstream URL is not fatal — the sidecar logs a warning at boot and
|
|
# answers 503 until it is set, so you can run Officer with any subset of these configured.
|
|
|
|
# Transmission (officer-transmission) is configured from the app, not from here — Transmission →
|
|
# Connection. The daemon URL, the optional RPC auth and the RPC path live in `service_connections`,
|
|
# with the password encrypted, so nothing outside the sidecar can read it.
|
|
|
|
# InvoiceShelf (officer-invoiceshelf) is configured from the app, not from here — Invoices → Connection.
|
|
# Instances, their Sanctum tokens and the company each one is pinned to live encrypted in
|
|
# `invoiceshelf_accounts`, so nothing outside the sidecar can read a token.
|
|
|
|
# slskd (officer-slskd) is configured from the app, not from here — Soulseek → Connection. The
|
|
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
|
|
# X-API-Key on every forwarded request.
|
|
|
|
# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's
|
|
# key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
|
|
# VAULTWARDEN_URL=http://127.0.0.1:8222
|
|
|
|
# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
|
|
# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
|
|
# the upstream token is the OAuth one `claude` writes to ~/.claude/.credentials.json, and the
|
|
# ANTHROPIC_API_KEY the agent presents to it is the proxy's own generated secret.
|
|
|
|
# ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to
|
|
# http://localhost:8899.
|
|
# RECLIP_URL=http://localhost:8899
|
|
|
|
# ── Headscale (/api/vpn) ────────────────────────────────────────────────────────────────────────
|
|
# These drive the /api/vpn router, NOT the officer-headscale sidecar. The sidecar deliberately reads
|
|
# neither, keeping its registered servers and their keys in Postgres so host env can never shadow
|
|
# one. Set these only if you use /api/vpn.
|
|
# HEADSCALE_URL=https://headscale.example.com
|
|
# HEADSCALE_API_KEY="<headscale admin api key>"
|
|
# HEADSCALE_USER=officer
|
|
|
|
# ── Bitcoin wallet (officer-wallet) ─────────────────────────────────────────────────────────────
|
|
# The chain data source is NOT here — it is configured from the app, at Wallet → Settings → Chain
|
|
# source, and stored per owner. Any Esplora-compatible API works (electrs, esplora, mempool.space);
|
|
# it defaults to the public mempool.space until you set one.
|
|
# WALLET_NETWORK=bitcoin # bitcoin | testnet | signet | regtest
|
|
#
|
|
# How long an unlocked wallet stays unlocked, in seconds. Default 900 (15 min). The root key is held
|
|
# in the sidecar's memory for exactly this long after an unlock, then wiped. Shorter is safer.
|
|
# WALLET_UNLOCK_TTL_SEC=900
|
|
#
|
|
# NOTE: seed material is encrypted with VAULT_STORE_KEY (above) on top of the owner passphrase that
|
|
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
|
# unrecoverable — back up the mnemonics separately, offline.
|
|
|
|
# Immich (officer-photos) is configured from the app, not from here — Photos → Connection. Instances and
|
|
# their API keys live encrypted in `photos_config`, so the platform never sees a key.
|