Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/dr/crypto-custody.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -220,6 +221,49 @@ upstream at <https://docs.sigstore.dev/cosign/verifying/verify/>.

---

## 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
Expand Down
46 changes: 46 additions & 0 deletions k8s/bases/apps/actual-budget/README.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions k8s/bases/infrastructure/vault-seed/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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