feat: add Banking TUI shell using bubbletea

Add an 80s-style green-on-black bank terminal shell ("banking") using
charmbracelet/bubbletea for full-screen TUI rendering over SSH.

Screens: login, main menu, account summary, account detail with
transactions, wire transfer wizard (6-step form capturing routing
number, destination, beneficiary, amount, memo, auth code), transaction
history with pagination, secure messages with breadcrumb content (fake
internal IPs, vault codes), change PIN, and hidden admin access (99)
that locks after 3 failed attempts with COBOL-style error output.

All key actions (login, navigation, wire transfers, admin attempts) are
logged to the session store. Wire transfer data is the honeypot gold.

Configurable via [shell.banking] in TOML: bank_name, terminal_id, region.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 23:17:12 +01:00
parent 462c44ce89
commit 8ff029fcb7
18 changed files with 2466 additions and 8 deletions

View File

@@ -34,7 +34,7 @@ Key settings:
- `auth.accept_after` — accept login after N failures per IP (default `10`)
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
- `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell)
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS)
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI)
- `storage.db_path` — SQLite database path (default `oubliette.db`)
- `storage.retention_days` — auto-prune records older than N days (default `90`)
- `storage.retention_interval` — how often to run retention (default `1h`)

View File

@@ -19,7 +19,7 @@ import (
"git.t-juice.club/torjus/oubliette/internal/web"
)
const Version = "0.4.0"
const Version = "0.5.0"
func main() {
if err := run(); err != nil {

17
go.mod
View File

@@ -4,18 +4,35 @@ go 1.25.5
require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.45.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

48
go.sum
View File

@@ -1,34 +1,70 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=

View File

@@ -17,6 +17,7 @@ import (
"git.t-juice.club/torjus/oubliette/internal/detection"
"git.t-juice.club/torjus/oubliette/internal/notify"
"git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/shell/banking"
"git.t-juice.club/torjus/oubliette/internal/shell/bash"
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
"git.t-juice.club/torjus/oubliette/internal/storage"
@@ -42,6 +43,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server,
if err := registry.Register(fridge.NewFridgeShell(), 1); err != nil {
return nil, fmt.Errorf("registering fridge shell: %w", err)
}
if err := registry.Register(banking.NewBankingShell(), 1); err != nil {
return nil, fmt.Errorf("registering banking shell: %w", err)
}
s := &Server{
cfg: cfg,

View File

@@ -0,0 +1,74 @@
package banking
import (
"context"
"fmt"
"io"
"math/rand/v2"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.t-juice.club/torjus/oubliette/internal/shell"
)
const sessionTimeout = 10 * time.Minute
// BankingShell is an 80s-style green-on-black bank terminal TUI.
type BankingShell struct{}
// NewBankingShell returns a new BankingShell instance.
func NewBankingShell() *BankingShell {
return &BankingShell{}
}
func (b *BankingShell) Name() string { return "banking" }
func (b *BankingShell) Description() string { return "80s-style banking terminal TUI" }
func (b *BankingShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error {
ctx, cancel := context.WithTimeout(ctx, sessionTimeout)
defer cancel()
bankName := configString(sess.ShellConfig, "bank_name", "SECUREBANK")
terminalID := configString(sess.ShellConfig, "terminal_id", "")
region := configString(sess.ShellConfig, "region", "NORTHEAST")
if terminalID == "" {
terminalID = fmt.Sprintf("SB-%04d", rand.IntN(10000))
}
m := newModel(sess, bankName, terminalID, region)
p := tea.NewProgram(m,
tea.WithInput(rw),
tea.WithOutput(rw),
tea.WithAltScreen(),
)
done := make(chan error, 1)
go func() {
_, err := p.Run()
done <- err
}()
select {
case err := <-done:
return err
case <-ctx.Done():
p.Quit()
<-done
return nil
}
}
// configString reads a string from the shell config map with a default.
func configString(cfg map[string]any, key, defaultVal string) string {
if cfg == nil {
return defaultVal
}
if v, ok := cfg[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return defaultVal
}

View File

@@ -0,0 +1,533 @@
package banking
import (
"context"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.t-juice.club/torjus/oubliette/internal/shell"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
// newTestModel creates a model with a test session context.
func newTestModel(t *testing.T) (*model, *storage.MemoryStore) {
t.Helper()
store := storage.NewMemoryStore()
sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking")
sess := &shell.SessionContext{
SessionID: sessID,
Username: "banker",
Store: store,
}
m := newModel(sess, "SECUREBANK", "SB-0001", "NORTHEAST")
return m, store
}
// sendKeys sends a string of characters as individual key messages to the model.
func sendKeys(m *model, s string) {
for _, ch := range s {
var msg tea.KeyMsg
switch ch {
case '\r':
msg = tea.KeyMsg{Type: tea.KeyEnter}
case '\x1b':
msg = tea.KeyMsg{Type: tea.KeyEscape}
case '\x03':
msg = tea.KeyMsg{Type: tea.KeyCtrlC}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}
}
m.Update(msg)
}
}
func TestBankingShellName(t *testing.T) {
sh := NewBankingShell()
if sh.Name() != "banking" {
t.Errorf("Name() = %q, want %q", sh.Name(), "banking")
}
if sh.Description() == "" {
t.Error("Description() should not be empty")
}
}
func TestFormatCurrency(t *testing.T) {
tests := []struct {
cents int64
want string
}{
{0, "$0.00"},
{100, "$1.00"},
{4738291, "$47,382.91"},
{18254100, "$182,541.00"},
{52387450, "$523,874.50"},
{25000000, "$250,000.00"},
{-125000, "-$1,250.00"},
{99, "$0.99"},
}
for _, tt := range tests {
got := formatCurrency(tt.cents)
if got != tt.want {
t.Errorf("formatCurrency(%d) = %q, want %q", tt.cents, got, tt.want)
}
}
}
func TestNewBankState(t *testing.T) {
state := newBankState()
if len(state.Accounts) != 4 {
t.Errorf("expected 4 accounts, got %d", len(state.Accounts))
}
for _, acct := range state.Accounts {
txns, ok := state.Transactions[acct.Number]
if !ok {
t.Errorf("no transactions for account %s", acct.Number)
continue
}
if len(txns) == 0 {
t.Errorf("account %s has no transactions", acct.Number)
}
}
if len(state.Messages) != 4 {
t.Errorf("expected 4 messages, got %d", len(state.Messages))
}
}
func TestLoginScreenRenders(t *testing.T) {
m, _ := newTestModel(t)
view := m.View()
if !strings.Contains(view, "SECUREBANK") {
t.Error("login should show bank name")
}
if !strings.Contains(view, "AUTHORIZED ACCESS ONLY") {
t.Error("login should show authorization warning")
}
if !strings.Contains(view, "ACCOUNT NUMBER") {
t.Error("login should prompt for account number")
}
}
func TestLoginFlow(t *testing.T) {
m, _ := newTestModel(t)
// Type account number.
sendKeys(m, "12345678")
view := m.View()
if !strings.Contains(view, "12345678") {
t.Error("should show typed account number")
}
// Press enter.
sendKeys(m, "\r")
view = m.View()
if !strings.Contains(view, "PIN") {
t.Error("should show PIN prompt after entering account number")
}
// Type PIN and enter.
sendKeys(m, "1234\r")
// Should be on menu now.
if m.screen != screenMenu {
t.Errorf("expected screenMenu, got %d", m.screen)
}
}
func TestMainMenuRenders(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r")
view := m.View()
if !strings.Contains(view, "MAIN MENU") {
t.Error("should show MAIN MENU after login")
}
if !strings.Contains(view, "WIRE TRANSFER") {
t.Error("menu should contain WIRE TRANSFER option")
}
if !strings.Contains(view, "SECURE MESSAGES") {
t.Error("menu should contain SECURE MESSAGES option")
}
if !strings.Contains(view, "ACCOUNT SUMMARY") {
t.Error("menu should contain ACCOUNT SUMMARY option")
}
}
func TestAccountSummary(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "1\r") // account summary
if m.screen != screenAccountSummary {
t.Fatalf("expected screenAccountSummary, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "ACCOUNT SUMMARY") {
t.Error("should show ACCOUNT SUMMARY")
}
if !strings.Contains(view, "CHECKING") {
t.Error("should show CHECKING account")
}
if !strings.Contains(view, "SAVINGS") {
t.Error("should show SAVINGS account")
}
if !strings.Contains(view, "TOTAL") {
t.Error("should show TOTAL")
}
// Press any key to return.
sendKeys(m, " ")
if m.screen != screenMenu {
t.Errorf("should return to menu, got screen %d", m.screen)
}
}
func TestWireTransferFlow(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "3\r") // wire transfer
if m.screen != screenTransfer {
t.Fatalf("expected screenTransfer, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "WIRE TRANSFER") {
t.Error("should show WIRE TRANSFER header")
}
// Fill all fields.
sendKeys(m, "021000021\r") // routing
sendKeys(m, "9876543210\r") // dest account
sendKeys(m, "JOHN DOE\r") // beneficiary
sendKeys(m, "FIRST NATIONAL BANK\r") // bank name
sendKeys(m, "50000\r") // amount
sendKeys(m, "INVOICE 12345\r") // memo
// Should be on confirm step.
view = m.View()
if !strings.Contains(view, "TRANSFER SUMMARY") {
t.Error("should show TRANSFER SUMMARY for confirmation")
}
if !strings.Contains(view, "021000021") {
t.Error("summary should show routing number")
}
// Confirm.
sendKeys(m, "Y\r")
// Auth code.
sendKeys(m, "AUTH99\r")
view = m.View()
if !strings.Contains(view, "TRANSFER QUEUED") {
t.Error("should show TRANSFER QUEUED confirmation")
}
// Check wire transfer was stored.
if len(m.state.Transfers) != 1 {
t.Fatalf("expected 1 transfer, got %d", len(m.state.Transfers))
}
wt := m.state.Transfers[0]
if wt.RoutingNumber != "021000021" {
t.Errorf("routing = %q, want %q", wt.RoutingNumber, "021000021")
}
if wt.DestAccount != "9876543210" {
t.Errorf("dest = %q, want %q", wt.DestAccount, "9876543210")
}
if wt.Beneficiary != "JOHN DOE" {
t.Errorf("beneficiary = %q, want %q", wt.Beneficiary, "JOHN DOE")
}
// Press key to return to menu.
sendKeys(m, " ")
if m.screen != screenMenu {
t.Errorf("should return to menu after transfer, got screen %d", m.screen)
}
}
func TestWireTransferCancel(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "3\r") // wire transfer
sendKeys(m, "021000021\r")
sendKeys(m, "9876543210\r")
sendKeys(m, "JOHN DOE\r")
sendKeys(m, "FIRST NATIONAL BANK\r")
sendKeys(m, "50000\r")
sendKeys(m, "INVOICE 12345\r")
// Cancel at confirm step.
sendKeys(m, "N\r")
if m.screen != screenMenu {
t.Errorf("should return to menu after cancel, got screen %d", m.screen)
}
}
func TestTransactionHistory(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "4\r") // transaction history
if m.screen != screenHistory {
t.Fatalf("expected screenHistory, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "TRANSACTION HISTORY") {
t.Error("should show TRANSACTION HISTORY header")
}
// Select first account.
sendKeys(m, "1\r")
view = m.View()
if !strings.Contains(view, "DATE") {
t.Error("should show transaction list with DATE column")
}
if !strings.Contains(view, "PAGE") {
t.Error("should show page indicator")
}
// Press B to go back to account list.
sendKeys(m, "B")
view = m.View()
if !strings.Contains(view, "SELECT ACCOUNT") {
t.Error("should return to account selection")
}
// Press 0 to return to menu.
sendKeys(m, "0\r")
if m.screen != screenMenu {
t.Errorf("should return to menu, got screen %d", m.screen)
}
}
func TestSecureMessages(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "5\r") // secure messages
if m.screen != screenMessages {
t.Fatalf("expected screenMessages, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "SECURE MESSAGES") {
t.Error("should show SECURE MESSAGES header")
}
if !strings.Contains(view, "SCHEDULED MAINTENANCE") {
t.Error("should show first message subject")
}
// View first message.
sendKeys(m, "1\r")
view = m.View()
if !strings.Contains(view, "10.48.2.100") {
t.Error("message body should contain breadcrumb IP")
}
// Press key to return to list.
sendKeys(m, " ")
// Return to menu.
sendKeys(m, "0\r")
if m.screen != screenMenu {
t.Errorf("should return to menu, got screen %d", m.screen)
}
}
func TestAdminAccessDenied(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "99\r") // admin
if m.screen != screenAdmin {
t.Fatalf("expected screenAdmin, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "SYSTEM ADMINISTRATION") {
t.Error("should show SYSTEM ADMINISTRATION header")
}
// Three failed PIN attempts.
sendKeys(m, "secret1\r")
view = m.View()
if !strings.Contains(view, "INVALID CREDENTIALS") {
t.Error("should show INVALID CREDENTIALS after first attempt")
}
sendKeys(m, "secret2\r")
sendKeys(m, "secret3\r")
view = m.View()
if !strings.Contains(view, "ACCESS DENIED") {
t.Error("should show ACCESS DENIED after 3 attempts")
}
if !strings.Contains(view, "ABEND S0C4") {
t.Error("should show COBOL-style error")
}
// Press key to return to menu.
sendKeys(m, " ")
if m.screen != screenMenu {
t.Errorf("should return to menu after lockout, got screen %d", m.screen)
}
}
func TestAdminEscapeReturns(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "99\r") // admin
sendKeys(m, "\x1b") // ESC to return
if m.screen != screenMenu {
t.Errorf("ESC should return to menu from admin, got screen %d", m.screen)
}
}
func TestChangePin(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "6\r") // change PIN
if m.screen != screenChangePin {
t.Fatalf("expected screenChangePin, got %d", m.screen)
}
view := m.View()
if !strings.Contains(view, "CHANGE PIN") {
t.Error("should show CHANGE PIN header")
}
// Old PIN.
sendKeys(m, "1234\r")
// New PIN.
sendKeys(m, "5678\r")
// Confirm PIN.
sendKeys(m, "5678\r")
view = m.View()
if !strings.Contains(view, "PIN CHANGED SUCCESSFULLY") {
t.Error("should show PIN CHANGED SUCCESSFULLY")
}
// Press key to return.
sendKeys(m, " ")
if m.screen != screenMenu {
t.Errorf("should return to menu after PIN change, got screen %d", m.screen)
}
}
func TestLogoutExits(t *testing.T) {
m, _ := newTestModel(t)
sendKeys(m, "12345678\r1234\r") // login
sendKeys(m, "7\r") // logout
if !m.quitting {
t.Error("should be quitting after logout")
}
}
func TestSessionLogs(t *testing.T) {
m, store := newTestModel(t)
// Send login keys and manually run returned commands.
// Type account number.
sendKeys(m, "12345678")
// Enter to advance to PIN.
sendKeys(m, "\r")
// Type PIN.
sendKeys(m, "1234")
// Enter to login — this returns a logAction cmd.
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
// Execute the batch of commands (login log).
execCmds(cmd)
}
// Give async store writes a moment.
time.Sleep(50 * time.Millisecond)
if len(store.SessionLogs) == 0 {
t.Error("expected session logs to be recorded after login")
}
found := false
for _, log := range store.SessionLogs {
if strings.Contains(log.Input, "LOGIN") {
found = true
break
}
}
if !found {
t.Error("expected a LOGIN entry in session logs")
}
// Navigate to account summary — also returns a logAction cmd.
sendKeys(m, "1")
_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
execCmds(cmd)
}
time.Sleep(50 * time.Millisecond)
foundMenu := false
for _, log := range store.SessionLogs {
if strings.Contains(log.Input, "MENU") {
foundMenu = true
break
}
}
if !foundMenu {
t.Error("expected a MENU entry in session logs for account summary")
}
}
// execCmds recursively executes tea.Cmd functions (including batches).
func execCmds(cmd tea.Cmd) {
if cmd == nil {
return
}
msg := cmd()
// tea.BatchMsg is a slice of Cmds returned by tea.Batch.
if batch, ok := msg.(tea.BatchMsg); ok {
for _, c := range batch {
execCmds(c)
}
}
}
func TestConfigString(t *testing.T) {
cfg := map[string]any{
"bank_name": "TESTBANK",
"region": "SOUTHWEST",
}
if got := configString(cfg, "bank_name", "DEFAULT"); got != "TESTBANK" {
t.Errorf("configString() = %q, want %q", got, "TESTBANK")
}
if got := configString(cfg, "missing", "DEFAULT"); got != "DEFAULT" {
t.Errorf("configString() = %q, want %q", got, "DEFAULT")
}
if got := configString(nil, "bank_name", "DEFAULT"); got != "DEFAULT" {
t.Errorf("configString(nil) = %q, want %q", got, "DEFAULT")
}
}
func TestScreenFrame(t *testing.T) {
frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here")
if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") {
t.Error("frame should contain bank name in header")
}
if !strings.Contains(frame, "TB-0001") {
t.Error("frame should contain terminal ID in footer")
}
if !strings.Contains(frame, "content here") {
t.Error("frame should contain the content")
}
}

View File

@@ -0,0 +1,258 @@
package banking
import (
"fmt"
"time"
)
// Account types.
const (
AcctChecking = "CHECKING"
AcctSavings = "SAVINGS"
AcctMoneyMarket = "MONEY MARKET"
AcctCertDeposit = "CERT OF DEPOSIT"
)
// Account represents a fake bank account.
type Account struct {
Number string
Type string
Balance int64 // cents
}
// Transaction represents a fake bank transaction.
type Transaction struct {
Date string
Description string
Amount int64 // cents (negative for debits)
Balance int64 // running balance in cents
}
// SecureMessage represents a fake internal message.
type SecureMessage struct {
ID int
Date string
From string
Subj string
Body string
Unread bool
}
// WireTransfer captures data entered during the wire transfer wizard.
type WireTransfer struct {
RoutingNumber string
DestAccount string
Beneficiary string
BankName string
Amount string
Memo string
AuthCode string
}
// bankState holds all fake data for a session.
type bankState struct {
Accounts []Account
Transactions map[string][]Transaction // keyed by account number
Messages []SecureMessage
Transfers []WireTransfer
}
func newBankState() *bankState {
now := time.Now()
accounts := []Account{
{Number: "****4821", Type: AcctChecking, Balance: 4738291},
{Number: "****7203", Type: AcctSavings, Balance: 18254100},
{Number: "****9915", Type: AcctMoneyMarket, Balance: 52387450},
{Number: "****1102", Type: AcctCertDeposit, Balance: 25000000},
}
transactions := make(map[string][]Transaction)
transactions["****4821"] = generateCheckingTxns(now, accounts[0].Balance)
transactions["****7203"] = generateSavingsTxns(now, accounts[1].Balance)
transactions["****9915"] = generateMoneyMarketTxns(now, accounts[2].Balance)
transactions["****1102"] = generateCDTxns(now, accounts[3].Balance)
messages := []SecureMessage{
{
ID: 1,
Date: now.Add(-2 * 24 * time.Hour).Format("01/02/2006"),
From: "SYSTEM ADMINISTRATOR",
Subj: "SCHEDULED MAINTENANCE WINDOW",
Unread: true,
Body: fmt.Sprintf(`FROM: SYSTEM ADMINISTRATOR <sysadmin@internal.securebank.local>
DATE: %s
RE: SCHEDULED MAINTENANCE WINDOW
ALL TERMINALS WILL BE OFFLINE FOR MAINTENANCE:
DATE: %s
TIME: 02:00 - 04:00 EST
AFFECTED: ALL REGIONS
DURING THIS WINDOW, THE FOLLOWING SYSTEMS WILL BE UNAVAILABLE:
- WIRE TRANSFER PROCESSING (10.48.2.100:8443)
- ACCOUNT MANAGEMENT (10.48.2.101:8443)
- ACH BATCH PROCESSOR (10.48.2.105:9090)
PLEASE ENSURE ALL PENDING TRANSACTIONS ARE SUBMITTED BEFORE 01:30 EST.
CONTACT: HELPDESK EXT 4400 OR ops-support@internal.securebank.local`,
now.Add(-2*24*time.Hour).Format("01/02/2006 15:04"),
now.Add(5*24*time.Hour).Format("01/02/2006")),
},
{
ID: 2,
Date: now.Add(-5 * 24 * time.Hour).Format("01/02/2006"),
From: "COMPLIANCE DEPT",
Subj: "QUARTERLY AUDIT REMINDER",
Unread: true,
Body: `FROM: COMPLIANCE DEPT <compliance@internal.securebank.local>
RE: QUARTERLY AUDIT REMINDER
ALL BRANCH MANAGERS:
THE Q4 COMPLIANCE AUDIT IS SCHEDULED FOR NEXT WEEK.
PLEASE ENSURE THE FOLLOWING ARE CURRENT:
1. TRANSACTION LOGS EXPORTED TO \\FILESERV01\AUDIT\Q4
2. VAULT ACCESS CODES ROTATED (LAST ROTATION: SEE VAULT-MGMT PORTAL)
3. EMPLOYEE ACCESS REVIEWS COMPLETED IN IAM PORTAL (https://iam.internal:8443)
NOTE: DEFAULT CREDENTIALS FOR THE AUDIT PORTAL HAVE BEEN RESET.
NEW CREDENTIALS DISTRIBUTED VIA SECURE COURIER.
REFERENCE: AUDIT-2024-Q4-0847
VAULT MASTER CODE HINT: FIRST 4 OF ROUTING + BRANCH ZIP (STANDARD FORMAT)`,
},
{
ID: 3,
Date: now.Add(-8 * 24 * time.Hour).Format("01/02/2006"),
From: "IT SECURITY",
Subj: "PASSWORD POLICY UPDATE",
Unread: false,
Body: `FROM: IT SECURITY <itsec@internal.securebank.local>
RE: PASSWORD POLICY UPDATE - EFFECTIVE IMMEDIATELY
ALL STAFF:
PER FEDERAL BANKING REGULATION 12 CFR 748, THE FOLLOWING
PASSWORD POLICY IS NOW IN EFFECT:
- MINIMUM 12 CHARACTERS
- MUST CONTAIN UPPERCASE, LOWERCASE, NUMBER, SPECIAL CHAR
- 90-DAY ROTATION CYCLE
- NO REUSE OF LAST 24 PASSWORDS
LEGACY SYSTEM ACCOUNTS (MAINFRAME, AS/400) ARE EXEMPT UNTIL
MIGRATION IS COMPLETE. CURRENT LEGACY ACCESS:
MAINFRAME: telnet://10.48.1.50:23 (CICS REGION PROD1)
AS/400: tn5250://10.48.1.55 (SUBSYSTEM QINTER)
SERVICE ACCOUNT PASSWORDS ARE MANAGED VIA CYBERARK:
https://pam.internal.securebank.local:8443
TICKET: SEC-2024-1847`,
},
{
ID: 4,
Date: now.Add(-12 * 24 * time.Hour).Format("01/02/2006"),
From: "WIRE OPERATIONS",
Subj: "FEDWIRE CUTOFF TIME CHANGE",
Unread: false,
Body: `FROM: WIRE OPERATIONS <wireops@internal.securebank.local>
RE: FEDWIRE CUTOFF TIME CHANGE
EFFECTIVE NEXT MONDAY, FEDWIRE CUTOFF TIMES ARE:
DOMESTIC WIRES: 16:30 EST (WAS 17:00)
INTERNATIONAL WIRES: 14:00 EST (NO CHANGE)
BOOK TRANSFERS: 17:30 EST (NO CHANGE)
WIRES SUBMITTED AFTER CUTOFF WILL BE QUEUED FOR NEXT
BUSINESS DAY PROCESSING.
FOR EMERGENCY SAME-DAY PROCESSING AFTER CUTOFF:
CONTACT WIRE ROOM: EXT 4450
AUTH CODE REQUIRED (OBTAIN FROM BRANCH MANAGER)
APPROVAL CHAIN: OPS-MGR -> VP-WIRE -> SVP-TREASURY
CORRESPONDENT BANK CONTACTS:
JPMORGAN: wire.ops@jpmc.com / 212-555-0147
CITI: fedwire@citi.com / 212-555-0283`,
},
}
return &bankState{
Accounts: accounts,
Transactions: transactions,
Messages: messages,
}
}
func generateCheckingTxns(now time.Time, endBalance int64) []Transaction {
txns := []Transaction{
{Description: "ACH DEPOSIT - PAYROLL", Amount: 485000},
{Description: "CHECK #1847", Amount: -125000},
{Description: "POS DEBIT - WHOLE FOODS #1284", Amount: -18743},
{Description: "ATM WITHDRAWAL - MAIN ST BRANCH", Amount: -40000},
{Description: "ACH DEBIT - MORTGAGE PMT", Amount: -215000},
{Description: "WIRE TRANSFER IN - REF#8847201", Amount: 1250000},
{Description: "POS DEBIT - SHELL OIL #4492", Amount: -6821},
{Description: "ACH DEPOSIT - PAYROLL", Amount: 485000},
{Description: "CHECK #1848", Amount: -75000},
{Description: "ONLINE TRANSFER TO SAVINGS", Amount: -100000},
{Description: "POS DEBIT - AMAZON.COM", Amount: -14599},
{Description: "ACH DEBIT - ELECTRIC COMPANY", Amount: -18742},
{Description: "ATM WITHDRAWAL - PARK AVE BRANCH", Amount: -20000},
{Description: "WIRE TRANSFER OUT - REF#9014882", Amount: -500000},
{Description: "POS DEBIT - COSTCO #0441", Amount: -28734},
{Description: "ACH DEPOSIT - TAX REFUND", Amount: 342100},
}
return populateTransactions(txns, now, endBalance)
}
func generateSavingsTxns(now time.Time, endBalance int64) []Transaction {
txns := []Transaction{
{Description: "INTEREST PAYMENT", Amount: 4521},
{Description: "ONLINE TRANSFER FROM CHECKING", Amount: 100000},
{Description: "INTEREST PAYMENT", Amount: 4633},
{Description: "ACH DEPOSIT - DIVIDEND PMT", Amount: 125000},
{Description: "ONLINE TRANSFER FROM CHECKING", Amount: 200000},
{Description: "INTEREST PAYMENT", Amount: 4748},
{Description: "WITHDRAWAL - TRANSFER TO MM", Amount: -500000},
{Description: "INTEREST PAYMENT", Amount: 4812},
}
return populateTransactions(txns, now, endBalance)
}
func generateMoneyMarketTxns(now time.Time, endBalance int64) []Transaction {
txns := []Transaction{
{Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 21847},
{Description: "DEPOSIT - TRANSFER FROM SAVINGS", Amount: 500000},
{Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 22105},
{Description: "WITHDRAWAL - WIRE TRANSFER", Amount: -1000000},
{Description: "DEPOSIT - ACH TRANSFER", Amount: 750000},
{Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 22394},
}
return populateTransactions(txns, now, endBalance)
}
func generateCDTxns(now time.Time, endBalance int64) []Transaction {
txns := []Transaction{
{Description: "CERTIFICATE OPENED - 12MO TERM", Amount: 25000000},
{Description: "INTEREST ACCRUAL", Amount: 10417},
{Description: "INTEREST ACCRUAL", Amount: 10417},
{Description: "INTEREST ACCRUAL", Amount: 10417},
}
return populateTransactions(txns, now, endBalance)
}
func populateTransactions(txns []Transaction, now time.Time, endBalance int64) []Transaction {
// Work backwards from end balance to assign dates and running balances.
bal := endBalance
for i := len(txns) - 1; i >= 0; i-- {
txns[i].Balance = bal
txns[i].Date = now.Add(time.Duration(-(len(txns) - i)) * 3 * 24 * time.Hour).Format("01/02/2006")
bal -= txns[i].Amount
}
return txns
}

View File

@@ -0,0 +1,422 @@
package banking
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.t-juice.club/torjus/oubliette/internal/shell"
)
type screen int
const (
screenLogin screen = iota
screenMenu
screenAccountSummary
screenAccountDetail
screenTransfer
screenHistory
screenMessages
screenChangePin
screenAdmin
)
type model struct {
sess *shell.SessionContext
bankName string
terminalID string
region string
state *bankState
screen screen
quitting bool
login loginModel
menu menuModel
summary accountSummaryModel
detail accountDetailModel
transfer transferModel
history historyModel
messages messagesModel
admin adminModel
pinInput string
pinStage int // 0=old, 1=new, 2=confirm, 3=done
pinMessage string
}
func newModel(sess *shell.SessionContext, bankName, terminalID, region string) *model {
state := newBankState()
unread := 0
for _, msg := range state.Messages {
if msg.Unread {
unread++
}
}
return &model{
sess: sess,
bankName: bankName,
terminalID: terminalID,
region: region,
state: state,
screen: screenLogin,
login: newLoginModel(bankName),
}
}
func (m *model) Init() tea.Cmd {
return nil
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.quitting {
return m, tea.Quit
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
}
switch m.screen {
case screenLogin:
return m.updateLogin(msg)
case screenMenu:
return m.updateMenu(msg)
case screenAccountSummary:
return m.updateAccountSummary(msg)
case screenAccountDetail:
return m.updateAccountDetail(msg)
case screenTransfer:
return m.updateTransfer(msg)
case screenHistory:
return m.updateHistory(msg)
case screenMessages:
return m.updateMessages(msg)
case screenChangePin:
return m.updateChangePin(msg)
case screenAdmin:
return m.updateAdmin(msg)
}
return m, nil
}
func (m *model) View() string {
var content string
switch m.screen {
case screenLogin:
content = m.login.View()
case screenMenu:
content = m.menu.View()
case screenAccountSummary:
content = m.summary.View()
case screenAccountDetail:
content = m.detail.View()
case screenTransfer:
content = m.transfer.View()
case screenHistory:
content = m.history.View()
case screenMessages:
content = m.messages.View()
case screenChangePin:
content = m.viewChangePin()
case screenAdmin:
content = m.admin.View()
}
return screenFrame(m.bankName, m.terminalID, m.region, content)
}
// --- Screen update handlers ---
func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.login, cmd = m.login.Update(msg)
if m.login.stage == 2 {
// Login always succeeds — this is a honeypot.
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
m.goToMenu()
return m, tea.Batch(cmd, logCmd)
}
return m, cmd
}
func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEnter {
choice := strings.TrimSpace(m.menu.choice)
switch choice {
case "1":
m.screen = screenAccountSummary
m.summary = newAccountSummaryModel(m.state.Accounts)
return m, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY")
case "2":
m.screen = screenAccountDetail
m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL")
case "3":
m.screen = screenTransfer
m.transfer = newTransferModel()
return m, logAction(m.sess, "MENU 3", "WIRE TRANSFER")
case "4":
m.screen = screenHistory
m.history = newHistoryModel(m.state.Accounts, m.state.Transactions)
return m, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY")
case "5":
m.screen = screenMessages
m.messages = newMessagesModel(m.state.Messages)
return m, logAction(m.sess, "MENU 5", "SECURE MESSAGES")
case "6":
m.screen = screenChangePin
m.pinInput = ""
m.pinStage = 0
m.pinMessage = ""
return m, logAction(m.sess, "MENU 6", "CHANGE PIN")
case "7":
m.quitting = true
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
case "99", "admin", "ADMIN":
m.screen = screenAdmin
m.admin = newAdminModel()
return m, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN")
}
// Invalid choice, reset.
m.menu.choice = ""
}
return m, cmd
}
func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
m.goToMenu()
}
return m, nil
}
func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg)
if m.detail.choice == "back" {
m.goToMenu()
}
return m, cmd
}
func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
prevStep := m.transfer.step
var cmd tea.Cmd
m.transfer, cmd = m.transfer.Update(msg)
// Transfer cancelled.
if m.transfer.confirm == "cancelled" {
m.goToMenu()
return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED")
}
// Transfer completed — log it.
if m.transfer.step == transferStepComplete && prevStep != transferStepComplete {
t := m.transfer.transfer
m.state.Transfers = append(m.state.Transfers, t)
logMsg := fmt.Sprintf("WIRE TRANSFER: routing=%s dest=%s beneficiary=%s bank=%s amount=%s memo=%s auth=%s",
t.RoutingNumber, t.DestAccount, t.Beneficiary, t.BankName, t.Amount, t.Memo, t.AuthCode)
return m, tea.Batch(cmd, logAction(m.sess, logMsg, "TRANSFER QUEUED"))
}
// Completed screen → any key goes back.
if m.transfer.step == transferStepComplete {
if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete {
m.goToMenu()
}
}
return m, cmd
}
func (m *model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.history, cmd = m.history.Update(msg)
if m.history.choice == "back" {
m.goToMenu()
}
return m, cmd
}
func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.messages, cmd = m.messages.Update(msg)
if m.messages.choice == "back" {
m.goToMenu()
return m, cmd
}
// Log when viewing a message.
if m.messages.viewing >= 0 {
idx := m.messages.viewing
return m, tea.Batch(cmd, logAction(m.sess,
fmt.Sprintf("VIEW MESSAGE #%d", idx+1),
m.state.Messages[idx].Subj))
}
return m, cmd
}
func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.pinStage == 3 {
m.goToMenu()
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
switch m.pinStage {
case 0:
if m.pinInput != "" {
m.pinStage = 1
m.pinInput = ""
}
case 1:
if len(m.pinInput) >= 4 {
m.pinMessage = m.pinInput
m.pinStage = 2
m.pinInput = ""
}
case 2:
if m.pinInput == m.pinMessage {
m.pinStage = 3
return m, logAction(m.sess, "CHANGE PIN", "PIN CHANGED SUCCESSFULLY")
}
m.pinInput = ""
m.pinMessage = ""
m.pinStage = 1
}
case tea.KeyEscape:
m.goToMenu()
return m, nil
case tea.KeyBackspace:
if len(m.pinInput) > 0 {
m.pinInput = m.pinInput[:len(m.pinInput)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.pinInput) < 12 {
m.pinInput += ch
}
}
return m, nil
}
func (m *model) viewChangePin() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("CHANGE PIN"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
if m.pinStage == 3 {
b.WriteString(titleStyle.Render(" PIN CHANGED SUCCESSFULLY"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(" YOUR NEW PIN IS NOW ACTIVE."))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" PLEASE USE YOUR NEW PIN FOR ALL FUTURE TRANSACTIONS."))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
} else {
prompts := []string{" CURRENT PIN: ", " NEW PIN: ", " CONFIRM PIN: "}
for i := 0; i < m.pinStage; i++ {
b.WriteString(baseStyle.Render(prompts[i]))
b.WriteString(baseStyle.Render(strings.Repeat("*", 4)))
b.WriteString("\n")
}
if m.pinStage < 3 {
b.WriteString(titleStyle.Render(prompts[m.pinStage]))
masked := strings.Repeat("*", len(m.pinInput))
b.WriteString(inputStyle.Render(masked))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
}
b.WriteString("\n")
if m.pinStage == 1 {
b.WriteString(dimStyle.Render(" PIN MUST BE AT LEAST 4 CHARACTERS"))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(dimStyle.Render(" PRESS ESC TO RETURN TO MAIN MENU"))
}
b.WriteString("\n")
return b.String()
}
func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
prevLocked := m.admin.locked
prevAttempts := m.admin.attempts
// Check for ESC before delegating.
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
m.goToMenu()
return m, nil
}
var cmd tea.Cmd
m.admin, cmd = m.admin.Update(msg)
// Log failed attempts.
if m.admin.attempts > prevAttempts {
cmd = tea.Batch(cmd, logAction(m.sess,
fmt.Sprintf("ADMIN PIN ATTEMPT #%d", m.admin.attempts),
"INVALID CREDENTIALS"))
}
// Log lockout.
if m.admin.locked && !prevLocked {
cmd = tea.Batch(cmd, logAction(m.sess,
"ADMIN LOCKOUT",
"TERMINAL LOCKED - INCIDENT LOGGED"))
}
// If locked and any key pressed, go back.
if m.admin.locked {
if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
m.goToMenu()
}
}
return m, cmd
}
func (m *model) goToMenu() {
unread := 0
for _, msg := range m.state.Messages {
if msg.Unread {
unread++
}
}
m.screen = screenMenu
m.menu = newMenuModel(m.bankName, unread)
}
// logAction returns a tea.Cmd that logs an action to the session store.
func logAction(sess *shell.SessionContext, input, output string) tea.Cmd {
return func() tea.Msg {
if sess.Store != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output)
}
return nil
}
}

View File

@@ -0,0 +1,213 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// --- Account Summary ---
type accountSummaryModel struct {
accounts []Account
}
func newAccountSummaryModel(accounts []Account) accountSummaryModel {
return accountSummaryModel{accounts: accounts}
}
func (m accountSummaryModel) Update(_ tea.Msg) (accountSummaryModel, tea.Cmd) {
// Any key returns to menu.
return m, nil
}
func (m accountSummaryModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("ACCOUNT SUMMARY"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
// Header.
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "ACCOUNT", "TYPE", "BALANCE")))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50)))
b.WriteString("\n")
total := int64(0)
for _, acct := range m.accounts {
total += acct.Balance
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-18s %18s",
acct.Number, acct.Type, formatCurrency(acct.Balance))))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50)))
b.WriteString("\n")
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "", "TOTAL", formatCurrency(total))))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
b.WriteString("\n")
return b.String()
}
// --- Account Detail (with transactions) ---
type accountDetailModel struct {
accounts []Account
transactions map[string][]Transaction
selected int
page int
pageSize int
choosing bool
choice string
}
func newAccountDetailModel(accounts []Account, transactions map[string][]Transaction) accountDetailModel {
return accountDetailModel{
accounts: accounts,
transactions: transactions,
choosing: true,
pageSize: 10,
}
}
func (m accountDetailModel) Update(msg tea.Msg) (accountDetailModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.choosing {
switch keyMsg.Type {
case tea.KeyEnter:
for i := range m.accounts {
if m.choice == fmt.Sprintf("%d", i+1) {
m.selected = i
m.choosing = false
m.page = 0
break
}
}
if m.choice == "0" {
m.choice = "back"
}
return m, nil
case tea.KeyBackspace:
if len(m.choice) > 0 {
m.choice = m.choice[:len(m.choice)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' {
m.choice += ch
}
}
} else {
switch keyMsg.String() {
case "n", "N":
acctNum := m.accounts[m.selected].Number
txns := m.transactions[acctNum]
maxPage := (len(txns) - 1) / m.pageSize
if m.page < maxPage {
m.page++
}
case "p", "P":
if m.page > 0 {
m.page--
}
case "b", "B":
m.choosing = true
m.choice = ""
default:
m.choice = "back"
}
}
return m, nil
}
func (m accountDetailModel) View() string {
if m.choosing {
return m.viewChooseAccount()
}
return m.viewDetail()
}
func (m accountDetailModel) viewChooseAccount() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("ACCOUNT DETAIL"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" SELECT ACCOUNT:"))
b.WriteString("\n\n")
for i, acct := range m.accounts {
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d] %s - %s %s",
i+1, acct.Number, acct.Type, formatCurrency(acct.Balance))))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
b.WriteString(inputStyle.Render(m.choice))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
return b.String()
}
func (m accountDetailModel) viewDetail() string {
var b strings.Builder
acct := m.accounts[m.selected]
txns := m.transactions[acct.Number]
b.WriteString("\n")
b.WriteString(centerText(fmt.Sprintf("ACCOUNT DETAIL - %s", acct.Number)))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" TYPE: %s", acct.Type)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" BALANCE: %s", formatCurrency(acct.Balance))))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
// Header.
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-34s %12s %12s", "DATE", "DESCRIPTION", "AMOUNT", "BALANCE")))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 72)))
b.WriteString("\n")
// Paginate.
start := m.page * m.pageSize
end := min(start+m.pageSize, len(txns))
for _, txn := range txns[start:end] {
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-34s %12s %12s",
txn.Date, txn.Description, formatCurrency(txn.Amount), formatCurrency(txn.Balance))))
b.WriteString("\n")
}
b.WriteString("\n")
totalPages := (len(txns) + m.pageSize - 1) / m.pageSize
b.WriteString(dimStyle.Render(fmt.Sprintf(" PAGE %d OF %d", m.page+1, totalPages)))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" [N]EXT PAGE [P]REV PAGE [B]ACK ANY OTHER KEY = MAIN MENU"))
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,111 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type adminModel struct {
pin string
attempts int
locked bool
}
func newAdminModel() adminModel {
return adminModel{}
}
func (m adminModel) Update(msg tea.Msg) (adminModel, tea.Cmd) {
if m.locked {
return m, nil
}
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
if m.pin != "" {
m.attempts++
if m.attempts >= 3 {
m.locked = true
}
m.pin = ""
}
case tea.KeyBackspace:
if len(m.pin) > 0 {
m.pin = m.pin[:len(m.pin)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.pin) < 20 {
m.pin += ch
}
}
return m, nil
}
func (m adminModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("SYSTEM ADMINISTRATION"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
if m.locked {
b.WriteString(errorStyle.Render(" *** ACCESS DENIED ***"))
b.WriteString("\n\n")
b.WriteString(errorStyle.Render(" MAXIMUM AUTHENTICATION ATTEMPTS EXCEEDED"))
b.WriteString("\n")
b.WriteString(errorStyle.Render(" TERMINAL LOCKED - INCIDENT LOGGED"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(" SECURITY ALERT HAS BEEN DISPATCHED TO:"))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" - INFORMATION SECURITY DEPT"))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" - BRANCH SECURITY OFFICER"))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" - FEDERAL RESERVE OVERSIGHT"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" INCIDENT REF: SEC-%d-ADMIN-BRUTE", 20240000+m.attempts)))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" IEF4271I UNAUTHORIZED ACCESS ATTEMPT - ABEND S0C4"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" IEF4272I JOB SECADMIN STEP0001 - COND CODE 4088"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
b.WriteString("\n")
} else {
b.WriteString(titleStyle.Render(" RESTRICTED ACCESS - ADMINISTRATOR ONLY"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(" THIS FUNCTION REQUIRES LEVEL 5 SECURITY CLEARANCE."))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" ALL ACCESS ATTEMPTS ARE LOGGED AND AUDITED."))
b.WriteString("\n\n")
if m.attempts > 0 {
b.WriteString(errorStyle.Render(fmt.Sprintf(" INVALID CREDENTIALS (%d OF 3 ATTEMPTS)", m.attempts)))
b.WriteString("\n\n")
}
b.WriteString(titleStyle.Render(" ADMIN PIN: "))
masked := strings.Repeat("*", len(m.pin))
b.WriteString(inputStyle.Render(masked))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ESC TO RETURN TO MAIN MENU"))
b.WriteString("\n")
}
return b.String()
}

View File

@@ -0,0 +1,152 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type historyModel struct {
accounts []Account
transactions map[string][]Transaction
selected int
page int
pageSize int
choosing bool
choice string
}
func newHistoryModel(accounts []Account, transactions map[string][]Transaction) historyModel {
return historyModel{
accounts: accounts,
transactions: transactions,
choosing: true,
pageSize: 12,
}
}
func (m historyModel) Update(msg tea.Msg) (historyModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.choosing {
switch keyMsg.Type {
case tea.KeyEnter:
for i := range m.accounts {
if m.choice == fmt.Sprintf("%d", i+1) {
m.selected = i
m.choosing = false
m.page = 0
break
}
}
if m.choice == "0" {
m.choice = "back"
}
return m, nil
case tea.KeyBackspace:
if len(m.choice) > 0 {
m.choice = m.choice[:len(m.choice)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' {
m.choice += ch
}
}
} else {
switch keyMsg.String() {
case "n", "N":
acctNum := m.accounts[m.selected].Number
txns := m.transactions[acctNum]
maxPage := (len(txns) - 1) / m.pageSize
if m.page < maxPage {
m.page++
}
case "p", "P":
if m.page > 0 {
m.page--
}
case "b", "B":
m.choosing = true
m.choice = ""
default:
m.choice = "back"
}
}
return m, nil
}
func (m historyModel) View() string {
if m.choosing {
return m.viewChooseAccount()
}
return m.viewHistory()
}
func (m historyModel) viewChooseAccount() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("TRANSACTION HISTORY"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" SELECT ACCOUNT:"))
b.WriteString("\n\n")
for i, acct := range m.accounts {
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d] %s - %s",
i+1, acct.Number, acct.Type)))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
b.WriteString(inputStyle.Render(m.choice))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
return b.String()
}
func (m historyModel) viewHistory() string {
var b strings.Builder
acct := m.accounts[m.selected]
txns := m.transactions[acct.Number]
b.WriteString("\n")
b.WriteString(centerText(fmt.Sprintf("TRANSACTION HISTORY - %s (%s)", acct.Number, acct.Type)))
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-40s %14s", "DATE", "DESCRIPTION", "AMOUNT")))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 68)))
b.WriteString("\n")
start := m.page * m.pageSize
end := min(start+m.pageSize, len(txns))
for _, txn := range txns[start:end] {
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-40s %14s",
txn.Date, txn.Description, formatCurrency(txn.Amount))))
b.WriteString("\n")
}
b.WriteString("\n")
totalPages := (len(txns) + m.pageSize - 1) / m.pageSize
b.WriteString(dimStyle.Render(fmt.Sprintf(" PAGE %d OF %d", m.page+1, totalPages)))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" [N]EXT PAGE [P]REV PAGE [B]ACK ANY OTHER KEY = MAIN MENU"))
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,103 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type loginModel struct {
accountNum string
pin string
stage int // 0 = account, 1 = pin, 2 = authenticating
bankName string
}
func newLoginModel(bankName string) loginModel {
return loginModel{bankName: bankName}
}
func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
if m.stage == 0 && m.accountNum != "" {
m.stage = 1
return m, nil
}
if m.stage == 1 && m.pin != "" {
m.stage = 2
return m, nil
}
case tea.KeyBackspace:
if m.stage == 0 && len(m.accountNum) > 0 {
m.accountNum = m.accountNum[:len(m.accountNum)-1]
} else if m.stage == 1 && len(m.pin) > 0 {
m.pin = m.pin[:len(m.pin)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
if m.stage == 0 && len(m.accountNum) < 20 {
m.accountNum += ch
} else if m.stage == 1 && len(m.pin) < 12 {
m.pin += ch
}
}
}
return m, nil
}
func (m loginModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText(m.bankName + " ONLINE BANKING"))
b.WriteString("\n")
b.WriteString(centerText("AUTHORIZED ACCESS ONLY"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ACCOUNT NUMBER: "))
if m.stage == 0 {
b.WriteString(inputStyle.Render(m.accountNum))
b.WriteString(inputStyle.Render("_"))
} else {
b.WriteString(baseStyle.Render(m.accountNum))
}
b.WriteString("\n\n")
if m.stage >= 1 {
b.WriteString(titleStyle.Render(" PIN: "))
masked := strings.Repeat("*", len(m.pin))
if m.stage == 1 {
b.WriteString(inputStyle.Render(masked))
b.WriteString(inputStyle.Render("_"))
} else {
b.WriteString(baseStyle.Render(masked))
}
b.WriteString("\n")
}
if m.stage == 2 {
b.WriteString("\n")
b.WriteString(baseStyle.Render(" AUTHENTICATING..."))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" WARNING: UNAUTHORIZED ACCESS TO THIS SYSTEM IS A FEDERAL CRIME"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(fmt.Sprintf(" UNDER 18 U.S.C. %s 1030. ALL ACTIVITY IS MONITORED AND LOGGED.", "\u00A7")))
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,80 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type menuModel struct {
choice string
unread int
bankName string
}
func newMenuModel(bankName string, unreadCount int) menuModel {
return menuModel{bankName: bankName, unread: unreadCount}
}
func (m menuModel) Update(msg tea.Msg) (menuModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
return m, nil
case tea.KeyBackspace:
if len(m.choice) > 0 {
m.choice = m.choice[:len(m.choice)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
if len(m.choice) < 10 {
m.choice += ch
}
}
}
return m, nil
}
func (m menuModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("MAIN MENU"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
items := []struct {
num string
desc string
}{
{"1", "ACCOUNT SUMMARY"},
{"2", "ACCOUNT DETAIL / TRANSACTIONS"},
{"3", "WIRE TRANSFER"},
{"4", "TRANSACTION HISTORY"},
{"5", fmt.Sprintf("SECURE MESSAGES (%d UNREAD)", m.unread)},
{"6", "CHANGE PIN"},
{"7", "LOGOUT"},
}
for _, item := range items {
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%s] %s", item.num, item.desc)))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
b.WriteString(inputStyle.Render(m.choice))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,122 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type messagesModel struct {
messages []SecureMessage
viewing int // -1 = list, >= 0 = detail
choice string
}
func newMessagesModel(messages []SecureMessage) messagesModel {
return messagesModel{messages: messages, viewing: -1}
}
func (m messagesModel) Update(msg tea.Msg) (messagesModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.viewing >= 0 {
// In detail view, any key goes back to list.
m.viewing = -1
m.choice = ""
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
if m.choice == "0" {
m.choice = "back"
return m, nil
}
for i := range m.messages {
if m.choice == fmt.Sprintf("%d", i+1) {
m.viewing = i
m.messages[i].Unread = false
return m, nil
}
}
m.choice = ""
case tea.KeyBackspace:
if len(m.choice) > 0 {
m.choice = m.choice[:len(m.choice)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' && len(m.choice) < 2 {
m.choice += ch
}
}
return m, nil
}
func (m messagesModel) View() string {
if m.viewing >= 0 {
return m.viewDetail()
}
return m.viewList()
}
func (m messagesModel) viewList() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("SECURE MESSAGES"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-4s %-3s %-12s %-22s %s", "#", "", "DATE", "FROM", "SUBJECT")))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 68)))
b.WriteString("\n")
for i, msg := range m.messages {
marker := " "
if msg.Unread {
marker = " * "
}
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d]%s%-12s %-22s %s",
i+1, marker, msg.Date, msg.From, msg.Subj)))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" SELECT MESSAGE: "))
b.WriteString(inputStyle.Render(m.choice))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
return b.String()
}
func (m messagesModel) viewDetail() string {
var b strings.Builder
msg := m.messages[m.viewing]
b.WriteString("\n")
b.WriteString(centerText(fmt.Sprintf("MESSAGE #%d", m.viewing+1)))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(msg.Body))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MESSAGE LIST"))
b.WriteString("\n")
return b.String()
}

View File

@@ -0,0 +1,194 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
const (
transferStepRouting = iota
transferStepDest
transferStepBeneficiary
transferStepBankName
transferStepAmount
transferStepMemo
transferStepConfirm
transferStepAuthCode
transferStepComplete
)
var transferPrompts = []string{
" ROUTING NUMBER (ABA): ",
" DESTINATION ACCOUNT: ",
" BENEFICIARY NAME: ",
" RECEIVING BANK NAME: ",
" AMOUNT (USD): ",
" MEMO / REFERENCE: ",
"",
" AUTHORIZATION CODE: ",
}
type transferModel struct {
step int
fields [8]string // indexed by step
transfer WireTransfer
confirm string // y/n input for confirm step
}
func newTransferModel() transferModel {
return transferModel{}
}
func (m transferModel) Update(msg tea.Msg) (transferModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.step == transferStepComplete {
return m, nil
}
if m.step == transferStepConfirm {
switch keyMsg.Type {
case tea.KeyEnter:
switch strings.ToUpper(m.confirm) {
case "Y", "YES":
m.step = transferStepAuthCode
case "N", "NO":
m.confirm = "cancelled"
}
return m, nil
case tea.KeyBackspace:
if len(m.confirm) > 0 {
m.confirm = m.confirm[:len(m.confirm)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.confirm) < 3 {
m.confirm += ch
}
}
return m, nil
}
switch keyMsg.Type {
case tea.KeyEnter:
val := strings.TrimSpace(m.fields[m.step])
if val == "" {
return m, nil
}
if m.step == transferStepAuthCode {
m.transfer.AuthCode = val
m.step = transferStepComplete
return m, nil
}
switch m.step {
case transferStepRouting:
m.transfer.RoutingNumber = val
case transferStepDest:
m.transfer.DestAccount = val
case transferStepBeneficiary:
m.transfer.Beneficiary = val
case transferStepBankName:
m.transfer.BankName = val
case transferStepAmount:
m.transfer.Amount = val
case transferStepMemo:
m.transfer.Memo = val
}
m.step++
return m, nil
case tea.KeyBackspace:
if len(m.fields[m.step]) > 0 {
m.fields[m.step] = m.fields[m.step][:len(m.fields[m.step])-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.fields[m.step]) < 40 {
m.fields[m.step] += ch
}
}
return m, nil
}
func (m transferModel) View() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("WIRE TRANSFER"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
// Show completed fields.
for i := 0; i < m.step && i < len(transferPrompts); i++ {
if i == transferStepConfirm {
continue
}
prompt := transferPrompts[i]
if prompt == "" {
continue
}
b.WriteString(baseStyle.Render(prompt))
b.WriteString(baseStyle.Render(m.fields[i]))
b.WriteString("\n")
}
// Current field.
switch {
case m.step == transferStepConfirm:
b.WriteString("\n")
b.WriteString(titleStyle.Render(" === TRANSFER SUMMARY ==="))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" ROUTING: %s", m.transfer.RoutingNumber)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" ACCOUNT: %s", m.transfer.DestAccount)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" BENEFICIARY: %s", m.transfer.Beneficiary)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" BANK: %s", m.transfer.BankName)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" AMOUNT: $%s", m.transfer.Amount)))
b.WriteString("\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" MEMO: %s", m.transfer.Memo)))
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" CONFIRM TRANSFER? (Y/N): "))
b.WriteString(inputStyle.Render(m.confirm))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
case m.step == transferStepComplete:
b.WriteString("\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s",
m.transfer.RoutingNumber[:4], "847291")))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT"))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" ESTIMATED COMPLETION: NEXT BUSINESS DAY"))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
b.WriteString("\n")
case m.step < len(transferPrompts):
prompt := transferPrompts[m.step]
b.WriteString(titleStyle.Render(prompt))
b.WriteString(inputStyle.Render(m.fields[m.step]))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
}
if m.step < transferStepConfirm {
b.WriteString("\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(fmt.Sprintf(" STEP %d OF 6", m.step+1)))
b.WriteString("\n")
}
return b.String()
}

View File

@@ -0,0 +1,128 @@
package banking
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
const termWidth = 80
// Color palette — green-on-black retro terminal.
var (
colorGreen = lipgloss.Color("#00FF00")
colorDim = lipgloss.Color("#007700")
colorBlack = lipgloss.Color("#000000")
colorBright = lipgloss.Color("#AAFFAA")
colorRed = lipgloss.Color("#FF3333")
)
// Reusable styles.
var (
baseStyle = lipgloss.NewStyle().
Foreground(colorGreen).
Background(colorBlack)
headerStyle = lipgloss.NewStyle().
Foreground(colorBright).
Background(colorBlack).
Bold(true).
Width(termWidth).
Align(lipgloss.Center)
titleStyle = lipgloss.NewStyle().
Foreground(colorGreen).
Background(colorBlack).
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(colorDim).
Background(colorBlack)
errorStyle = lipgloss.NewStyle().
Foreground(colorRed).
Background(colorBlack).
Bold(true)
inputStyle = lipgloss.NewStyle().
Foreground(colorBright).
Background(colorBlack)
)
// divider returns an 80-column === line.
func divider() string {
return dimStyle.Render(strings.Repeat("=", termWidth))
}
// thinDivider returns an 80-column --- line.
func thinDivider() string {
return dimStyle.Render(strings.Repeat("-", termWidth))
}
// centerText centers text within 80 columns.
func centerText(s string) string {
return headerStyle.Render(s)
}
// padRight pads a string to the given width.
func padRight(s string, width int) string {
if len(s) >= width {
return s[:width]
}
return s + strings.Repeat(" ", width-len(s))
}
// formatCurrency formats cents as $X,XXX.XX
func formatCurrency(cents int64) string {
negative := cents < 0
if negative {
cents = -cents
}
dollars := cents / 100
remainder := cents % 100
// Add thousands separators.
ds := fmt.Sprintf("%d", dollars)
if len(ds) > 3 {
var parts []string
for len(ds) > 3 {
parts = append([]string{ds[len(ds)-3:]}, parts...)
ds = ds[:len(ds)-3]
}
parts = append([]string{ds}, parts...)
ds = strings.Join(parts, ",")
}
if negative {
return fmt.Sprintf("-$%s.%02d", ds, remainder)
}
return fmt.Sprintf("$%s.%02d", ds, remainder)
}
// screenFrame wraps content in the persistent header and footer.
func screenFrame(bankName, terminalID, region, content string) string {
var b strings.Builder
// Header.
b.WriteString(divider())
b.WriteString("\n")
b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM"))
b.WriteString("\n")
b.WriteString(centerText("SECURE BANKING TERMINAL"))
b.WriteString("\n")
b.WriteString(divider())
b.WriteString("\n")
// Content.
b.WriteString(content)
// Footer.
b.WriteString("\n")
b.WriteString(divider())
b.WriteString("\n")
footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region)
b.WriteString(dimStyle.Render(padRight(footer, termWidth)))
return b.String()
}

View File

@@ -24,6 +24,11 @@ password = "admin"
# password = "fridge"
# shell = "fridge"
# [[auth.static_credentials]]
# username = "teller"
# password = "banking"
# shell = "banking"
[storage]
db_path = "oubliette.db"
retention_days = 90
@@ -38,6 +43,12 @@ hostname = "ubuntu-server"
# banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n"
# fake_user = "" # override username in prompt; empty = use authenticated user
# Per-shell configuration (optional).
# [shell.banking]
# bank_name = "SECUREBANK"
# terminal_id = "SB-0001" # random if not set
# region = "NORTHEAST"
# [detection]
# enabled = true
# threshold = 0.6 # 0.01.0, sessions above this trigger notifications