Written from the conversation of 2026-08-12. Nothing implemented; every claim about the current tree was checked on that date. The short version: secrets stay in Postgres, the keys move out of .env into a small SQLite store, and rotation becomes an operation instead of data loss. What is actually wrong today is narrower than "secrets in .env" and worth stating precisely, because the separation that exists is correct and must survive a refactor: secrets live in Postgres and the key that opens them does not. The problem is blast radius across processes — Bun auto-loads .env, so VAULT_STORE_KEY sits in the environment of all twenty pm2 processes, and officer-music holds the key that decrypts wallet seed envelopes for no reason. The document records the decisions and, more usefully, what was ruled out: Keys cannot go in Postgres. A dump would carry the ciphertext and the thing that opens it. Encrypting the key with a second key only moves the question — one secret has to be readable without any other, and the only decision is where it lives. The store is not encrypted at rest, and this was tested rather than assumed: stock SQLite silently ignores unknown pragmas, so `PRAGMA key` succeeds, encrypts nothing, and the value is readable with `strings`. bun:sqlite ships stock SQLite 3.53.0. Whole-file encryption needs SQLCipher, which is a second native dependency, and this project already knows what one of those costs. SQLite rather than a flat file for rotation, not secrecy: rotation needs key VERSIONS, since an interrupted rotation needs the old key and the new one to both exist. The file must not live in $OFFICER_ROOT/data/ — that is what people back up, and a key store in the same tarball as a database dump rebuilds the problem. It also records the core/plugin split the design assumes: light plus officer-headscale is the core, because CLAUDE.md rests the security model on the tailnet and a model that rests on the tailnet cannot treat administering it as optional. Vaultwarden and the wallet become plugins. Moving headscale into light removes it from the app store automatically, since catalogue.test.ts asserts the catalogue equals full minus light. Five open questions are left open rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
10 KiB
Markdown
207 lines
10 KiB
Markdown
# The secret store
|
||
|
||
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
|
||
current code was checked against the tree on that date; the file:line references are live.
|
||
|
||
A small SQLite database, created during setup, holding every encryption and signing key the platform
|
||
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
|
||
instead of inventing its own.
|
||
|
||
---
|
||
|
||
## What is wrong with today
|
||
|
||
Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is
|
||
not lost in a refactor:
|
||
|
||
> **Secrets live in Postgres. The key that opens them does not.**
|
||
|
||
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
|
||
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
|
||
without the passphrase"*.
|
||
|
||
What is wrong is narrower, and it is about **blast radius across processes**.
|
||
|
||
`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many
|
||
words — it is the reason the Anthropic credential was moved out of the main process. So today
|
||
`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds
|
||
the key that decrypts wallet seed envelopes. Anything that can read `/proc/<pid>/environ` for those
|
||
processes has it, and nineteen of them have no reason to.
|
||
|
||
The second problem is that changing the key is currently unrecoverable rather than an operation. See
|
||
[Rotation](#rotation).
|
||
|
||
---
|
||
|
||
## What the key actually protects
|
||
|
||
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
|
||
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
|
||
|
||
| column | what it is |
|
||
| --- | --- |
|
||
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
|
||
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
|
||
| `jellyfin_servers.access_token` | Jellyfin session token |
|
||
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
|
||
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
|
||
|
||
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
|
||
one of those becoming unreadable at once.
|
||
|
||
The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never
|
||
persisted). Everything else in that table has exactly one lock.
|
||
|
||
---
|
||
|
||
## Decisions
|
||
|
||
### 1. The store is SQLite, in the install, outside Postgres
|
||
|
||
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
|
||
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
|
||
second key only moves the question — eventually exactly one secret has to be readable without any other
|
||
secret, and the only real decision is *where it lives*.
|
||
|
||
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
|
||
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
|
||
both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward
|
||
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
||
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||
|
||
### 2. It is NOT encrypted at rest, for now
|
||
|
||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||
|
||
```
|
||
$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …'
|
||
read without key: THE-SECRET-VALUE
|
||
strings enc.db | grep THE-SECRET-VALUE -> found
|
||
```
|
||
|
||
Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the
|
||
value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher.
|
||
|
||
Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already
|
||
knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every
|
||
machine.
|
||
|
||
So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns.
|
||
What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable
|
||
trade and it is written down here so nobody later assumes the file is opaque.
|
||
|
||
`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying.
|
||
|
||
### 3. Where the file goes
|
||
|
||
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
|
||
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
|
||
problem this design exists to avoid.
|
||
|
||
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
|
||
documented loudly enough that a backup script excludes it deliberately.
|
||
|
||
### 4. One secret remains outside
|
||
|
||
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
|
||
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
|
||
two processes that need it.**
|
||
|
||
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
|
||
one value — or comes from a file read on demand.
|
||
|
||
### 5. What moves in
|
||
|
||
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
|
||
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
|
||
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
|
||
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
|
||
|
||
---
|
||
|
||
## Core, and plugins
|
||
|
||
The store is core infrastructure, created at first boot. It is **not** a side effect of installing any
|
||
one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months
|
||
finds it already there.
|
||
|
||
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
|
||
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**.
|
||
|
||
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
|
||
perimeter, and that `ALLOW_ANY_ORIGIN` defaulting on is only defensible because of it. A security model
|
||
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
|
||
the wallet are not load-bearing that way — nothing else stops working without them — so they become
|
||
plugins.
|
||
|
||
Moving headscale into the light profile also removes it from the app store automatically:
|
||
`catalogue.test.ts` asserts the catalogue equals `full − light`, so the test fails until the entry is
|
||
deleted. That derivation is doing its job and should not be worked around.
|
||
|
||
Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first
|
||
core credential needing a key.
|
||
|
||
---
|
||
|
||
## The contract
|
||
|
||
What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is:
|
||
|
||
- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose,
|
||
creating one on first use.
|
||
- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the
|
||
process-environment problem in a different container.
|
||
- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows.
|
||
- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to
|
||
be handed the retired key for data written before a rotation.
|
||
|
||
---
|
||
|
||
## Rotation
|
||
|
||
The feature that makes the store worth building, and the reason versions exist.
|
||
|
||
Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes
|
||
unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help,
|
||
because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down
|
||
offline, the coins are gone with it.
|
||
|
||
Rotation turns that into a supported action:
|
||
|
||
1. Mint a new key for the purpose, leaving the old one in the store as retired.
|
||
2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with
|
||
the new one.
|
||
3. Retire the old key only when every row has moved.
|
||
|
||
Two properties it must have, both learned from the failure it replaces:
|
||
|
||
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
|
||
tell which rows are which.
|
||
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
|
||
A key that is already wrong should fail loudly on row one rather than produce a second layer of
|
||
unreadable data.
|
||
|
||
`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large
|
||
wallet table and it cannot be interrupted safely, which argues for something that reports progress.
|
||
|
||
---
|
||
|
||
## What this does not change
|
||
|
||
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
|
||
changes, so no caller is touched.
|
||
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
|
||
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
1. Where the file lives, given it must not be swept up by a backup of `data/`.
|
||
2. Whether the store's own key stays in `.env` or moves to a file read on demand.
|
||
3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes.
|
||
4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency.
|
||
5. What happens to a plugin's keys when it is uninstalled. The app store already decided that
|
||
uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.
|