Add native NixOS deployment and UI-managed provider credentials

This commit is contained in:
Lars Nolden
2026-09-10 14:25:37 +02:00
parent 9843fe0c50
commit 964b9dfc15
21 changed files with 2084 additions and 104 deletions
+120 -39
View File
@@ -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
}
+4 -1
View File
@@ -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"`
+203
View File
@@ -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)
}
+384
View File
@@ -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")
}
}
+7 -1
View File
@@ -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")
+219
View File
@@ -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")
}
+19 -12
View File
@@ -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 {
+60 -7
View File
@@ -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")
}
}
}
+57
View File
@@ -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"`
+152
View File
@@ -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)
}