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

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