diff --git a/docs/dr/crypto-custody.md b/docs/dr/crypto-custody.md index a8da1877b..4e9972dc5 100644 --- a/docs/dr/crypto-custody.md +++ b/docs/dr/crypto-custody.md @@ -24,6 +24,7 @@ and [openbao.md](openbao.md) (OpenBao-specific DR). | **cosign signing identity**| sign the platform OCI artifact published to GHCR | publish artifacts the cluster will trust as ours | re-sign on next CI run (no on-disk key — see below) | | **Hetzner Cloud token** | provision / destroy nodes; reconfigure Cloud LB and firewall | destroy infrastructure; pivot via the LB | mint a new one in the Hetzner console; rotate `HCLOUD_TOKEN` | | **Cloudflare API token** | manage DNS for the platform domain | redirect prod traffic; mint Origin CA certs | mint a new one in the Cloudflare dashboard | +| **Actual Budget E2EE password** | client-side key to encrypt/decrypt an Actual budget file | decrypt that budget's financial data | the budget is **permanently unrecoverable** — the server never held the key | The rest of this document walks each row in detail. @@ -220,6 +221,49 @@ upstream at . --- +## Actual Budget end-to-end-encryption password + +**What:** a user-chosen password from which the Actual Budget **client** derives +the key that encrypts a budget file. It is a fundamentally different kind of +secret from the rest of this document: it is **not** a platform-managed key and +the sync-server never possesses it. When E2EE is enabled, the client derives the +key locally, re-encrypts the budget, and uploads only ciphertext plus non-secret +metadata (`keyId`, `keySalt`, an encrypted `testContent`) — so the server holds +no material that can decrypt the data. That is what makes it *end-to-end*, and +it is also why there is no declarative "enable encryption" switch: turning it on +is a one-time client action (see the +[actual-budget app README](../../k8s/bases/apps/actual-budget/README.md)). + +**Where it lives:** OpenBao at `apps/actual-budget/encryption` (property +`password`), seeded create-only with a placeholder by +`push-secret-seed-actual-budget-encryption.yaml`. Unlike every other secret in +this platform, **nothing in-cluster reads it back** — no ExternalSecret +materialises it — because only a client can use it. OpenBao is purely the +durable record so the password is not lost. + +### Custody recommendations + +Treat it like the SOPS Age keys: a primary copy in OpenBao, plus at least one +off-cluster copy in a password manager you actually use. It is short (a +human-typed passphrase), so a printed cold backup is trivial. + +### What to do if it leaks + +Rotate it: in the Actual UI, disable then re-enable encryption with a new +password (this re-encrypts the file), and update the OpenBao entry. Anyone who +captured the old ciphertext *and* the old password could decrypt the data as it +was, so also consider the exposed transactions compromised. + +### What to do if it is *lost* (no copies remaining) + +The budget file's server-side data is **permanently unrecoverable** — there is +no reset, no recovery, no operator override, because the platform never held the +key. The only fallback is an *unencrypted* copy of the budget that predates +encryption (a local device that still has it, or a pre-E2EE export). This is the +strongest reason to keep the OpenBao entry backed up. + +--- + ## Cross-references - [`runbook.md`](runbook.md) — operational scenarios (rotation steps, full diff --git a/k8s/bases/apps/actual-budget/README.md b/k8s/bases/apps/actual-budget/README.md new file mode 100644 index 000000000..6df9c988f --- /dev/null +++ b/k8s/bases/apps/actual-budget/README.md @@ -0,0 +1,46 @@ +# Actual Budget + +[Actual Budget](https://actualbudget.org/) is a local-first personal finance +app with an optional self-hosted sync server. + +- [Documentation](https://actualbudget.org/docs/) +- [Helm Chart](https://github.com/community-charts/helm-charts/tree/main/charts/actualbudget) + +## Bank sync (Enable Banking) + +Bank-sync credentials (Application ID + PEM secret key) live only in the +sync-server's internal `secrets` SQLite table — Actual exposes no env/config +hook for them. They are stored in OpenBao at `apps/actual-budget/enablebanking` +and reconciled into the table by the `enablebanking-seed` sidecar (see +[`config-map.yaml`](config-map.yaml)). The sync-server reaches the Enable +Banking API (`api.enablebanking.com`) egress-allowed by +[`cilium-network-policy.yaml`](cilium-network-policy.yaml). + +## End-to-end encryption (manual, by design) + +Actual's end-to-end encryption **cannot be enabled declaratively**, and by +design it should not be: it is a **client-side, password-derived** feature. When +you turn it on, the client derives an encryption key from a password *locally*, +re-encrypts the budget, and uploads only ciphertext plus non-secret key metadata +(`keyId`, `keySalt`, an encrypted `testContent`). The **server never sees the +password or key** — that is precisely what makes it end-to-end. There is no +`ACTUAL_*` env var and no server endpoint to turn it on; the sync-server's +`/user-create-key` only *stores* metadata the client computed. + +So enabling it is a **one-time manual step**, and what we manage declaratively is +the **password of record** (in OpenBao) so it survives DR: + +1. Store the password in OpenBao at `apps/actual-budget/encryption` + (property `password`). The path is seeded create-only with a placeholder by + [`push-secret-seed-actual-budget-encryption.yaml`](../../infrastructure/vault-seed/push-secret-seed-actual-budget-encryption.yaml); + type the real password in via the OpenBao UI/CLI. Nothing in-cluster reads it + back — it exists only as the durable record of the password. +2. In the Actual web UI: open the budget → **Settings → Show advanced settings → + Enable encryption**, and enter that same password. +3. Every device that opens the file will now prompt for the password once. + +> [!CAUTION] +> A lost E2EE password means the budget data is **permanently unrecoverable** — +> the server cannot help, because it never had the key. Treat the OpenBao +> `apps/actual-budget/encryption` entry as a root of trust; custody guidance is +> in [`docs/dr/crypto-custody.md`](../../../../docs/dr/crypto-custody.md). diff --git a/k8s/bases/infrastructure/vault-seed/kustomization.yaml b/k8s/bases/infrastructure/vault-seed/kustomization.yaml index d70ebc9ed..df537a3d8 100644 --- a/k8s/bases/infrastructure/vault-seed/kustomization.yaml +++ b/k8s/bases/infrastructure/vault-seed/kustomization.yaml @@ -43,3 +43,5 @@ resources: - push-secret-seed-velero-repo-credentials.yaml - secret-actual-budget-enablebanking-placeholder.yaml - push-secret-seed-actual-budget-enablebanking.yaml + - secret-actual-budget-encryption-placeholder.yaml + - push-secret-seed-actual-budget-encryption.yaml diff --git a/k8s/bases/infrastructure/vault-seed/push-secret-seed-actual-budget-encryption.yaml b/k8s/bases/infrastructure/vault-seed/push-secret-seed-actual-budget-encryption.yaml new file mode 100644 index 000000000..c7f74b673 --- /dev/null +++ b/k8s/bases/infrastructure/vault-seed/push-secret-seed-actual-budget-encryption.yaml @@ -0,0 +1,33 @@ +--- +# Seeds Actual Budget's end-to-end-encryption password path into OpenBao with a +# placeholder. updatePolicy: IfNotExists makes it create-only — once the path +# exists, the operator's real value (typed into OpenBao at +# apps/actual-budget/encryption) is never overwritten by the placeholder. +# +# Unlike the enablebanking seed, this value is NOT read back by any +# ExternalSecret or sidecar: enabling E2EE is an inherently client-side action +# (the client derives the key from this password and re-encrypts the file; the +# server only ever stores non-secret key metadata). OpenBao is therefore just +# the declarative store of record for DR — see docs/dr/crypto-custody.md. If +# OpenBao is re-initialized the placeholder re-seeds and the operator re-enters +# the password. +apiVersion: external-secrets.io/v1alpha1 +kind: PushSecret +metadata: + name: seed-actual-budget-encryption + namespace: flux-system +spec: + refreshInterval: 1h + updatePolicy: IfNotExists + secretStoreRefs: + - name: openbao + kind: ClusterSecretStore + selector: + secret: + name: actual-budget-encryption-placeholder + data: + - match: + secretKey: password + remoteRef: + remoteKey: apps/actual-budget/encryption + property: password diff --git a/k8s/bases/infrastructure/vault-seed/secret-actual-budget-encryption-placeholder.yaml b/k8s/bases/infrastructure/vault-seed/secret-actual-budget-encryption-placeholder.yaml new file mode 100644 index 000000000..68ae94b81 --- /dev/null +++ b/k8s/bases/infrastructure/vault-seed/secret-actual-budget-encryption-placeholder.yaml @@ -0,0 +1,25 @@ +--- +# Placeholder source for Actual Budget's end-to-end-encryption password. This is +# user-fed (not generated, not SOPS), so we seed OpenBao ONCE with a placeholder +# and the operator then types the real password into OpenBao at +# apps/actual-budget/encryption. The paired PushSecret uses updatePolicy: +# IfNotExists so this placeholder never overwrites that edit. +# +# Nothing in-cluster consumes this password: Actual's E2EE is a CLIENT-side, +# password-derived feature (the server never sees the key — that is what makes +# it end-to-end), so there is no ExternalSecret materialising it into the +# actual-budget namespace. OpenBao is purely the DR store of record for the +# password the operator types once into the web UI (see the actual-budget app +# README and docs/dr/crypto-custody.md). A lost password = permanently +# unrecoverable budget data, so keeping it here is the point. +# +# Not a real secret (literally "PLACEHOLDER"), so it is committed in plaintext — +# same pattern as the enablebanking placeholder alongside it. +apiVersion: v1 +kind: Secret +metadata: + name: actual-budget-encryption-placeholder + namespace: flux-system +type: Opaque +stringData: + password: PLACEHOLDER