Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions internal/aba/aba.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

// Package aba decodes and validates an ABA routing transit number (RTN)
// — the 9-digit code that identifies a US bank or credit union in ACH
// and wire transfers. It is the US-domestic counterpart to internal/iban
// (the international account): a routing number turns up in ACH-fraud /
// business-email-compromise lures, leaked direct-deposit forms, and
// check images, so decoding one off a paste tells an operator whether
// the number is internally consistent and which Federal Reserve district
// issued it.
//
// # Wrap-vs-native judgement
//
// Native. An RTN is a fixed digit-field layout — a 4-digit Federal
// Reserve routing symbol, a 4-digit ABA institution identifier, and 1
// check digit — guarded by a weighted modulus-10 checksum. Validating
// it is integer arithmetic plus a small fixed range classification; no
// runtime dependency or hand-transcribed lookup beyond the 12 statutory
// Federal Reserve districts is justified. stdlib only, no new go.mod
// dep.
//
// # Verifiable / no confidently-wrong output
//
// The modulus-10 checksum is the verification anchor — it is recomputed
// and compared, and a mismatch is reported as checksum_valid=false with
// a note (a mistyped RTN), never asserted as a definitively fake bank.
// The Federal Reserve district and the institution type are derived
// from the leading two digits by the published numbering ranges (not a
// guess); a prefix outside those ranges leaves the district undetermined
// rather than inventing one. The institution identifier is surfaced raw
// — it maps to a specific bank only via the proprietary American Bankers
// Association registry, which is not embedded.
package aba

import (
"fmt"
"strings"
)

// Result is the decoded view of an ABA routing transit number.
type Result struct {
RoutingNumber string `json:"routing_number"`
RoutingSymbol string `json:"federal_reserve_routing_symbol"` // digits 1-4
InstitutionID string `json:"aba_institution_identifier"` // digits 5-8
CheckDigit string `json:"check_digit"` // digit 9
Type string `json:"type"` // Government / Primary / Thrift / Electronic / Traveler's
District int `json:"federal_reserve_district,omitempty"`
DistrictName string `json:"federal_reserve_district_name,omitempty"`
ChecksumValid bool `json:"checksum_valid"` // ABA weighted modulus-10 (the verification anchor)
Notes []string `json:"notes,omitempty"`
}

// rtnLength is the fixed digit count of an ABA routing transit number.
const rtnLength = 9

// frbDistricts maps the 12 statutory Federal Reserve districts (defined
// by the Federal Reserve Act of 1913) to their head-office city. Fixed
// by law, not drifting reference data.
//
//nolint:gochecknoglobals
var frbDistricts = map[int]string{
1: "Boston", 2: "New York", 3: "Philadelphia", 4: "Cleveland",
5: "Richmond", 6: "Atlanta", 7: "Chicago", 8: "St. Louis",
9: "Minneapolis", 10: "Kansas City", 11: "Dallas", 12: "San Francisco",
}

// Decode validates and breaks down an ABA routing number. Spaces, '-'
// and ':' separators are tolerated.
func Decode(raw string) (*Result, error) {
// Keep only digits — RTNs are sometimes pasted with grouping spaces
// or dashes (e.g. from a check's MICR line).
d := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
switch r {
case ' ', '-', ':', '\t', '\n', '\r':
return -1
}
// Any other rune (a letter, punctuation) is a hard signal this
// is not an RTN; mark it so the length check below rejects it.
return 'x'
}, raw)

if d == "" {
return nil, fmt.Errorf("aba: empty input")
}
if strings.ContainsRune(d, 'x') {
return nil, fmt.Errorf("aba: input contains non-digit characters — a routing number is 9 digits")
}
if len(d) != rtnLength {
return nil, fmt.Errorf("aba: %d digits — an ABA routing number is exactly %d", len(d), rtnLength)
}

r := &Result{
RoutingNumber: d,
RoutingSymbol: d[:4],
InstitutionID: d[4:8],
CheckDigit: d[8:9],
}
r.ChecksumValid = checksumValid(d)
if !r.ChecksumValid {
r.Notes = append(r.Notes,
"weighted modulus-10 checksum failed — the routing number is mistyped or transposed")
}
classify(r, int(d[0]-'0')*10+int(d[1]-'0'))
return r, nil
}

// classify resolves the institution type and Federal Reserve district
// from the leading two digits per the published RTN numbering ranges.
func classify(r *Result, prefix int) {
switch {
case prefix == 0:
r.Type = "US Government / Treasury"
case prefix >= 1 && prefix <= 12:
r.Type = "Primary (Federal Reserve Bank)"
r.District = prefix
case prefix >= 21 && prefix <= 32:
r.Type = "Thrift institution"
r.District = prefix - 20
case prefix >= 61 && prefix <= 72:
r.Type = "Electronic / ACH"
r.District = prefix - 60
case prefix == 80:
r.Type = "Traveler's cheque"
default:
r.Type = "unknown"
r.Notes = append(r.Notes, fmt.Sprintf(
"leading digits %02d are outside the assigned RTN ranges; Federal Reserve district undetermined", prefix))
}
if name, ok := frbDistricts[r.District]; ok {
r.DistrictName = name
}
}

// checksumValid reports whether the 9-digit RTN satisfies the ABA
// weighted modulus-10 checksum (weights 3,7,1 repeating, sum ≡ 0 mod 10).
func checksumValid(d string) bool {
weights := [9]int{3, 7, 1, 3, 7, 1, 3, 7, 1}
sum := 0
for i := 0; i < rtnLength; i++ {
sum += int(d[i]-'0') * weights[i]
}
return sum%10 == 0
}
128 changes: 128 additions & 0 deletions internal/aba/aba_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package aba

import "testing"

// realRTNs are real, well-known US bank routing numbers. Every assigned
// RTN satisfies the ABA weighted modulus-10 checksum, so they double as
// authoritative validation vectors: an incorrect algorithm could not
// make all of them pass.
var realRTNs = []struct {
name, rtn string
district int
dName string
}{
{"JPMorgan Chase NY", "021000021", 2, "New York"},
{"FRB Boston (FedACH)", "011000015", 1, "Boston"},
{"Bank of America CA", "121000358", 12, "San Francisco"},
{"Bank of America NY", "026009593", 2, "New York"},
{"Chase IL", "071000013", 7, "Chicago"},
}

// TestDecode_RealRTNs validates and field-splits each real routing number.
func TestDecode_RealRTNs(t *testing.T) {
for _, c := range realRTNs {
t.Run(c.name, func(t *testing.T) {
r, err := Decode(c.rtn)
if err != nil {
t.Fatalf("Decode(%q) error: %v", c.rtn, err)
}
if !r.ChecksumValid {
t.Errorf("Decode(%q) ChecksumValid = false, want true", c.rtn)
}
if r.District != c.district {
t.Errorf("District = %d, want %d", r.District, c.district)
}
if r.DistrictName != c.dName {
t.Errorf("DistrictName = %q, want %q", r.DistrictName, c.dName)
}
if r.RoutingSymbol != c.rtn[:4] || r.InstitutionID != c.rtn[4:8] || r.CheckDigit != c.rtn[8:9] {
t.Errorf("field split wrong: %+v", r)
}
})
}
}

// TestDecode_Thrift covers the 21-32 thrift prefix range (district =
// prefix - 20).
func TestDecode_Thrift(t *testing.T) {
// 322271627 (a Chase CA thrift-range RTN): prefix 32 → district 12.
r, err := Decode("322271627")
if err != nil {
t.Fatalf("Decode error: %v", err)
}
if !r.ChecksumValid {
t.Error("ChecksumValid = false, want true")
}
if r.Type != "Thrift institution" || r.District != 12 || r.DistrictName != "San Francisco" {
t.Errorf("got type=%q district=%d name=%q", r.Type, r.District, r.DistrictName)
}
}

// TestDecode_SeparatorsTolerated confirms grouped pastes (MICR / form
// formatting) decode identically.
func TestDecode_SeparatorsTolerated(t *testing.T) {
for _, in := range []string{"0210 0002 1", "021-000-021", "021:000:021"} {
r, err := Decode(in)
if err != nil {
t.Fatalf("Decode(%q) error: %v", in, err)
}
if r.RoutingNumber != "021000021" || !r.ChecksumValid {
t.Errorf("Decode(%q) = %q valid=%v", in, r.RoutingNumber, r.ChecksumValid)
}
}
}

// TestDecode_BadChecksum is the verification-anchor behaviour: a
// transposed RTN fails the checksum but is still decoded (a soft note,
// not a hard error).
func TestDecode_BadChecksum(t *testing.T) {
// JPMorgan RTN with the check digit bumped 1 -> 2.
r, err := Decode("021000022")
if err != nil {
t.Fatalf("Decode error: %v", err)
}
if r.ChecksumValid {
t.Fatal("ChecksumValid = true for a corrupted RTN, want false")
}
if len(r.Notes) == 0 {
t.Fatal("expected a note explaining the failed checksum, got none")
}
}

// TestDecode_StructuralErrors covers the hard-error boundary cases.
func TestDecode_StructuralErrors(t *testing.T) {
for _, in := range []string{
"", // empty
" ", // only separators
"02100002", // 8 digits (too short)
"0210000211", // 10 digits (too long)
"02100002A", // contains a letter
} {
if _, err := Decode(in); err == nil {
t.Errorf("Decode(%q) = nil error, want structural error", in)
}
}
}

// FuzzDecode is a panic-safety guard: no byte string may crash the
// decoder, and any returned result must be self-consistent.
func FuzzDecode(f *testing.F) {
for _, s := range []string{"021000021", "", "0210 0002 1", "\x00\xff", "999999999"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
r, err := Decode(s)
if err != nil {
return
}
if len(r.RoutingNumber) != rtnLength {
t.Errorf("decoded RTN length %d, want %d", len(r.RoutingNumber), rtnLength)
}
if r.RoutingSymbol+r.InstitutionID+r.CheckDigit != r.RoutingNumber {
t.Errorf("fields do not reconstruct RTN: %q+%q+%q != %q",
r.RoutingSymbol, r.InstitutionID, r.CheckDigit, r.RoutingNumber)
}
})
}
11 changes: 11 additions & 0 deletions internal/risk/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -2714,6 +2714,17 @@ var toolLevels = func() map[string]Level {
// pasted string; reads a string, transmits nothing, so it is Low.
// Luhn algorithm verified against five well-known real ISINs.
"isin_decode",
// v0.737 (US-domestic bank decode): aba_routing_decode — the ABA
// routing transit number, the US counterpart to iban_decode (the
// international account). 4-digit Federal Reserve routing symbol +
// 4-digit institution id + 1 check digit, guarded by the ABA
// weighted modulus-10 checksum (the verification anchor); the
// leading two digits resolve the FRB district + institution type.
// Turns up in ACH-fraud / BEC lures / direct-deposit forms / check
// MICR lines. Pure offline digit-field decode of a pasted number;
// reads a string, transmits nothing, so it is Low. Checksum
// verified against real bank routing numbers.
"aba_routing_decode",
// v0.551 (travel-document decode): mrz_decode — the ICAO 9303
// Machine Readable Zone of a passport / ID / visa (TD1/TD2/TD3),
// the BAC-key input for reading an e-passport NFC chip. Fields +
Expand Down
71 changes: 71 additions & 0 deletions internal/tools/aba_routing_decode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// aba_routing_decode.go — host-side ABA routing-number decoder Spec,
// delegating to internal/aba.
//
// Wrap-vs-native: native — an RTN is a fixed digit-field layout (4-digit
// Federal Reserve routing symbol + 4-digit institution id + 1 check
// digit) guarded by a weighted modulus-10 checksum; validating it is
// integer arithmetic, stdlib only, no new go.mod dep. The US-domestic
// counterpart to iban_decode (the international account). Offline read,
// no hardware.

package tools

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/xunholy/promptzero/internal/aba"
"github.com/xunholy/promptzero/internal/risk"
)

func init() { //nolint:gochecknoinits
Register(abaRoutingDecodeSpec)
}

var abaRoutingDecodeSpec = Spec{
Name: "aba_routing_decode",
Description: "Decode and validate an **ABA routing number** (RTN) — the 9-digit code that identifies a US " +
"bank or credit union in ACH and wire transfers. The **US-domestic counterpart to `iban_decode`** " +
"(the international account): an RTN shows up in ACH-fraud / business-email-compromise lures, leaked " +
"direct-deposit forms, and check (MICR-line) images.\n\n" +
"Breaks the RTN into:\n" +
"- **Federal Reserve routing symbol** — digits 1-4.\n" +
"- **ABA institution identifier** — digits 5-8 (surfaced raw; the bank name lives only in the " +
"proprietary ABA registry, not embedded).\n" +
"- **Check digit** — digit 9.\n" +
"- **Type + Federal Reserve district** — derived from the leading two digits by the published RTN " +
"numbering ranges (Government / Primary FRB / Thrift / Electronic-ACH / Traveler's cheque); a prefix " +
"outside those ranges leaves the district undetermined rather than guessing.\n" +
"- **Checksum result** — recomputed and compared (`checksum_valid`).\n\n" +
"The **weighted modulus-10 checksum is the verification anchor**: a mismatch is reported as " +
"`checksum_valid=false` with a note (a mistyped or transposed RTN), never asserted as a definitively " +
"fake bank. No confidently-wrong output.\n\n" +
"Offline transform — reads a string, transmits nothing, so it is Low risk. ' ' / '-' / ':' separators " +
"are tolerated. Wrap-vs-native: native — modulus-10 arithmetic, stdlib only, no new go.mod dep.",
Schema: json.RawMessage(`{
"type":"object",
"properties":{
"routing_number":{"type":"string","description":"9-digit ABA routing transit number. ' ' / '-' / ':' separators tolerated."}
},
"required":["routing_number"]
}`),
Required: []string{"routing_number"},
Risk: risk.Low,
Group: GroupHostTools,
AgentOnly: false,
Handler: abaRoutingDecodeHandler,
}

func abaRoutingDecodeHandler(_ context.Context, _ *Deps, p map[string]any) (string, error) {
if strings.TrimSpace(str(p, "routing_number")) == "" {
return "", fmt.Errorf("aba_routing_decode: 'routing_number' is required")
}
res, err := aba.Decode(str(p, "routing_number"))
if err != nil {
return "", fmt.Errorf("aba_routing_decode: %w", err)
}
body, _ := json.MarshalIndent(res, "", " ")
return string(body), nil
}
6 changes: 5 additions & 1 deletion internal/tools/registry_size_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3026,7 +3026,11 @@ func TestRegistrySize(t *testing.T) {
// 6166 → the 2-letter prefix + 9-char NSIN + 1 Luhn check digit; the securities
// leg of the financial-data family with iban/lei, Luhn algorithm verified against
// five well-known real ISINs). internal/isin.
const expected = 688
// v0.737.0 added aba_routing_decode (ABA routing transit number → 4-digit Federal
// Reserve routing symbol + 4-digit institution id + 1 check digit, ABA weighted
// modulus-10 checksum + FRB district/type from the leading two digits; the US-
// domestic bank counterpart to iban_decode, verified against real RTNs). internal/aba.
const expected = 689
if initialRegistrySize != expected {
t.Errorf("registry names at init = %d, want %d (wave-by-wave checked in §D of runbook)",
initialRegistrySize, expected)
Expand Down
Loading
Loading