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) }