From 964b9dfc15a18ee16c0a0641dba719104192d0b5 Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Thu, 10 Sep 2026 14:25:37 +0200 Subject: [PATCH] Add native NixOS deployment and UI-managed provider credentials --- .gitignore | 2 + OPERATIONS.txt | 132 ++++++++- README.md | 139 +++++++-- flake.lock | 27 ++ flake.nix | 28 ++ internal/app/app.go | 159 +++++++--- internal/app/app_test.go | 5 +- internal/app/banking_settings.go | 203 +++++++++++++ internal/app/banking_settings_test.go | 384 +++++++++++++++++++++++++ internal/app/import.go | 8 +- internal/app/openrouter_test.go | 219 ++++++++++++++ internal/banking/enablebanking.go | 31 +- internal/banking/enablebanking_test.go | 67 ++++- internal/server/server.go | 57 ++++ internal/server/server_test.go | 152 ++++++++++ nix/package.nix | 71 +++++ nix/service.nix | 119 ++++++++ web/src/Accounts.tsx | 7 +- web/src/Settings.tsx | 372 +++++++++++++++++++++++- web/src/api.ts | 4 +- web/src/main.tsx | 2 + 21 files changed, 2084 insertions(+), 104 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 internal/app/banking_settings.go create mode 100644 internal/app/banking_settings_test.go create mode 100644 internal/app/openrouter_test.go create mode 100644 nix/package.nix create mode 100644 nix/service.nix diff --git a/.gitignore b/.gitignore index 0ff48f0..4795135 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /web/dist/ /web/*.tsbuildinfo /bin/ +/result +/result-* .env* /secrets/ *.key diff --git a/OPERATIONS.txt b/OPERATIONS.txt index fe00b79..7f085a2 100644 --- a/OPERATIONS.txt +++ b/OPERATIONS.txt @@ -36,11 +36,99 @@ The container is unprivileged with a read-only root and a persistent named volume at /data. A host bind mount must be writable by UID/GID 10001. Never change the port mapping to public 0.0.0.0 without VPN/firewall isolation. +Native NixOS: the flake exports nixosModules.default. Import it into your host, +then set services.finance-duck.enable=true and publicURL to the exact browser +origin. Deploy with your normal nixos-rebuild switch. The module runs ONE +systemd service; React is embedded in the Go binary, not a separate service. +Defaults: listen 127.0.0.1:8080, service user finance-duck, persistent data at +/var/lib/finance-duck (0700). It does not configure networking or Tailscale. +Optional services.finance-duck.environmentFile names an existing runtime file +read by systemd for optional provider startup fallbacks. Both Enable Banking +and OpenRouter can be configured directly in Settings without that file. +Keep private keys outside the Nix store and readable only by the service. +See README's native NixOS section for optional environment-file permissions. + +Deployed host: bender@100.87.224.3 +-------------------------------- +URL: https://nixos.taile9e6d9.ts.net:8444 (Tailscale only, no application login). +The backend binds 127.0.0.1:8080. Existing Funnel on 443 and OpenClaw on 8443 +are separate. Never enable Funnel for Finance Duck. + +Update from your workstation: + ssh -t bender@100.87.224.3 'sudo finance-duck-update' +Or on the host: + sudo finance-duck-update + sudo finance-duck-update --no-pull # deploy existing checkout edits + +The updater serializes updates, pulls ~/projects/finance-duck as bender with +git pull --ff-only, creates a Git-filtered source snapshot, updates ONLY the +finance-duck input in /etc/nixos/flake.lock, runs nixos-rebuild switch, then +checks /api/health with the correct Host. The app has its own pinned Nixpkgs; +the host's Nixpkgs and NixVirt pins stay unchanged. A build failure leaves the +running service untouched; an activation/health failure exits nonzero. + +Application and deployment source are tracked in the project repository. +Commit and push changes before running the normal updater. New local source +files must be tracked with git add before a --no-pull deployment. Pull refuses +to overwrite conflicting local edits; resolve them before retrying. No +automatic stashing, resetting, committing or pushing occurs. Keep credentials +and live finance data out of Git. + +Host files: + /etc/nixos/flake.nix application input and module import + /etc/nixos/modules/finance-duck.nix service, tailnet proxy, update command + /var/lib/finance-duck-source replaceable Git-filtered source snapshot + /var/lib/finance-duck persistent data, finance-duck:finance-duck 0700 +The source snapshot excludes .git, ignored files and untracked files. It is +not a data backup. The initial deployment starts empty without provider +credentials; it does not copy the workstation's finance/ directory. + +Inspect on the host: + systemctl status finance-duck finance-duck-tailscale-serve + sudo journalctl -u finance-duck -f + tailscale serve status + curl -f https://nixos.taile9e6d9.ts.net:8444/api/health + +Before significant upgrades, stop the service and back up the entire data +directory to a protected location (including hidden recovery files). Restart +after the backup, and protect any separate credential files as well: + sudo systemctl stop finance-duck + sudo install -d -m 0700 /var/backups/finance-duck + sudo sh -c 'umask 077; tar -C /var/lib -czf /var/backups/finance-duck/data-$(date +%Y%m%dT%H%M%S).tar.gz finance-duck' + sudo systemctl start finance-duck +Copy backups to secure off-host storage; a backup on this disk cannot protect +against disk loss. + +Binary/system rollback: + sudo nixos-rebuild switch --rollback +This switches the previous NixOS generation, NOT financial data or config +source. The generation before the first deployment has no Finance Duck service. +For a lasting rollback, restore the intended source/config/lock before the +next update. Do not remove or roll back live data blindly. + OpenRouter ---------- -Set OPENROUTER_API_KEY in the process environment, then choose a model in -Settings. The model and chosen endpoint must support strict structured outputs -and the configured privacy routing. Every classification request sets: +Open Settings -> OpenRouter credentials, paste the API key, and Save key. +Choose the exact provider/model identifier in Classification preferences and +save preferences. Replace key rotates it; Remove key disables AI. No SSH, +Nix rebuild or restart is needed. Changes affect future classification +requests; in-flight requests retain their original key. + +The key is stored as private 0600 plaintext in state/openrouter.json under +the data directory, never in config.toml or browser storage. API responses +never return the saved key. Backups of the data directory contain this secret. +Only allow trusted users to reach the application; there is no separate +administrator login for changing credentials. + +OPENROUTER_API_KEY remains a startup fallback only when the saved credential +file is absent. A saved key overrides it. Removing the key in Settings saves +an explicit disable, which also overrides the environment after restart. +Malformed saved credentials fail startup rather than reverting to another key. + +Configured means present, not verified with OpenRouter. A successful +AI classification -> Analyse request verifies access with the selected model. +The model and endpoint must support strict structured outputs and the +configured privacy routing. Every classification request sets: provider.data_collection = deny provider.zdr = true provider.require_parameters = true @@ -71,17 +159,39 @@ Configure the allowed redirect URL to the exact externally reachable URL: https://finance.example.internal/api/banking/callback The browser must be able to reach this callback through your VPN. -Environment variables (all three required when enabling banking): +Open Settings -> Enable Banking credentials. Enter the application ID, upload +its matching private-key PEM file, and Save configuration. Settings supplies +the exact browser-origin callback URL to register. Keys must be a single +unencrypted RSA PKCS#1 or PKCS#8 PEM, >=2048 bits and <=32 KiB. +No SSH, environment file, Nix rebuild or restart is needed. + +Save validates local credentials without contacting Enable Banking. It does +not register/activate the app or authorize an account. Configured means local +setup is present; complete registration above and authorize under Accounts. + +Leave the key upload empty when editing the same application to keep its key. +Same-app key/callback rotation preserves sessions and cursors. Changing the +application ID requires a new key upload and reconnecting your banks. Remove +configuration confirms before disabling banking and clearing local connection +state. Canonical accounts/history remain; upstream bank consent is not revoked. +Each successful change invalidates pending authorization flows. + +Credentials persist privately (0600) in state/enablebanking.json beneath the +data directory. The API never returns the private key; browser storage never +contains it. Session state is bound to the application configuration generation, +so stale sessions cannot be revived by a restart after app changes/removal. +Back up the entire data directory securely, not isolated credential/state files. + +Optional environment startup fallback, used ONLY when no saved config exists: ENABLEBANKING_APP_ID= ENABLEBANKING_KEY_FILE=/run/secrets/enablebanking.key ENABLEBANKING_REDIRECT_URL=https://finance.example.internal/api/banking/callback -Use a PEM RSA private key (PKCS#1 or PKCS#8, at least 2048 bits). Mount it -read-only with permissions allowing the service user to read it. Never commit -it to Git. The Compose file contains a commented example key mount; set the -container-side KEY_FILE path when enabling that mount. +The referenced key must be readable by the service. Environment changes need +a restart. UI configuration or explicit removal overrides all three variables; +invalid saved configuration fails startup rather than using another key. -Accounts shows a copyable callback URL. Register it exactly with Enable Banking -and set ENABLEBANKING_REDIRECT_URL to the same value. The redirect carries a +Accounts shows the configured callback URL. Register it exactly with Enable +Banking. Settings saves the current browser callback URL. The redirect carries a one-time code and state, not a reusable API key. Go verifies state, exchanges the code for a session_id, and persists session details locally with mode 0600. @@ -143,6 +253,8 @@ finance/ merchants.finance journal/YYYY/YYYY-MM.finance state/sync-state.json sensitive local consent/session metadata + state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable + state/enablebanking.json sensitive UI-managed banking key/configuration or disable cache/finance.duckdb disposable analytical projection The custom grammar is deliberately small: diff --git a/README.md b/README.md index 1b11c24..f8a4eae 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Use the address you open Finance Duck at through your VPN, followed by `/api/ban https://finance.example.internal/api/banking/callback ``` -Replace `finance.example.internal` throughout these examples with your actual hostname. Register the exact URL with Enable Banking and configure the same value on the server, including scheme, hostname, port if applicable, and path. +Replace `finance.example.internal` throughout these examples with your actual hostname. **Settings → Enable Banking credentials** shows the exact callback URL to register, including scheme, hostname, port and path. On the deployed server it is `https://nixos.taile9e6d9.ts.net:8444/api/banking/callback`. For local use, the corresponding URL is: @@ -51,7 +51,7 @@ http://localhost:8080/api/banking/callback **This is a browser redirect, not a webhook.** Your browser must be connected to the VPN and able to reach the callback. Finance Duck does not need to be publicly exposed. The server needs outbound HTTPS access to Enable Banking. -The **Accounts** screen displays a copyable callback URL. Before configuration it suggests the current browser origin; afterward it displays `ENABLEBANKING_REDIRECT_URL`. +**Settings** shows a copyable callback URL for the current browser origin and saves it with the application credentials. **Accounts** displays the configured callback URL, or suggests the current origin before configuration. ### 2. Generate an application key and certificate @@ -74,10 +74,10 @@ nix-shell -p openssl --run ' ``` - Upload **`secrets/enablebanking.crt`**, the public certificate, to Enable Banking. -- Keep **`secrets/enablebanking.key`** private on your server and back it up securely. +- Upload **`secrets/enablebanking.key`** through Finance Duck's Settings, and back up your original key securely. Never upload it to Enable Banking in place of the public certificate. - Do not rerun the generation command over a key already in use. A different private key will not authenticate against the existing registered certificate. -The application accepts PEM RSA keys in PKCS#1 or PKCS#8 format, at least 2048 bits. It creates signed application JWTs itself; you do not need to generate daily tokens. +The application accepts a single unencrypted PEM RSA key in PKCS#1 or PKCS#8 format, at least 2048 bits and no larger than 32 KiB. It creates signed application JWTs itself; you do not need to generate daily tokens. ### 3. Register and activate the Enable Banking application @@ -92,9 +92,24 @@ Enable Banking documents restricted production access for individual, non-commer **Linking accounts in their control panel activates your application; it does not create Finance Duck's bank session.** You must also authorize from Finance Duck in step 5, even for the same account. -### 4. Configure Finance Duck and restart it +### 4. Configure Finance Duck in Settings -For a native process, set all three banking variables in the environment of the process that starts Finance Duck: +Open **Settings → Enable Banking credentials**: + +1. Enter the application ID from Enable Banking. +2. Select the matching private-key `.pem` or `.key` file. +3. Register the displayed callback URL in Enable Banking if you have not already. +4. Click **Save configuration**, then proceed to **Accounts** to authorize your bank. + +Saving validates the local key and callback format and applies the provider immediately; it does not contact Enable Banking, register an application, or grant bank consent. **Configured** means locally configured, not externally verified. No SSH, environment file, Nix rebuild, or restart is required. + +For the same application ID, leave the upload empty to keep the saved key, or upload a replacement to rotate it. Existing bank sessions and sync cursors are preserved. Changing the application ID requires a new upload and invalidates old local bank connections, so reconnect afterward. **Remove configuration** disables banking and invalidates local connections after confirmation; accounts and transaction history stay intact. It does not revoke consent at your bank. Successful configuration changes also cancel pending authorization flows. + +The private key and application settings are stored atomically in `state/enablebanking.json` beneath the data directory, with permissions `0600`. The key is never returned by the API or saved in browser storage. Banking sessions are bound to that configuration's application generation so old sessions cannot be reused after switching applications or removal, even across restart. Protect backups of the entire data directory. + +#### Optional environment-managed setup + +For headless configuration, all three variables below remain a startup fallback **only when no saved banking configuration exists**. A UI-saved configuration, including explicit removal, overrides them. Environment changes require a restart; malformed saved credentials fail closed rather than reverting to the environment. ```sh export ENABLEBANKING_APP_ID="your-application-id" @@ -111,7 +126,7 @@ This example assumes a VPN-accessible reverse proxy forwards to `127.0.0.1:8080` Stop the old process before restarting; only one process may write a finance directory. Native runs do **not** automatically load `.env` files. Shell exports also do not configure an already-running process or a systemd service—configure that service's environment separately. -For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose-recommended). +For Docker, use the container-side key path and the configuration in [Docker Compose](#docker-compose). ### 5. Authorize from the UI @@ -138,20 +153,94 @@ Reconnect → bank login and approval → callback code → new session Renewal preserves local account identities and existing transaction history. It does not create a second copy of the account or its transactions. Transient provider failures are displayed separately from expired consent. -Reconnecting renews bank consent, not your application registration. Problems with the registered certificate/private key must be corrected in the server configuration and Enable Banking control panel. +Reconnecting renews bank consent, not your application registration. Correct certificate registration in the Enable Banking control panel and update the matching private key in **Settings** (or your optional environment-managed setup). ## Deployment options -| Option | Best fit | Tradeoff / current support | -| --- | --- | --- | -| **Docker Compose** | Straightforward unattended deployment on a private server or VM | Included Dockerfile and Compose configuration; packages the Go runtime dependencies and frontend together. | -| **Native binary + systemd** | A small installation without a container daemon | Binary build is supported; configure your own service user, persistent data directory, credentials, and systemd unit. | -| **NixOS declarative OCI container** | Managing the application alongside other services in your NixOS configuration | Reuse the Docker image through `virtualisation.oci-containers`; a ready-made NixOS module is not included. | -| **Manual native run** | Development, evaluation, or troubleshooting | The quick-start command works, but it is not an unattended service and stops when the process is terminated. | +| Option | Best fit | Included support | +| ------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------- | +| **Native NixOS service** | Deployment on your own NixOS machine | Pinned flake package and one systemd service, with persistent state and runtime credentials. | +| **Docker Compose** | A private server using containers | Dockerfile and Compose configuration package the binary and frontend together. | +| **Manual native run** | Development or troubleshooting | The quick-start command runs in your terminal. | -A VM or VPS can host either of the first two options; it does not require a separate application architecture. Kubernetes and multiple replicas add little here: **run one application instance per canonical finance directory**. +**Run one application instance per canonical finance directory.** React is compiled and embedded in the Go binary: production needs neither Node.js nor a separate frontend service. -### Docker Compose (recommended) +### Native NixOS service (recommended on NixOS) + +Add this input to your host flake after committing and pushing the deployment files: + +```nix +inputs.finance-duck.url = "git+https://git.larsnolden.com/lars/finance-duck.git"; +``` + +Include `finance-duck` in your flake's `outputs` arguments, then add these entries to your existing `nixosSystem.modules` list: + +```nix +finance-duck.nixosModules.default +{ + services.finance-duck = { + enable = true; + publicURL = "http://localhost:8080"; + # Optional, once you have installed the runtime credential file: + # environmentFile = "/var/lib/finance-duck-secrets/environment"; + }; +} +``` + +Keep your existing host modules, including OpenClaw, unchanged. **Do not make this input's `nixpkgs` follow the host's NixOS 24.11 input:** Finance Duck needs Go 1.24 or newer. The package uses its own pinned toolchain; the service module works on the older host. + +Deploy and inspect: + +```sh +sudo nixos-rebuild switch --flake /etc/nixos#nixos +systemctl status finance-duck +journalctl -u finance-duck -f +``` + +The service runs as `finance-duck`, listens on **127.0.0.1:8080**, and stores all canonical data, settings, provider state, and the disposable DuckDB cache in **/var/lib/finance-duck**. It creates that directory with mode `0700`; rebuilds do not replace it. No firewall ports, VM, container, reverse proxy, or Tailscale configuration are created. + +For access from another machine, an SSH tunnel is sufficient: `ssh -L 8080:127.0.0.1:8080 your-server`, then open `http://localhost:8080`. For your existing Tailscale setup, instead set `publicURL` to the host's **actual full MagicDNS HTTPS origin**, for example `https://nixos.YOUR-TAILNET.ts.net:8444`, rebuild, and publish the loopback listener: + +```sh +sudo tailscale serve --bg --yes --https=8444 http://127.0.0.1:8080 +``` + +Choose an unused port; `8444` leaves the existing OpenClaw `8443` endpoint alone. Keep access restricted to trusted tailnet users. This application has no login; do not enable Funnel or expose it publicly. The Enable Banking callback must use that exact HTTPS origin plus `/api/banking/callback`. + +**Optional environment-managed credentials:** both Enable Banking and OpenRouter can be configured directly in **Settings**, without these steps. If you prefer a banking startup fallback, create a private runtime directory and install the key after the first rebuild creates the service account: + +```sh +sudo install -d -o root -g finance-duck -m 0750 /var/lib/finance-duck-secrets +sudo install -o root -g finance-duck -m 0640 secrets/enablebanking.key \ + /var/lib/finance-duck-secrets/enablebanking.key +sudo touch /var/lib/finance-duck-secrets/environment +sudo chmod 0600 /var/lib/finance-duck-secrets/environment +sudoedit /var/lib/finance-duck-secrets/environment +``` + +Put the three `ENABLEBANKING_*` variables from [bank setup](#optional-environment-managed-setup) in that file together, using `ENABLEBANKING_KEY_FILE=/var/lib/finance-duck-secrets/enablebanking.key`. Enable `environmentFile` in your Nix configuration and rebuild. Later environment-file changes require `sudo systemctl restart finance-duck`. An optional `OPENROUTER_API_KEY` provides the equivalent AI startup fallback. For each provider, saved Settings configuration or explicit removal takes precedence over its environment fallback. Never place private keys or credential values in Nix configuration or the Nix store. + +**Existing local data:** this service does not automatically adopt `./finance`. Stop the previous instance, back it up, stop `finance-duck.service`, and copy the entire data directory into `/var/lib/finance-duck`, owned by `finance-duck:finance-duck`. Never run two writers against the same directory. + +**Updates:** update the host's Finance Duck input with `nix flake update finance-duck` in `/etc/nixos`, then run `nixos-rebuild switch` again. Normal NixOS rollback restores the binary, not financial data; keep separate data backups. To build without installing a service, run `nix build` in this repository (the new flake and `nix/` files must be tracked by Git). + +#### Deployed server + +The server at `bender@100.87.224.3` serves Finance Duck at **https://nixos.taile9e6d9.ts.net:8444**, accessible only through Tailscale. Port 443's existing Funnel and OpenClaw on port 8443 are separate and unchanged. + +```sh +ssh -t bender@100.87.224.3 'sudo finance-duck-update' +# Deploy edits already in the server checkout without pulling: +ssh -t bender@100.87.224.3 'sudo finance-duck-update --no-pull' +``` + +The command pulls `~/projects/finance-duck` with `git pull --ff-only`, snapshots its Git-tracked working files to `/var/lib/finance-duck-source`, updates only the host's `finance-duck` flake input, rebuilds NixOS, and checks backend health. New files must be tracked with Git to enter the snapshot. Ignored data, untracked secrets, and `.git` are excluded; never track credentials. The host's Nixpkgs and NixVirt pins are not updated. + +Host configuration lives in `/etc/nixos/flake.nix` and `/etc/nixos/modules/finance-duck.nix`; the latter defines the service settings, tailnet proxy, and updater. Application data lives separately in `/var/lib/finance-duck`, owned by `finance-duck` with mode `0700`. The initial deployment has no bank/AI credentials and does not import workstation data. + +Application and deployment source are tracked in the project repository. Commit and push changes before running the normal updater; `--no-pull` can deploy tracked local edits. Git refuses an update that would overwrite conflicting local edits. The updater never stashes, resets, commits, or pushes automatically. See `OPERATIONS.txt` for service inspection, backup, and rollback commands. + +### Docker Compose For an initial local deployment without bank credentials: @@ -216,10 +305,10 @@ Configure a systemd service with: - An absolute binary path and `-data /var/lib/finance-duck`. - `-listen 127.0.0.1:8080` behind your VPN-only reverse proxy. - `-public-url https://finance.example.internal` matching the browser origin. -- Banking environment variables and a private key readable by that service user. +- Provider credentials configured through **Settings**, or optional banking environment variables and a private key readable by that service user. - Automatic startup and a restart policy, such as `Restart=on-failure`. -The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, deploy it with its runtime closure retained—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. The repository currently provides `shell.nix`, not a production Nix package or service module. +The binary uses CGO/native DuckDB dependencies; it is not a universally portable static Go executable. Build for your target OS/architecture and provide the required native runtime libraries. On NixOS, use the native flake package and service module above to retain its runtime closure—copying a Nix-linked binary alone to another machine, or garbage-collecting its unrooted runtime paths, is not a reliable deployment method. ### NixOS declarative OCI container @@ -233,7 +322,7 @@ docker build -t finance-duck:local . Make it available to the daemon used by the NixOS service, or publish it to a private registry and pin a version/digest. A rootless Docker image is not automatically available to the system Docker daemon or Podman. -Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** This is an available deployment approach, not an included ready-to-enable Finance Duck NixOS module. +Keep private keys and API keys in protected runtime files or a secrets manager. **Do not embed secret values in Nix expressions or copy them into the world-readable Nix store.** The included NixOS module runs the native package; an OCI deployment requires your own container configuration. ### VPN and reverse-proxy requirements @@ -243,7 +332,7 @@ For any deployment: - Point your chosen hostname at the VPN-accessible proxy and configure trusted HTTPS there. Finance Duck itself serves HTTP. - Forward to the application and preserve the public `Host` header. The application checks Host and mutation origins; forwarding only `X-Forwarded-Host` is insufficient. - For native runs, configure `-public-url`; for the supplied Compose configuration, set `FINANCE_PUBLIC_URL`. -- Set `ENABLEBANKING_REDIRECT_URL` to that same origin plus `/api/banking/callback`, and register it exactly with Enable Banking. +- Register the callback shown in **Settings → Enable Banking credentials**: the browser origin plus `/api/banking/callback`. For optional environment-managed setup, set `ENABLEBANKING_REDIRECT_URL` to that same URL. - Allow outbound HTTPS to Enable Banking, and optionally OpenRouter. No public inbound bank webhook is required. - Allow sufficiently long proxy requests for sequential bulk AI previews. @@ -251,7 +340,13 @@ A direct bind to a VPN interface is also supported with `-listen :8080` ## Optional OpenRouter setup -Set `OPENROUTER_API_KEY` in the server environment, or add it to the Compose `.env`, then restart/recreate the service and choose a supported model in **Settings**. +Open **Settings → OpenRouter credentials**, paste your API key, and click **Save key**. Then choose an exact OpenRouter `provider/model` identifier under **Classification preferences** and save those preferences. No SSH, Nix configuration changes, or service restart is needed. + +Use **Replace key** to rotate the credential or **Remove key** to disable AI. Changes apply to future classifications immediately and survive restart; an already-running classification keeps the key it started with. “Configured” means a key is present, not that OpenRouter has accepted it. A successful **AI classification → Analyse** request checks the key, model, and private routing together. + +The key is stored separately from preferences and journals in `state/openrouter.json` beneath your data directory (`/var/lib/finance-duck/state/openrouter.json` on the deployed server), as a private `0600` plaintext file. The API never returns it, and the UI clears the password field after saving rather than storing it in browser storage. Treat data-directory backups as containing credentials. Anyone able to access this no-login application can replace or remove the key: keep access restricted to trusted tailnet users. + +For administrator-managed startup configuration, `OPENROUTER_API_KEY` in the server environment or Compose `.env` remains an optional fallback **only while no saved credential file exists**. A UI-saved key takes precedence; **Remove key** persists an explicit disable, so a restart cannot silently restore the environment key. Environment-only changes require a restart/recreate. Bank imports do **not** require this key. Without AI, explicit merchant-default rules still work; unresolved transactions remain unclassified and editable. @@ -259,7 +354,7 @@ Every AI classification requests `provider.data_collection = "deny"`, `provider. ## Data, backups, and recovery -Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus the separately stored secrets. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt. +Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt. Use **Settings → Rebuild index** while the app runs. For an offline rebuild, stop the service first and run: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f7de8e7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1788921488, + "narHash": "sha256-8+xWRxEkD6l217cIUdRxfeUGS9lQX0hVtUuNVsBaDzk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6aefcda9401be8acc2b74244fb3b37520ea1f0a8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..998e119 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + description = "Finance Duck package and native NixOS service"; + + # Kept independent of the host's Nixpkgs: this application needs Go >= 1.24. + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + package = pkgs.callPackage ./nix/package.nix { }; + in + { + packages.${system} = { + default = package; + finance-duck = package; + }; + nixosModules.default = { lib, pkgs, ... }: { + imports = [ ./nix/service.nix ]; + services.finance-duck.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + checks.${system}.package = package; + formatter.${system} = pkgs.nixfmt; + }; +} diff --git a/internal/app/app.go b/internal/app/app.go index 01e7921..6273d23 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,10 +1,12 @@ package app import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strconv" @@ -30,36 +32,39 @@ type Status struct { AIConfigured bool `json:"ai_configured"` } type State struct { - Data domain.Dataset `json:"data"` - Revision string `json:"revision"` - Status Status `json:"status"` - Settings Settings `json:"settings"` - Sessions []banking.Session `json:"sessions"` - CallbackURL string `json:"callback_url"` - Connections []Connection `json:"connections"` + Data domain.Dataset `json:"data"` + Revision string `json:"revision"` + Status Status `json:"status"` + Settings Settings `json:"settings"` + Sessions []banking.Session `json:"sessions"` + CallbackURL string `json:"callback_url"` + BankingAppID string `json:"banking_app_id"` + Connections []Connection `json:"connections"` } type operational struct { - Sessions []banking.Session `json:"sessions"` - LastSync string `json:"last_sync"` - SyncError string `json:"sync_error"` - Consents map[string]Consent `json:"consents"` - AccountSync map[string]string `json:"account_sync"` + Sessions []banking.Session `json:"sessions"` + LastSync string `json:"last_sync"` + SyncError string `json:"sync_error"` + Consents map[string]Consent `json:"consents"` + AccountSync map[string]string `json:"account_sync"` + BankingScope string `json:"banking_scope"` } type App struct { - mu sync.Mutex - dir string - journal *journal.Store - index *analytics.Store - indexed string - indexError string - settings Settings - ops operational - bank banking.Provider - classifier classification.Client - previews map[string]Preview - authStates map[string]authorization - callbackURL string - syncRequested chan struct{} + mu sync.Mutex + dir string + journal *journal.Store + index *analytics.Store + indexed string + indexError string + settings Settings + ops operational + bank banking.Provider + classifier classification.Client + previews map[string]Preview + authStates map[string]authorization + callbackURL string + bankingSettings bankingSettings + syncRequested chan struct{} } func Open(dir string) (*App, error) { @@ -115,17 +120,13 @@ func Open(dir string) (*App, error) { if a.ops.AccountSync == nil { a.ops.AccountSync = make(map[string]string) } - a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount} - appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL") - a.callbackURL = redirect - if appID != "" || key != "" || redirect != "" { - if appID == "" || key == "" || redirect == "" { - return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables")) - } - a.bank, err = banking.NewEnableBanking(appID, key, redirect) - if err != nil { - return fail(err) - } + apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json")) + if err != nil { + return fail(err) + } + a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount} + if err = a.loadBankingSettings(); err != nil { + return fail(err) } a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb")) if err != nil { @@ -155,7 +156,7 @@ func (a *App) snapshot(ctx context.Context) (State, error) { a.indexError = "" } } - return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil + return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil } func (a *App) Snapshot(ctx context.Context) (State, error) { a.mu.Lock() @@ -237,6 +238,86 @@ func (a *App) saveOps() error { } return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n')) } + +func normalizeOpenRouterKey(key string) (string, error) { + key = strings.TrimSpace(key) + const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters" + if len(key) > 4096 { + return "", errors.New(invalid) + } + for i := range len(key) { + if key[i] < 0x21 || key[i] > 0x7e { + return "", errors.New(invalid) + } + } + return key, nil +} + +func loadOpenRouterKey(path string) (string, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY")) + } + if err != nil { + return "", errors.New("cannot read saved OpenRouter credential") + } + defer f.Close() + // Bound encoded storage too, allowing JSON escapes for a maximum-size key. + b, err := io.ReadAll(io.LimitReader(f, 32*1024+1)) + if err != nil { + return "", errors.New("cannot read saved OpenRouter credential") + } + invalid := errors.New("invalid saved OpenRouter credential") + if len(b) > 32*1024 { + return "", invalid + } + // Require exactly one case-sensitive string field, rejecting duplicates, + // unknown fields, null, and trailing JSON rather than silently disabling AI. + dec := json.NewDecoder(bytes.NewReader(b)) + if token, err := dec.Token(); err != nil || token != json.Delim('{') { + return "", invalid + } + if token, err := dec.Token(); err != nil || token != "api_key" { + return "", invalid + } + token, err := dec.Token() + key, ok := token.(string) + if err != nil || !ok { + return "", invalid + } + if token, err := dec.Token(); err != nil || token != json.Delim('}') { + return "", invalid + } + if _, err := dec.Token(); err != io.EOF { + return "", invalid + } + key, err = normalizeOpenRouterKey(key) + if err != nil { + return "", invalid + } + return key, nil +} + +func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) { + a.mu.Lock() + defer a.mu.Unlock() + key, err := normalizeOpenRouterKey(key) + if err != nil { + return State{}, err + } + b, err := json.Marshal(struct { + APIKey string `json:"api_key"` + }{APIKey: key}) + if err != nil { + return State{}, errors.New("cannot encode OpenRouter credential") + } + if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil { + return State{}, errors.New("cannot save OpenRouter credential") + } + a.classifier.APIKey = key + return a.snapshot(ctx) +} + func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) { a.mu.Lock() defer a.mu.Unlock() @@ -244,7 +325,7 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) { if len(s.Model) > 200 { return State{}, errors.New("model name is too long") } - b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n") + b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n") if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil { return State{}, err } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index eee6726..0de1afa 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -86,9 +86,12 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) { t.Fatalf("import not visible in analytics: %+v", dash.Totals) } } -func mockClassifier(t *testing.T, a *App) { +func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) { t.Helper() mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, check := range inspect { + check(r) + } var req struct { Messages []struct { Content string `json:"content"` diff --git a/internal/app/banking_settings.go b/internal/app/banking_settings.go new file mode 100644 index 0000000..95e2766 --- /dev/null +++ b/internal/app/banking_settings.go @@ -0,0 +1,203 @@ +package app + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + + "finance-duck/internal/banking" + "finance-duck/internal/domain" +) + +// Scope binds operational sessions to an application generation. It is committed +// with credentials, so a crash before saving sync-state cannot revive old sessions. +type bankingSettings struct { + AppID string `json:"app_id"` + RedirectURL string `json:"redirect_url"` + PrivateKey string `json:"private_key"` + Disabled bool `json:"disabled"` + Scope string `json:"scope"` +} + +func (a *App) clearBankingSessions(scope string) { + a.ops.Sessions = nil + a.ops.Consents = make(map[string]Consent) + a.ops.AccountSync = make(map[string]string) + a.ops.LastSync = "" + a.ops.SyncError = "" + a.ops.BankingScope = scope +} + +func (a *App) loadBankingSettings() error { + path := filepath.Join(a.dir, "state", "enablebanking.json") + f, err := os.Open(path) + var cfg bankingSettings + var provider *banking.EnableBanking + fromEnvironment := os.IsNotExist(err) + if fromEnvironment { + cfg.AppID = os.Getenv("ENABLEBANKING_APP_ID") + cfg.RedirectURL = os.Getenv("ENABLEBANKING_REDIRECT_URL") + keyFile := os.Getenv("ENABLEBANKING_KEY_FILE") + cfg.Disabled = cfg.AppID == "" && cfg.RedirectURL == "" && keyFile == "" + if !cfg.Disabled { + if cfg.AppID == "" || cfg.RedirectURL == "" || keyFile == "" { + return errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables") + } + key, e := os.Open(keyFile) + if e != nil { + return errors.New("cannot read Enable Banking private key") + } + b, e := io.ReadAll(io.LimitReader(key, banking.MaxPrivateKeyPEM+1)) + key.Close() + if e != nil { + return errors.New("cannot read Enable Banking private key") + } + cfg.PrivateKey = string(b) + } + // A stable environment identity detects app-ID changes on restart while + // retaining sessions through key or callback rotation of the same app. + hash := sha256.Sum256([]byte(cfg.AppID)) + cfg.Scope = "env_" + hex.EncodeToString(hash[:]) + } else { + if err != nil { + return errors.New("cannot read saved Enable Banking settings") + } + defer f.Close() + const limit = 256 * 1024 // Allows JSON escaping of a maximum-size PEM. + b, e := io.ReadAll(io.LimitReader(f, limit+1)) + invalid := errors.New("invalid saved Enable Banking settings") + if e != nil || len(b) > limit { + return invalid + } + decoder := json.NewDecoder(bytes.NewReader(b)) + if token, e := decoder.Token(); e != nil || token != json.Delim('{') { + return invalid + } + seen := make(map[string]bool, 5) + for decoder.More() { + token, e := decoder.Token() + name, ok := token.(string) + if e != nil || !ok || seen[name] { + return invalid + } + seen[name] = true + value, e := decoder.Token() + if e != nil { + return invalid + } + if name == "disabled" { + cfg.Disabled, ok = value.(bool) + } else { + var text string + text, ok = value.(string) + switch name { + case "app_id": + cfg.AppID = text + case "redirect_url": + cfg.RedirectURL = text + case "private_key": + cfg.PrivateKey = text + case "scope": + cfg.Scope = text + default: + return invalid + } + } + if !ok { + return invalid + } + } + if token, e := decoder.Token(); e != nil || token != json.Delim('}') { + return invalid + } + if _, e := decoder.Token(); e != io.EOF || len(seen) != 5 || cfg.Scope == "" || len(cfg.Scope) > 256 { + return invalid + } + if cfg.Disabled && (cfg.AppID != "" || cfg.RedirectURL != "" || cfg.PrivateKey != "") { + return invalid + } + } + if !cfg.Disabled { + provider, err = banking.NewEnableBanking(cfg.AppID, []byte(cfg.PrivateKey), cfg.RedirectURL) + if err != nil { + return errors.New("invalid Enable Banking settings") + } + } + if a.ops.BankingScope != cfg.Scope { + if a.ops.BankingScope == "" && fromEnvironment && !cfg.Disabled { + // Legacy sessions predate Settings and belong to the validated env app. + a.ops.BankingScope = cfg.Scope + } else { + a.clearBankingSessions(cfg.Scope) + } + // Fail closed if legacy binding or mismatch invalidation cannot persist. + if err = a.saveOps(); err != nil { + return errors.New("cannot bind Enable Banking sessions") + } + } + a.bankingSettings = cfg + a.callbackURL = cfg.RedirectURL + if provider != nil { + a.bank = provider + } + return nil +} + +func (a *App) SaveBankingSettings(ctx context.Context, appID string, privateKey *string, redirectURL string) (State, error) { + a.mu.Lock() + defer a.mu.Unlock() + key := "" + if privateKey != nil { + key = *privateKey + } else if !a.bankingSettings.Disabled && appID == a.bankingSettings.AppID { + key = a.bankingSettings.PrivateKey + } + if key == "" { + return State{}, errors.New("an Enable Banking private key is required for this application") + } + provider, err := banking.NewEnableBanking(appID, []byte(key), redirectURL) + if err != nil { + return State{}, err + } + cfg := bankingSettings{AppID: appID, PrivateKey: key, RedirectURL: redirectURL, Scope: a.bankingSettings.Scope} + if a.bankingSettings.Disabled || appID != a.bankingSettings.AppID || cfg.Scope == "" { + cfg.Scope = domain.NewID("bank") + } + return a.persistBankingSettings(ctx, cfg, provider) +} + +func (a *App) RemoveBankingSettings(ctx context.Context) (State, error) { + a.mu.Lock() + defer a.mu.Unlock() + return a.persistBankingSettings(ctx, bankingSettings{Disabled: true, Scope: domain.NewID("bank")}, nil) +} + +// Caller holds mu. Only the credential file must commit: an older sync-state +// remains unusable because its scope differs. The next operational save or Open +// writes the cleared sessions, without a fallible two-file transaction here. +func (a *App) persistBankingSettings(ctx context.Context, cfg bankingSettings, provider *banking.EnableBanking) (State, error) { + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return State{}, errors.New("cannot encode Enable Banking settings") + } + if err = atomicFile(filepath.Join(a.dir, "state", "enablebanking.json"), append(b, '\n')); err != nil { + return State{}, errors.New("cannot save Enable Banking settings") + } + if cfg.Scope != a.ops.BankingScope { + a.clearBankingSessions(cfg.Scope) + } + a.authStates = make(map[string]authorization) + a.bankingSettings = cfg + a.callbackURL = cfg.RedirectURL + a.bank = nil + if provider != nil { + a.bank = provider + } + return a.snapshot(ctx) +} diff --git a/internal/app/banking_settings_test.go b/internal/app/banking_settings_test.go new file mode 100644 index 0000000..2df4f24 --- /dev/null +++ b/internal/app/banking_settings_test.go @@ -0,0 +1,384 @@ +package app + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "finance-duck/internal/banking" + "finance-duck/internal/domain" +) + +const bankingCallback = "http://localhost:8080/api/banking/callback" + +func bankingKey(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return key, string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} + +// Exercise the live provider, verifying the actual signed JWT and callback sent +// upstream, rather than inspecting its private fields or merely saved metadata. +func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redirect string) string { + t.Helper() + pending := "" + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), ".") + if len(parts) != 3 { + t.Error("missing signed banking authorization") + w.WriteHeader(401) + return + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + hash := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err != nil || rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], sig) != nil { + t.Error("live provider signed with the wrong key") + w.WriteHeader(401) + return + } + headerBytes, _ := base64.RawURLEncoding.DecodeString(parts[0]) + var header map[string]string + if json.Unmarshal(headerBytes, &header) != nil || header["kid"] != appID { + t.Error("live provider signed for the wrong application") + } + switch r.URL.Path { + case "/aspsps": + fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`) + case "/auth": + var req struct { + State string `json:"state"` + Redirect string `json:"redirect_url"` + } + if json.NewDecoder(r.Body).Decode(&req) != nil || req.Redirect != redirect { + t.Error("wrong callback sent to banking provider") + } + pending = req.State + fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`) + case "/sessions": + fmt.Fprint(w, `{"session_id":"session-one","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"N26","country":"DE"},"accounts":[{"uid":"uid-one","identification_hash":"stable-one","account_id":{"iban":"DE02120300000000202051"},"details":"Bank account","currency":"EUR"}]}`) + case "/accounts/uid-one/balances": + fmt.Fprint(w, `{"balances":[{"balance_amount":{"currency":"EUR","amount":"12.50"},"balance_type":"CLBD"}]}`) + default: + t.Errorf("unexpected banking request: %s", r.URL.Path) + w.WriteHeader(500) + } + })) + t.Cleanup(mock.Close) + provider, ok := a.bank.(*banking.EnableBanking) + if !ok { + t.Fatal("banking provider unavailable") + } + provider.BaseURL = mock.URL + provider.HTTPClient = mock.Client() + if _, err := a.Authorize(context.Background(), "N26", "DE"); err != nil { + t.Fatal(err) + } + return pending +} + +func reopenBankingApp(t *testing.T, a *App) *App { + t.Helper() + dir := a.dir + if err := a.Close(); err != nil { + t.Fatal(err) + } + reopened, err := Open(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { reopened.Close() }) + return reopened +} + +func TestBankingRuntimeRotationPreservesConsentAndRejectsPendingCallback(t *testing.T) { + a, s := testApp(t) + s = seed(t, a, s) + key, keyPEM := bankingKey(t) + ctx := context.Background() + if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil { + t.Fatal(err) + } + pending := bankingAuthorization(t, a, key, "app-one", bankingCallback) + if err := a.Callback(ctx, "code", pending); err != nil { + t.Fatal(err) + } + before, err := a.Snapshot(ctx) + if err != nil { + t.Fatal(err) + } + accountID := before.Sessions[0].Accounts[0].ID + a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z" + if err := a.saveOps(); err != nil { + t.Fatal(err) + } + pending = bankingAuthorization(t, a, key, "app-one", bankingCallback) + rotated, rotatedPEM := bankingKey(t) + callback := "https://finance.example/api/banking/callback" + after, err := a.SaveBankingSettings(ctx, "app-one", &rotatedPEM, callback) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before.Sessions, after.Sessions) || !reflect.DeepEqual(before.Data, after.Data) || a.ops.AccountSync[accountID] == "" { + t.Fatal("same-application rotation discarded consent, cursor or canonical data") + } + if err := a.Callback(ctx, "code", pending); err == nil { + t.Fatal("rotation accepted a stale pending callback") + } + bankingAuthorization(t, a, rotated, "app-one", callback) + balances, err := a.Balances(ctx, accountID) + if err != nil || len(balances) != 1 || balances[0].Amount.String() != "12.50" { + t.Fatalf("rotated consent could not fetch balances: %v %v", balances, err) + } + a = reopenBankingApp(t, a) + bankingAuthorization(t, a, rotated, "app-one", callback) + if _, err := a.Balances(ctx, accountID); err != nil { + t.Fatal("same-app consent did not survive restart", err) + } + if _, err := a.SaveBankingSettings(ctx, "app-one", nil, bankingCallback); err != nil { + t.Fatal(err) + } + bankingAuthorization(t, a, rotated, "app-one", bankingCallback) +} + +func TestBankingAppSwitchAndDisableNeverReuseOldSessions(t *testing.T) { + for _, remove := range []bool{false, true} { + t.Run(fmt.Sprint("remove=", remove), func(t *testing.T) { + a, s := testApp(t) + seed(t, a, s) + key, keyPEM := bankingKey(t) + ctx := context.Background() + if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil { + t.Fatal(err) + } + pending := bankingAuthorization(t, a, key, "app-one", bankingCallback) + if err := a.Callback(ctx, "code", pending); err != nil { + t.Fatal(err) + } + before, _ := a.Snapshot(ctx) + accountID := before.Sessions[0].Accounts[0].ID + a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z" + if err := a.saveOps(); err != nil { + t.Fatal(err) + } + pending = bankingAuthorization(t, a, key, "app-one", bankingCallback) + var err error + if remove { + _, err = a.RemoveBankingSettings(ctx) + } else { + _, err = a.SaveBankingSettings(ctx, "app-two", &keyPEM, bankingCallback) + } + if err != nil { + t.Fatal(err) + } + for restart := range 2 { + if restart != 0 { + // The credential save deliberately left old sync-state on disk. + a = reopenBankingApp(t, a) + } + if !remove { + bankingAuthorization(t, a, key, "app-two", bankingCallback) + } + if err := a.Callback(ctx, "code", pending); err == nil { + t.Fatal("old pending authorization accepted after app change") + } + if _, err := a.Balances(ctx, accountID); err == nil { + t.Fatal("old account UID used with changed credentials") + } + after, err := a.Snapshot(ctx) + if err != nil || len(after.Sessions) != 0 || len(a.ops.AccountSync) != 0 || !reflect.DeepEqual(before.Data, after.Data) { + t.Fatal("app change retained session/cursor or changed canonical data") + } + } + }) + } +} + +func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) { + a, _ := testApp(t) + envKey, envPEM := bankingKey(t) + keyFile := filepath.Join(t.TempDir(), "env.pem") + if err := os.WriteFile(keyFile, []byte(envPEM), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("ENABLEBANKING_APP_ID", "environment-app") + t.Setenv("ENABLEBANKING_KEY_FILE", keyFile) + t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback) + a = reopenBankingApp(t, a) + bankingAuthorization(t, a, envKey, "environment-app", bankingCallback) + key, keyPEM := bankingKey(t) + ctx := context.Background() + state, err := a.SaveBankingSettings(ctx, "saved-app", &keyPEM, bankingCallback) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(state) + if err != nil || strings.Contains(string(encoded), "PRIVATE KEY") || strings.Contains(string(encoded), "private_key") { + t.Fatal("private key exposed in State") + } + info, err := os.Stat(filepath.Join(a.dir, "state", "enablebanking.json")) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("saved credential is not private") + } + // Saved settings must not even read a now-unavailable environment key. + t.Setenv("ENABLEBANKING_KEY_FILE", filepath.Join(t.TempDir(), "missing.pem")) + a = reopenBankingApp(t, a) + bankingAuthorization(t, a, key, "saved-app", bankingCallback) + if _, err := a.RemoveBankingSettings(ctx); err != nil { + t.Fatal(err) + } + a = reopenBankingApp(t, a) + if _, err := a.Authorize(ctx, "N26", "DE"); err == nil { + t.Fatal("disabled saved configuration fell back to environment") + } +} + +func TestBankingRejectedSettingsAndFailedWritePreserveActiveProvider(t *testing.T) { + a, _ := testApp(t) + key, keyPEM := bankingKey(t) + ctx := context.Background() + if _, err := a.SaveBankingSettings(ctx, "active-app", &keyPEM, bankingCallback); err != nil { + t.Fatal(err) + } + bad := "secret-invalid-private-key" + for _, input := range []struct { + app string + key *string + callback string + }{ + {"active-app", &bad, bankingCallback}, + {"new-app", nil, bankingCallback}, + {"", &keyPEM, bankingCallback}, + {"secret invalid app", &keyPEM, bankingCallback}, + {"active-app", &keyPEM, "https://secret.example/wrong"}, + } { + if _, err := a.SaveBankingSettings(ctx, input.app, input.key, input.callback); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("invalid configuration accepted or leaked in error") + } + } + pending := bankingAuthorization(t, a, key, "active-app", bankingCallback) + path := filepath.Join(a.dir, "state", "enablebanking.json") + if err := os.Rename(path, path+".backup"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + if _, err := a.SaveBankingSettings(ctx, "new-app", &keyPEM, bankingCallback); err == nil { + t.Fatal("failed write reported success") + } + if _, err := a.RemoveBankingSettings(ctx); err == nil { + t.Fatal("failed removal reported success") + } + if err := a.Callback(ctx, "code", pending); err != nil { + t.Fatal("failed credential write invalidated active authorization", err) + } + bankingAuthorization(t, a, key, "active-app", bankingCallback) +} + +func TestBankingMalformedSavedSettingsFailClosed(t *testing.T) { + _, keyPEM := bankingKey(t) + for _, malformed := range []string{ + `{}`, `null`, `{"private_key":"secret-invalid-key"}`, `{"disabled":true,"scope":"bank_test","app_id":"","redirect_url":"","private_key":"","disabled":false}`, + } { + a, _ := testApp(t) + if _, err := a.SaveBankingSettings(context.Background(), "valid-app", &keyPEM, bankingCallback); err != nil { + t.Fatal(err) + } + dir := a.dir + if err := a.Close(); err != nil { + t.Fatal(err) + } + t.Setenv("ENABLEBANKING_APP_ID", "environment-app") + if err := os.WriteFile(filepath.Join(dir, "state", "enablebanking.json"), []byte(malformed), 0600); err != nil { + t.Fatal(err) + } + reopened, err := Open(dir) + if err == nil { + reopened.Close() + t.Fatal("invalid saved settings were accepted") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "APP_ID") { + t.Fatal("saved error leaked input or fell back to environment") + } + } +} + +func TestBankingLegacyEnvironmentSessionsBindBeforeFirstSave(t *testing.T) { + a, s := testApp(t) + key, keyPEM := bankingKey(t) + keyFile := filepath.Join(t.TempDir(), "env.pem") + if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("ENABLEBANKING_APP_ID", "legacy-app") + t.Setenv("ENABLEBANKING_KEY_FILE", keyFile) + t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback) + account := s.Data.Accounts[0] + account.ExternalAccountID = "uid-one" + // Simulate sync-state written by the version predating Settings. + a.ops.Sessions = []banking.Session{{ID: "legacy-session", ValidUntil: "2099-01-01T00:00:00Z", Accounts: []domain.Account{account}}} + a.ops.BankingScope = "" + a.ops.AccountSync[account.ID] = "2026-09-01T00:00:00Z" + if err := a.saveOps(); err != nil { + t.Fatal(err) + } + a = reopenBankingApp(t, a) + ctx := context.Background() + before, err := a.Snapshot(ctx) + if err != nil || len(before.Sessions) != 1 { + t.Fatal("legacy environment consent was not retained") + } + // Restart again before any UI save, proving the migration itself persisted. + a = reopenBankingApp(t, a) + after, err := a.SaveBankingSettings(ctx, "legacy-app", nil, bankingCallback) + if err != nil || !reflect.DeepEqual(before.Sessions, after.Sessions) || a.ops.AccountSync[account.ID] == "" { + t.Fatal("first same-app Settings save discarded legacy consent") + } + bankingAuthorization(t, a, key, "legacy-app", bankingCallback) +} + +func TestBankingEnvironmentAppChangeInvalidatesBoundSessions(t *testing.T) { + a, _ := testApp(t) + key, keyPEM := bankingKey(t) + keyFile := filepath.Join(t.TempDir(), "env.pem") + if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("ENABLEBANKING_APP_ID", "env-one") + t.Setenv("ENABLEBANKING_KEY_FILE", keyFile) + t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback) + a = reopenBankingApp(t, a) + ctx := context.Background() + pending := bankingAuthorization(t, a, key, "env-one", bankingCallback) + if err := a.Callback(ctx, "code", pending); err != nil { + t.Fatal(err) + } + before, _ := a.Snapshot(ctx) + t.Setenv("ENABLEBANKING_APP_ID", "env-two") + a = reopenBankingApp(t, a) + bankingAuthorization(t, a, key, "env-two", bankingCallback) + if _, err := a.Balances(ctx, before.Sessions[0].Accounts[0].ID); err == nil { + t.Fatal("environment app change reused another application's account") + } + after, err := a.Snapshot(ctx) + if err != nil || len(after.Sessions) != 0 || !reflect.DeepEqual(before.Data, after.Data) { + t.Fatal("environment change retained sessions or lost canonical data") + } +} diff --git a/internal/app/import.go b/internal/app/import.go index 2c3f2f8..3cc5327 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -218,7 +218,13 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error } for _, account := range s.Data.Accounts { if account.ID == id && account.ExternalAccountID != "" { - return a.bank.Balances(ctx, account.ExternalAccountID) + for _, session := range a.ops.Sessions { + for _, linked := range session.Accounts { + if linked.ID == account.ID && linked.ExternalAccountID == account.ExternalAccountID { + return a.bank.Balances(ctx, linked.ExternalAccountID) + } + } + } } } return nil, errors.New("account is not connected") diff --git a/internal/app/openrouter_test.go b/internal/app/openrouter_test.go new file mode 100644 index 0000000..43362b0 --- /dev/null +++ b/internal/app/openrouter_test.go @@ -0,0 +1,219 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) { + t.Helper() + p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}) + if err != nil { + t.Fatal(err) + } + defer a.CancelPreview(p.ID) + if key == "" { + if len(p.Changes) != 0 || len(p.Errors) != 2 { + t.Fatal("disabled AI did not leave both transactions unclassified") + } + } else { + if len(p.Errors) != 0 || len(p.Changes) != 2 { + t.Fatalf("classification failed: %+v", p.Errors) + } + for _, change := range p.Changes { + if change.After.CategoryID != "groceries" { + t.Fatal("provider classification was not applied to the preview") + } + select { + case got := <-auth: + if got != "Bearer "+key { + t.Fatal("provider received the wrong Authorization credential") + } + default: + t.Fatal("classification did not reach the provider") + } + } + } + select { + case <-auth: + t.Fatal("unexpected provider request") + default: + } +} + +func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) { + a, s := testApp(t) + s = seed(t, a, s) + auth := make(chan string, 8) + mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") }) + for _, key := range []string{"first-private-key", "replacement-private-key", ""} { + var err error + s, err = a.SaveOpenRouterKey(context.Background(), " \t"+key+"\r\n") + if err != nil { + t.Fatal(err) + } + if s.Status.AIConfigured != (key != "") { + t.Fatal("credential status did not update immediately") + } + encoded, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "private-key") { + t.Fatal("saved credential leaked into browser state") + } + checkOpenRouterPreview(t, a, s, auth, key) + } +} + +func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testing.T) { + a, s := testApp(t) + s = seed(t, a, s) + auth := make(chan string, 8) + mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") }) + dir, baseURL := a.dir, a.classifier.BaseURL + t.Setenv("OPENROUTER_API_KEY", "environment-private-key") + reopen := func() { + t.Helper() + if err := a.Close(); err != nil { + t.Fatal(err) + } + var err error + a, err = Open(dir) + if err != nil { + t.Fatal(err) + } + a.classifier.BaseURL = baseURL + s, err = a.Snapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { + if a != nil { + a.Close() + } + }) + reopen() + checkOpenRouterPreview(t, a, s, auth, "environment-private-key") + for _, key := range []string{"saved-private-key", ""} { + var err error + s, err = a.SaveOpenRouterKey(context.Background(), key) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "state", "openrouter.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("credential permissions: %o, want 600", info.Mode().Perm()) + } + reopen() + if s.Status.AIConfigured != (key != "") { + t.Fatal("restarted credential status ignored saved preference") + } + checkOpenRouterPreview(t, a, s, auth, key) + } +} + +func TestOpenRouterMalformedStorageFailsClosedWithoutLeaking(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "environment-private-key") + t.Setenv("ENABLEBANKING_APP_ID", "") + t.Setenv("ENABLEBANKING_KEY_FILE", "") + t.Setenv("ENABLEBANKING_REDIRECT_URL", "") + for name, content := range map[string]string{ + "missing": `{}`, + "null": `{"api_key":null}`, + "wrong type": `{"api_key":123}`, + "case variant": `{"API_KEY":"saved-private-key"}`, + "unknown field": `{"api_key":"saved-private-key","extra":true}`, + "duplicate": `{"api_key":"saved-private-key","api_key":""}`, + "trailing JSON": `{"api_key":"saved-private-key"} {}`, + "malformed": `{"api_key":"saved-private-key`, + "control byte": `{"api_key":"saved-private-key\u0000"}`, + "oversized": `{"api_key":"` + strings.Repeat("k", 4097) + `"}`, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "state"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "state", "openrouter.json"), []byte(content), 0600); err != nil { + t.Fatal(err) + } + a, err := Open(dir) + if err == nil { + a.Close() + t.Fatal("malformed credential silently fell back to environment") + } + if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) { + t.Fatal("startup error leaked credential content") + } + }) + } +} + +func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) { + a, s := testApp(t) + s = seed(t, a, s) + auth := make(chan string, 8) + mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") }) + key := strings.Repeat("k", 4096) + s, err := a.SaveOpenRouterKey(context.Background(), key) + if err != nil { + t.Fatal("maximum-size key was rejected") + } + for name, invalid := range map[string]string{ + "too long": key + "k", + "internal whitespace": "private-key value", + "control byte": "private-key\x00", + "DEL": "private-key\x7f", + "non ASCII": "private-key\u00e9", + } { + t.Run(name, func(t *testing.T) { + _, err := a.SaveOpenRouterKey(context.Background(), invalid) + if err == nil { + t.Fatal("invalid credential was accepted") + } + if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) { + t.Fatal("validation error leaked credential content") + } + }) + } + checkOpenRouterPreview(t, a, s, auth, key) +} + +func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) { + a, s := testApp(t) + s = seed(t, a, s) + auth := make(chan string, 8) + mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") }) + s, err := a.SaveOpenRouterKey(context.Background(), "active-private-key") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(a.dir, "state", "openrouter.json") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + // A directory at the destination makes atomic rename fail even as root. + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + for _, key := range []string{"replacement-private-key", ""} { + _, err := a.SaveOpenRouterKey(context.Background(), key) + if err == nil { + t.Fatal("credential save unexpectedly succeeded") + } + if strings.Contains(err.Error(), "private-key") { + t.Fatal("persistence error leaked credential content") + } + } + checkOpenRouterPreview(t, a, s, auth, "active-private-key") +} diff --git a/internal/banking/enablebanking.go b/internal/banking/enablebanking.go index af56e30..22ec103 100644 --- a/internal/banking/enablebanking.go +++ b/internal/banking/enablebanking.go @@ -19,7 +19,6 @@ import ( "io" "net/http" "net/url" - "os" "strings" "time" @@ -58,21 +57,29 @@ type EnableBanking struct { var _ Provider = (*EnableBanking)(nil) -func NewEnableBanking(appID, keyFile, redirectURL string) (*EnableBanking, error) { - if strings.TrimSpace(appID) == "" { - return nil, fmt.Errorf("Enable Banking application ID is required") +// MaxPrivateKeyPEM bounds uploaded and environment-loaded private keys. +const MaxPrivateKeyPEM = 32 * 1024 + +func NewEnableBanking(appID string, keyPEM []byte, redirectURL string) (*EnableBanking, error) { + if len(appID) == 0 || len(appID) > 256 { + return nil, errors.New("invalid Enable Banking application ID") + } + for _, c := range appID { + if c < 33 || c > 126 { + return nil, errors.New("invalid Enable Banking application ID") + } } redirect, err := url.Parse(redirectURL) - if err != nil || redirect.Host == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil { - return nil, fmt.Errorf("invalid Enable Banking redirect URL") + if err != nil || redirect.Hostname() == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil || redirect.Opaque != "" || redirect.Path != "/api/banking/callback" || redirect.RawPath != "" || redirect.RawQuery != "" || redirect.ForceQuery || redirect.Fragment != "" || strings.Contains(redirectURL, "#") { + return nil, errors.New("invalid Enable Banking redirect URL") } - content, err := os.ReadFile(keyFile) - if err != nil { - return nil, fmt.Errorf("read Enable Banking RSA private key: %w", err) + if len(keyPEM) > MaxPrivateKeyPEM { + return nil, errors.New("Enable Banking private key exceeds size limit") } - block, _ := pem.Decode(content) - if block == nil { - return nil, fmt.Errorf("Enable Banking key must be PEM encoded") + content := bytes.TrimSpace(keyPEM) + block, rest := pem.Decode(content) + if block == nil || !bytes.HasPrefix(content, []byte("-----BEGIN "+block.Type+"-----")) || len(bytes.TrimSpace(rest)) != 0 || len(block.Headers) != 0 { + return nil, errors.New("Enable Banking key must be a single PEM private key") } var key *rsa.PrivateKey switch block.Type { diff --git a/internal/banking/enablebanking_test.go b/internal/banking/enablebanking_test.go index a9fc04a..7dae5ca 100644 --- a/internal/banking/enablebanking_test.go +++ b/internal/banking/enablebanking_test.go @@ -1,6 +1,7 @@ package banking import ( + "bytes" "context" "crypto" "crypto/rand" @@ -14,8 +15,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -27,11 +26,8 @@ func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa. if err != nil { t.Fatal(err) } - path := filepath.Join(t.TempDir(), "private.pem") - if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0600); err != nil { - t.Fatal(err) - } - p, err := NewEnableBanking("test-app", path, "http://localhost:8080/api/banking/callback") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + p, err := NewEnableBanking("test-app", keyPEM, "http://localhost:8080/api/banking/callback") if err != nil { t.Fatal(err) } @@ -268,3 +264,60 @@ func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) { t.Fatalf("expired consent must request reconnection: %v", err) } } + +func TestEnableBankingValidatesUploadedCredentials(t *testing.T) { + _, key := testProvider(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("credential validation must not call provider") + }) + pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + pkcs8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + for _, content := range [][]byte{pkcs1, pkcs8} { + p, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback") + if err != nil { + t.Fatal(err) + } + token, err := p.jwt() + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer "+token) + assertJWT(t, r, key) + } + weak, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string][]byte{ + "invalid": []byte("secret-invalid-key"), + "oversized": bytes.Repeat([]byte("k"), MaxPrivateKeyPEM+1), + "multiple": append(append([]byte{}, pkcs1...), pkcs8...), + "prefix": append([]byte("secret-prefix\n"), pkcs1...), + "weak": pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(weak)}), + } { + t.Run(name, func(t *testing.T) { + if _, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback"); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("invalid PEM accepted or leaked") + } + }) + } + for _, appID := range []string{"", "app one", "app\none", "app\u007fone", strings.Repeat("a", 257)} { + if _, err := NewEnableBanking(appID, pkcs1, "https://finance.example/api/banking/callback"); err == nil { + t.Fatal("invalid app ID accepted") + } + } + for _, redirect := range []string{ + "https://finance.example/", "https://finance.example/api/banking/callback?secret=value", + "https://finance.example/api/banking/callback#", "https://finance.example/api/banking/callback?", + "https://user:secret@finance.example/api/banking/callback", "ftp://finance.example/api/banking/callback", + "https://finance.example/api/banking/%63allback", "https:///api/banking/callback", + } { + if _, err := NewEnableBanking("test-app", pkcs1, redirect); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatal("invalid callback accepted or leaked") + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 63bfdd8..7d8d94a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -44,6 +44,8 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) { s.mux.HandleFunc("POST /api/rebuild", func(w http.ResponseWriter, r *http.Request) { v, e := a.Rebuild(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/sync", func(w http.ResponseWriter, r *http.Request) { v, e := a.Sync(r.Context()); respond(w, v, e) }) s.mux.HandleFunc("POST /api/settings", s.settings) + s.mux.HandleFunc("POST /api/settings/openrouter", s.openRouterKey) + s.mux.HandleFunc("POST /api/settings/enablebanking", s.bankingSettings) s.mux.HandleFunc("POST /api/banking/authorize", s.authorize) s.mux.HandleFunc("GET /api/banking/callback", s.callback) s.mux.HandleFunc("GET /api/balances", func(w http.ResponseWriter, r *http.Request) { @@ -287,6 +289,61 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { v, e := s.app.SaveSettings(r.Context(), b) respond(w, v, e) } +func (s *Server) openRouterKey(w http.ResponseWriter, r *http.Request) { + var b struct { + APIKey *string `json:"api_key"` + } + d := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + d.DisallowUnknownFields() + // Decoder errors can quote request values. Never echo credential input. + if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF || b.APIKey == nil { + respond(w, nil, errors.New("expected one JSON object with an api_key string")) + return + } + v, e := s.app.SaveOpenRouterKey(r.Context(), *b.APIKey) + respond(w, v, e) +} +func (s *Server) bankingSettings(w http.ResponseWriter, r *http.Request) { + var b struct { + AppID *string `json:"app_id"` + PrivateKey *string `json:"private_key"` + RedirectURL *string `json:"redirect_url"` + Remove bool `json:"remove"` + } + d := json.NewDecoder(io.LimitReader(r.Body, 256<<10)) + d.DisallowUnknownFields() + // Parsing failures must not echo uploaded private-key content. + if d.Decode(&b) != nil || d.Decode(&struct{}{}) != io.EOF { + respond(w, nil, errors.New("invalid Enable Banking configuration request")) + return + } + if b.Remove { + if b.AppID != nil || b.PrivateKey != nil || b.RedirectURL != nil { + respond(w, nil, errors.New("remove cannot be combined with banking credentials")) + return + } + v, e := s.app.RemoveBankingSettings(r.Context()) + respond(w, v, e) + return + } + if b.AppID == nil || b.RedirectURL == nil { + respond(w, nil, errors.New("Enable Banking application ID and callback URL are required")) + return + } + scheme, host := "http", r.Host + if r.TLS != nil { + scheme = "https" + } + if s.origin != nil { + scheme, host = s.origin.Scheme, s.origin.Host + } + if *b.RedirectURL != scheme+"://"+host+"/api/banking/callback" { + respond(w, nil, errors.New("Enable Banking callback URL must match this application's origin and /api/banking/callback path")) + return + } + v, e := s.app.SaveBankingSettings(r.Context(), *b.AppID, b.PrivateKey, *b.RedirectURL) + respond(w, v, e) +} func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { var b struct { Institution string `json:"institution"` diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5c9a19e..a495f37 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,7 +1,11 @@ package server import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/json" + "encoding/pem" "io" "net/http" "net/http/httptest" @@ -61,3 +65,151 @@ func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) { t.Fatalf("UI not served: %d %s", w.Code, b) } } + +func TestOpenRouterKeyIsWriteOnlyAndRequiresExplicitRemoval(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("ENABLEBANKING_APP_ID", "") + t.Setenv("ENABLEBANKING_KEY_FILE", "") + t.Setenv("ENABLEBANKING_REDIRECT_URL", "") + a, err := app.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer a.Close() + h, err := New(a, fstest.MapFS{}, "") + if err != nil { + t.Fatal(err) + } + const secret = "test-openrouter-private-key" + check := func(method, path, body, origin string, want int) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, "http://localhost:8080"+path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", origin) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if strings.Contains(w.Body.String(), secret) { + t.Fatal("credential leaked in HTTP response") + } + if w.Code != want { + t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String()) + } + return w + } + configured := func(w *httptest.ResponseRecorder, want bool) { + t.Helper() + var state app.State + if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil { + t.Fatal(err) + } + if state.Status.AIConfigured != want { + t.Fatalf("configured = %t, want %t", state.Status.AIConfigured, want) + } + } + const endpoint = "/api/settings/openrouter" + const origin = "http://localhost:8080" + keyJSON := `{"api_key":"` + secret + `"}` + check("POST", endpoint, keyJSON, "https://attacker.example", http.StatusForbidden) + configured(check("GET", "/api/state", "", origin, http.StatusOK), false) + configured(check("POST", endpoint, keyJSON, origin, http.StatusOK), true) + configured(check("GET", "/api/state", "", origin, http.StatusOK), true) + // Ordinary preference updates must not implicitly erase credentials. + configured(check("POST", "/api/settings", `{"model":"example/model","include_amount":false}`, origin, http.StatusOK), true) + for _, body := range []string{ + `{}`, + `{"api_key":null}`, + `{"api_key":["` + secret + `"]}`, + `{"` + secret + `":"unexpected field"}`, + keyJSON + `{}`, + `{"api_key":"` + secret + `\ninvalid"}`, + } { + check("POST", endpoint, body, origin, http.StatusBadRequest) + configured(check("GET", "/api/state", "", origin, http.StatusOK), true) + } + configured(check("POST", endpoint, `{"api_key":""}`, origin, http.StatusOK), false) + configured(check("GET", "/api/state", "", origin, http.StatusOK), false) +} + +func TestBankingConfigurationProtectsPrivateKeyAndCallbackOrigin(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("ENABLEBANKING_APP_ID", "") + t.Setenv("ENABLEBANKING_KEY_FILE", "") + t.Setenv("ENABLEBANKING_REDIRECT_URL", "") + a, err := app.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer a.Close() + const origin = "https://finance.internal:8444" + const callback = origin + "/api/banking/callback" + const endpoint = "/api/settings/enablebanking" + h, err := New(a, fstest.MapFS{}, origin) + if err != nil { + t.Fatal(err) + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + secretLine := strings.Split(keyPEM, "\n")[1] + payload := func(v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) + } + check := func(method, path, body, requestOrigin string, want int) *httptest.ResponseRecorder { + t.Helper() + // The reverse-proxy hop is HTTP; public Origin and callback are HTTPS. + r := httptest.NewRequest(method, "http://finance.internal:8444"+path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", requestOrigin) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if strings.Contains(w.Body.String(), secretLine) || strings.Contains(w.Body.String(), "PRIVATE KEY") { + t.Fatal("private key leaked in banking response") + } + if w.Code != want { + t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String()) + } + return w + } + configured := func(w *httptest.ResponseRecorder, want bool) { + t.Helper() + var state app.State + if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil { + t.Fatal(err) + } + if state.Status.BankingConfigured != want { + t.Fatalf("banking configured = %t, want %t", state.Status.BankingConfigured, want) + } + if want && (state.BankingAppID != "bank-app" || state.CallbackURL != callback) { + t.Fatal("saved application metadata is not available to the UI") + } + } + save := payload(map[string]any{"app_id": "bank-app", "private_key": keyPEM, "redirect_url": callback}) + check("POST", endpoint, save, "https://attacker.example", http.StatusForbidden) + configured(check("GET", "/api/state", "", origin, http.StatusOK), false) + configured(check("POST", endpoint, save, origin, http.StatusOK), true) + configured(check("GET", "/api/state", "", origin, http.StatusOK), true) + // A callback correction can retain the current signing key. + configured(check("POST", endpoint, payload(map[string]any{"app_id": "bank-app", "private_key": nil, "redirect_url": callback}), origin, http.StatusOK), true) + for _, body := range []string{ + `{}`, + payload(map[string]any{"app_id": "bank-app", "redirect_url": "https://attacker.example/api/banking/callback"}), + payload(map[string]any{"app_id": "bank-app", "redirect_url": "http://finance.internal:8444/api/banking/callback"}), + payload(map[string]any{"app_id": "different-app", "private_key": nil, "redirect_url": callback}), + payload(map[string]any{"app_id": "", "private_key": "", "redirect_url": callback}), + payload(map[string]any{"remove": true, "private_key": keyPEM}), + payload(map[string]any{secretLine: "unknown field"}), + save + `{}`, + } { + check("POST", endpoint, body, origin, http.StatusBadRequest) + configured(check("GET", "/api/state", "", origin, http.StatusOK), true) + } + configured(check("POST", endpoint, `{"remove":true}`, origin, http.StatusOK), false) + configured(check("GET", "/api/state", "", origin, http.StatusOK), false) +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..8152068 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,71 @@ +{ + lib, + buildGoModule, + buildNpmPackage, + nodejs, + stdenv, +}: +let + # Explicit allowlist: never include live finance/, secrets/, .env, node_modules, + # generated binaries, or a local DuckDB file in either package derivation. + root = ../.; + source = lib.fileset.toSource { + inherit root; + fileset = lib.fileset.unions [ + ../go.mod + ../go.sum + ../cmd + ../internal + ../web/embed.go + ]; + }; + frontend = buildNpmPackage { + pname = "finance-duck-frontend"; + version = "0.1.0"; + inherit nodejs; + src = lib.fileset.toSource { + root = ../web; + fileset = lib.fileset.unions [ + ../web/src + ../web/index.html + ../web/package.json + ../web/package-lock.json + ../web/tsconfig.json + ../web/vite.config.ts + ]; + }; + npmDepsHash = "sha256-Sq4qmgNpg8b3fN8v1QHiISMQt5ZHI6oZ8J0M5svV0Ys="; + npmFlags = [ "--ignore-scripts" ]; + installPhase = '' + runHook preInstall + mkdir -p "$out" + cp -r dist/. "$out/" + runHook postInstall + ''; + }; +in +buildGoModule { + pname = "finance-duck"; + version = "0.1.0"; + src = source; + vendorHash = "sha256-ks/X1pmBjX1BTyQBSpvn1EbbUuv8RMi4n7at+o31mnw="; + proxyVendor = true; + env.CGO_ENABLED = "1"; + nativeBuildInputs = [ stdenv.cc ]; + subPackages = [ "cmd/finance-duck" ]; + postConfigure = '' + mkdir -p web/dist + cp -r ${frontend}/. web/dist/ + ''; + checkPhase = '' + runHook preCheck + go test ./internal/... + runHook postCheck + ''; + passthru = { inherit frontend; }; + meta = { + description = "Private personal finance dashboard with canonical plaintext journals"; + mainProgram = "finance-duck"; + platforms = [ "x86_64-linux" ]; + }; +} diff --git a/nix/service.nix b/nix/service.nix new file mode 100644 index 0000000..9ae6d13 --- /dev/null +++ b/nix/service.nix @@ -0,0 +1,119 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.finance-duck; + address = + if lib.hasInfix ":" cfg.listenAddress then "[${cfg.listenAddress}]" else cfg.listenAddress; + validOrigin = + builtins.match "https?://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|[[][0-9A-Fa-f:]+[]])(:[0-9]+)?" cfg.publicURL + != null; +in +{ + options.services.finance-duck = { + enable = lib.mkEnableOption "Finance Duck, a private financial dashboard without application authentication"; + package = lib.mkOption { + type = lib.types.package; + description = "Finance Duck package, including the embedded dashboard."; + }; + publicURL = lib.mkOption { + type = lib.types.str; + description = "Exact HTTP(S) browser origin, including any non-default port, without a trailing slash or path."; + }; + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "HTTP listen address. Non-loopback listeners must be isolated by a firewall or VPN."; + }; + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "HTTP listen port; the native module does not open the firewall."; + }; + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Absolute runtime environment-file path, never a Nix store path. + Optional startup fallbacks; both providers can be configured in Settings. + When configured, the file must exist. For banking, set ENABLEBANKING_APP_ID, + ENABLEBANKING_KEY_FILE and ENABLEBANKING_REDIRECT_URL together. + OPENROUTER_API_KEY is the optional AI fallback. For each provider, + saved UI configuration or explicit removal overrides its environment. + The environment file may be root:root 0600; the referenced private key must + be readable by finance-duck (for example root:finance-duck 0640). + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = validOrigin; + message = "services.finance-duck.publicURL must be an exact HTTP(S) origin, without userinfo, path, query or fragment."; + } + { + assertion = cfg.listenAddress != "" && builtins.match "[A-Za-z0-9.:_-]+" cfg.listenAddress != null; + message = "services.finance-duck.listenAddress must be a nonempty address without brackets or a port."; + } + { + assertion = + cfg.environmentFile == null + || ( + lib.hasPrefix "/" cfg.environmentFile + && !lib.hasPrefix "/nix/store/" cfg.environmentFile + && builtins.match "/[A-Za-z0-9_./-]+" cfg.environmentFile != null + && !(lib.elem ".." (lib.splitString "/" cfg.environmentFile)) + ); + message = "services.finance-duck.environmentFile must be an absolute runtime path outside /nix/store, with no parent-directory traversal."; + } + ]; + + users.groups.finance-duck = { }; + users.users.finance-duck = { + isSystemUser = true; + group = "finance-duck"; + home = "/var/lib/finance-duck"; + }; + + systemd.services.finance-duck = { + description = "Finance Duck private financial dashboard"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + environment.HOME = "/var/lib/finance-duck"; + serviceConfig = { + ExecStart = lib.escapeShellArgs [ + "${cfg.package}/bin/finance-duck" + "-data" + "/var/lib/finance-duck" + "-listen" + "${address}:${toString cfg.port}" + "-public-url" + cfg.publicURL + ]; + User = "finance-duck"; + Group = "finance-duck"; + StateDirectory = "finance-duck"; + StateDirectoryMode = "0700"; + WorkingDirectory = "/var/lib/finance-duck"; + UMask = "0077"; + Restart = "on-failure"; + RestartSec = "5s"; + TimeoutStopSec = "30s"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + CapabilityBoundingSet = ""; + } + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; +} diff --git a/web/src/Accounts.tsx b/web/src/Accounts.tsx index a7781b4..8258a84 100644 --- a/web/src/Accounts.tsx +++ b/web/src/Accounts.tsx @@ -453,7 +453,7 @@ function ConnectForm({ {copied ? "Copied" : "Copy callback URL"}

- Set ENABLEBANKING_REDIRECT_URL on your server and + Configure your application in Settings and register this exact URL with Enable Banking. It must match exactly, including scheme, hostname, port and path.

@@ -494,8 +494,9 @@ function ConnectForm({ {!state.status.banking_configured && (

- Set the Enable Banking application ID, signing key and callback URL - on your server first. Credentials never enter this browser form. + Add your Enable Banking application ID and private key in{" "} + Settings first, then return here to + authorize your bank.

)} + )} + + + + +
+
+
+

Enable Banking credentials

+

Manage the application used to connect your banks.

+
+ + {state.status.banking_configured ? ( + + ) : ( + + )} + {state.status.banking_configured ? "Configured" : "Not configured"} + +
+
{ + e.preventDefault(); + await saveBankingSettings(); + }} + > +
+

+ First register your application and its public certificate in the{" "} + + Enable Banking control panel + + , using the exact callback URL below. Upload only the matching + private key here. After saving, go to{" "} + Accounts to authorize your bank. +

+ + setBankingAppID(e.target.value)} + /> + + + + + + e.target.select()} + /> + + +

+ Credentials are stored locally on this server, not in browser + storage, and apply without a restart. The saved private key is + never displayed. Configured means credentials are present, not + that Enable Banking has verified them. Saving does not register or + test an application, or authorize a bank. +

+

+ Changing the application ID requires reconnecting your banks. + Rotating the key or updating the callback for the same application + preserves existing local bank sessions. +

+
+
+ {state.status.banking_configured && ( + + )} + +
+
+

Classification preferences

-

Provider credentials are configured on the server.

+

Choose the model and what AI classification shares.

{ e.preventDefault(); + if (busy || credentialsBusy) return; setBusy(true); setError(""); try { @@ -58,7 +344,10 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) { } }} > - + - @@ -165,7 +457,7 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
+ + + + )} {rebuild && ( { - if (!busy) setRebuild(false); + if (!busy && !credentialsBusy) setRebuild(false); }} >
@@ -198,15 +553,16 @@ export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {