From 5ca234a64ecd90fba6e170e9a834922aae507651 Mon Sep 17 00:00:00 2001 From: Michael Fornaro <20387402+xUnholy@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:19:32 +1000 Subject: [PATCH] =?UTF-8?q?feat(emv):=20add=20nfc=5Femv=5Faip=5Fdecode=20?= =?UTF-8?q?=E2=80=94=20EMV=20Application=20Interchange=20Profile=20(tag=20?= =?UTF-8?q?82)=20field=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nfc_emv_* family already cracks the structured EMV fields the BER-TLV walker surfaces raw — Track-2 (tag 57), DOL lists, AFL (tag 94), CVM List (tag 8E). The Application Interchange Profile (tag 82) was named by the tag dictionary but its 2-byte bitfield was never decoded, even though it is the single most security-relevant field on an EMV card dump: it advertises which offline data-authentication method the card supports (SDA / DDA / CDA), plus cardholder verification, issuer authentication and terminal risk management. nfc_emv_aip_decode cracks it. Byte 1's seven defined bits are decoded per EMV 4.3 Book 3, Annex C1 (SDA 0x40, DDA 0x20, cardholder verification 0x10, terminal risk management 0x08, issuer authentication 0x04, on-device CVM 0x02 [EMV 4.3+], CDA 0x01). An offline_data_authentication headline picks the strongest method present (CDA > DDA > SDA), flagging SDA-only cards as replayable/clone-prone and no-offline-auth cards as online-only — the takeaway an analyst actually wants. No confidently-wrong output: byte 1 bit 8 and the whole of byte 2 are RFU in the EMV Book 3 contact profile (contactless kernels repurpose byte 2), so they are surfaced raw with a note rather than guessed. Gated structurally to exactly 2 bytes. Accepts either the 2-byte AIP value directly or a full EMV BER-TLV blob (tag 82 extracted automatically). Offline transform — reads bytes, transmits nothing — so it is Low risk like the rest of the EMV decode family. Verification: the decode is a direct mask of the published EMV bit table, so the single-bit vectors (each byte-1 bit in isolation) are authoritative; plus realistic combined values (7C00 offline-DDA card, 1800 online-only, 0500 CDA), the CDA>DDA>SDA headline precedence, and RFU-note + 2-byte-gate rejection paths. gofmt / go vet / golangci-lint (0 issues) / go test -race (emv, risk, tools) all green. Wired: internal/emv/{aip.go,aip_test.go}, internal/tools/emv_aip.go, risk.go classification (Low, consistency test green), registry_size_test.go 676 -> 677 (count + comment), and the gap-analysis EMV row. --- docs/catalog/gap-analysis.md | 2 +- internal/emv/aip.go | 118 +++++++++++++++++++++++++++ internal/emv/aip_test.go | 112 +++++++++++++++++++++++++ internal/risk/risk.go | 5 ++ internal/tools/emv_aip.go | 87 ++++++++++++++++++++ internal/tools/registry_size_test.go | 8 +- 6 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 internal/emv/aip.go create mode 100644 internal/emv/aip_test.go create mode 100644 internal/tools/emv_aip.go diff --git a/docs/catalog/gap-analysis.md b/docs/catalog/gap-analysis.md index eddd58bc..606edec7 100644 --- a/docs/catalog/gap-analysis.md +++ b/docs/catalog/gap-analysis.md @@ -95,7 +95,7 @@ audit (`docs/refactor/v0.8-team-audit.md`). | NFC relay (two-Flipper proxy) | apps top-20 #13 | ❌ | — | — | **NEW vs audit** ⟶ `nfc_relay_start/stop` | | ULC / SEOS BLE-tunnel relay | apps `ulc_relay` | ❌ | — | — | New gap | | ISO15693-3 writer | firmware §4.2 #4 | ❌ | — | — | **NEW** small gap | -| EMV parse (visa/mc) | firmware §4.2 #3 | ✅ `nfc_emv_decode` (+ `nfc_emv_encode`) — BER-TLV walker + ~80-tag dictionary; `nfc_emv_track2_decode` v0.414 cracks tag 57 (PAN/expiry/service code, Luhn-gated); `nfc_emv_dol_decode` v0.415 walks PDOL/CDOL/DDOL/TDOL (tag,length) lists; `nfc_emv_afl_decode` v0.416 expands tag 94 (SFI/record ranges → READ RECORD list); `nfc_emv_cvm_decode` v0.426 cracks tag 8E (X/Y amounts + CVM method/condition rules) | — | — | shipped — BER-TLV + Track-2 + DOL + AFL + CVM-List field decode. Cryptogram/online-auth flow deliberately out of scope (needs issuer keys). | +| EMV parse (visa/mc) | firmware §4.2 #3 | ✅ `nfc_emv_decode` (+ `nfc_emv_encode`) — BER-TLV walker + ~80-tag dictionary; `nfc_emv_track2_decode` v0.414 cracks tag 57 (PAN/expiry/service code, Luhn-gated); `nfc_emv_dol_decode` v0.415 walks PDOL/CDOL/DDOL/TDOL (tag,length) lists; `nfc_emv_afl_decode` v0.416 expands tag 94 (SFI/record ranges → READ RECORD list); `nfc_emv_cvm_decode` v0.426 cracks tag 8E (X/Y amounts + CVM method/condition rules); `nfc_emv_aip_decode` v0.717 cracks tag 82 (Application Interchange Profile — SDA/DDA/CDA offline-auth capabilities + cardholder/issuer auth, per EMV Book 3 Annex C1) | — | — | shipped — BER-TLV + Track-2 + DOL + AFL + CVM-List + AIP field decode. Cryptogram/online-auth flow deliberately out of scope (needs issuer keys). | | Wiegand D0/D1 capture + replay | apps top-20 #6 + attacks #6 | ❌ | — | — | **§2b** ⟶ `gpio_wiegand_capture/replay` | | HID Prox / EM4xxx PACS decode | apps top-20 #15 | ✅ `rfid_pacs_decode` (+ inverse `rfid_pacs_encode`: H10301/H10306/H10304/H10302-37bit, round-trip-verified) | — | ✅ pm3 | shipped — Wiegand PACS field decode (see §3 #19) | | LF EM4100 / T5577 read+write | baseline | ✅ `rfid_*`, `loader_t5577_multiwriter`; offline `em4100_decode` (ID forms) + `em4100_encode` (64-bit frame) + `em4100_frame_decode` v0.417 (parity-validating frame→ID inverse) | — | — | — | diff --git a/internal/emv/aip.go b/internal/emv/aip.go new file mode 100644 index 00000000..6ac4f78d --- /dev/null +++ b/internal/emv/aip.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package emv + +import ( + "encoding/hex" + "fmt" +) + +// AIP is a decoded EMV Application Interchange Profile (tag 82): the 2-byte +// bitfield in which the card advertises which authentication and verification +// capabilities it supports. Byte 1's bits are defined unambiguously in EMV 4.3 +// Book 3, Annex C1 (Table 41); byte 2 is RFU in the contact profile and is +// repurposed by individual contactless kernels, so it is surfaced raw and not +// interpreted. +type AIP struct { + Raw string `json:"raw"` // the 2 bytes, hex + Byte1 string `json:"byte1"` // 0xNN + Byte2 string `json:"byte2"` // 0xNN + + // Byte-1 capability bits (EMV Book 3 Annex C1). + SDA bool `json:"sda_supported"` // bit 7 (0x40) + DDA bool `json:"dda_supported"` // bit 6 (0x20) + CardholderVerification bool `json:"cardholder_verification_supported"` // bit 5 (0x10) + TerminalRiskManagement bool `json:"terminal_risk_management_to_perform"` // bit 4 (0x08) + IssuerAuthentication bool `json:"issuer_authentication_supported"` // bit 3 (0x04) + OnDeviceCVM bool `json:"on_device_cardholder_verif_supported"` // bit 2 (0x02), EMV 4.3+ + CDA bool `json:"cda_supported"` // bit 1 (0x01) + + // Capabilities is the human-readable list of the set byte-1 bits, in + // bit order, for a quick read of what the card advertises. + Capabilities []string `json:"capabilities"` + + // OfflineDataAuthentication summarises the SDA/DDA/CDA story — the + // single most security-relevant takeaway from an AIP. + OfflineDataAuthentication string `json:"offline_data_authentication"` + + Notes []string `json:"notes,omitempty"` +} + +// aipBit pairs a byte-1 mask with its EMV Book 3 capability name, in bit order +// (high to low) so the Capabilities list reads top-down. +var aipBits = []struct { + mask byte + name string +}{ + {0x40, "Static Data Authentication (SDA) supported"}, + {0x20, "Dynamic Data Authentication (DDA) supported"}, + {0x10, "Cardholder verification is supported"}, + {0x08, "Terminal risk management is to be performed"}, + {0x04, "Issuer authentication is supported"}, + {0x02, "On-device cardholder verification is supported"}, + {0x01, "Combined DDA / Application Cryptogram generation (CDA) supported"}, +} + +// DecodeAIP decodes the raw bytes of EMV tag 82 (Application Interchange +// Profile). The AIP is a fixed 2-byte bitfield, so it is gated structurally: +// exactly 2 bytes must be present. Byte 1's seven defined capability bits are +// decoded per EMV Book 3 Annex C1; byte 1 bit 8 and the whole of byte 2 are +// RFU in the contact profile and are surfaced raw with a note rather than +// guessed (no confidently-wrong output). +func DecodeAIP(raw []byte) (*AIP, error) { + if len(raw) != 2 { + return nil, fmt.Errorf("emv: AIP (tag 82) must be exactly 2 bytes, got %d", len(raw)) + } + b1, b2 := raw[0], raw[1] + out := &AIP{ + Raw: fmt.Sprintf("%02X%02X", b1, b2), + Byte1: fmt.Sprintf("0x%02X", b1), + Byte2: fmt.Sprintf("0x%02X", b2), + SDA: b1&0x40 != 0, + DDA: b1&0x20 != 0, + CardholderVerification: b1&0x10 != 0, + TerminalRiskManagement: b1&0x08 != 0, + IssuerAuthentication: b1&0x04 != 0, + OnDeviceCVM: b1&0x02 != 0, + CDA: b1&0x01 != 0, + } + for _, b := range aipBits { + if b1&b.mask != 0 { + out.Capabilities = append(out.Capabilities, b.name) + } + } + + // Offline data authentication is the headline: which (if any) of + // SDA/DDA/CDA the card offers. CDA is the strongest, then DDA, then SDA. + switch { + case out.CDA: + out.OfflineDataAuthentication = "CDA (strongest — combined DDA + cryptogram)" + case out.DDA: + out.OfflineDataAuthentication = "DDA" + case out.SDA: + out.OfflineDataAuthentication = "SDA only (weakest — replayable; clone-prone)" + default: + out.OfflineDataAuthentication = "none advertised — card relies on online authorization" + } + + if b1&0x80 != 0 { + out.Notes = append(out.Notes, + "byte 1 bit 8 (0x80) is set but reserved for future use (RFU) in EMV Book 3") + } + if b2 != 0 { + out.Notes = append(out.Notes, fmt.Sprintf( + "byte 2 (0x%02X) is RFU in the EMV Book 3 contact profile; contactless kernels "+ + "(Visa payWave / Mastercard PayPass) repurpose these bits — surfaced raw, not interpreted", + b2)) + } + return out, nil +} + +// DecodeAIPHex is the hex-string convenience wrapper. +func DecodeAIPHex(s string) (*AIP, error) { + b, err := hex.DecodeString(stripSeparators(s)) + if err != nil { + return nil, fmt.Errorf("emv: invalid hex: %w", err) + } + return DecodeAIP(b) +} diff --git a/internal/emv/aip_test.go b/internal/emv/aip_test.go new file mode 100644 index 00000000..90613411 --- /dev/null +++ b/internal/emv/aip_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package emv + +import "testing" + +// Each byte-1 bit decoded in isolation, asserting the exact boolean per EMV +// Book 3 Annex C1. The decode is a direct mask of the published bit table, so +// these single-bit vectors are authoritative. +func TestDecodeAIP_SingleBits(t *testing.T) { + cases := []struct { + hex string + want func(*AIP) bool + name string + }{ + {"4000", func(a *AIP) bool { return a.SDA && !a.DDA && !a.CDA }, "SDA"}, + {"2000", func(a *AIP) bool { return a.DDA && !a.SDA }, "DDA"}, + {"1000", func(a *AIP) bool { return a.CardholderVerification }, "cardholder-verif"}, + {"0800", func(a *AIP) bool { return a.TerminalRiskManagement }, "terminal-risk"}, + {"0400", func(a *AIP) bool { return a.IssuerAuthentication }, "issuer-auth"}, + {"0200", func(a *AIP) bool { return a.OnDeviceCVM }, "on-device-cvm"}, + {"0100", func(a *AIP) bool { return a.CDA && !a.DDA }, "CDA"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, err := DecodeAIPHex(c.hex) + if err != nil { + t.Fatalf("DecodeAIPHex(%q): %v", c.hex, err) + } + if !c.want(a) { + t.Errorf("%s: bit not decoded as expected for %s: %+v", c.name, c.hex, a) + } + }) + } +} + +// Realistic combined AIP values seen on real cards. +func TestDecodeAIP_RealValues(t *testing.T) { + // 0x7C00 = 0111 1100: SDA + DDA + cardholder-verif + terminal-risk + + // issuer-auth — a common offline-DDA credit card. + a, err := DecodeAIPHex("7C00") + if err != nil { + t.Fatal(err) + } + full := a.SDA && a.DDA && a.CardholderVerification && a.TerminalRiskManagement && a.IssuerAuthentication + if !full { + t.Errorf("7C00: expected SDA+DDA+CV+TRM+IssuerAuth, got %+v", a) + } + if a.OnDeviceCVM || a.CDA { + t.Errorf("7C00: did not expect OnDeviceCVM/CDA, got %+v", a) + } + if a.OfflineDataAuthentication != "DDA" { + t.Errorf("7C00: ODA headline = %q, want DDA", a.OfflineDataAuthentication) + } + if len(a.Capabilities) != 5 { + t.Errorf("7C00: want 5 capabilities, got %d: %v", len(a.Capabilities), a.Capabilities) + } + + // 0x1800 = 0001 1000: cardholder-verif + terminal-risk, NO offline auth + // → online-only card. + b, err := DecodeAIPHex("1800") + if err != nil { + t.Fatal(err) + } + if b.SDA || b.DDA || b.CDA { + t.Errorf("1800: expected no offline auth, got %+v", b) + } + if b.OfflineDataAuthentication != "none advertised — card relies on online authorization" { + t.Errorf("1800: ODA headline = %q", b.OfflineDataAuthentication) + } + + // 0x0500 = 0000 0101: issuer-auth + CDA. CDA must win the ODA headline. + c, err := DecodeAIPHex("0500") + if err != nil { + t.Fatal(err) + } + if !c.CDA || !c.IssuerAuthentication { + t.Errorf("0500: expected CDA+IssuerAuth, got %+v", c) + } + if c.OfflineDataAuthentication != "CDA (strongest — combined DDA + cryptogram)" { + t.Errorf("0500: ODA headline = %q, want CDA", c.OfflineDataAuthentication) + } +} + +// Byte 1 bit 8 and any non-zero byte 2 are RFU in the contact profile and must +// be surfaced as a note, never silently interpreted. +func TestDecodeAIP_RFUNotes(t *testing.T) { + a, err := DecodeAIPHex("8040") + if err != nil { + t.Fatal(err) + } + if len(a.Notes) != 2 { + t.Errorf("8040: expected 2 RFU notes (byte1 bit8 + byte2), got %d: %v", len(a.Notes), a.Notes) + } + + // Clean SDA card: no RFU bits set → no notes. + clean, err := DecodeAIPHex("4000") + if err != nil { + t.Fatal(err) + } + if len(clean.Notes) != 0 { + t.Errorf("4000: expected no notes, got %v", clean.Notes) + } +} + +func TestDecodeAIP_Rejects(t *testing.T) { + for _, bad := range []string{"", "82", "7C0000", "zz"} { + if _, err := DecodeAIPHex(bad); err == nil { + t.Errorf("DecodeAIPHex(%q): expected error, got nil", bad) + } + } +} diff --git a/internal/risk/risk.go b/internal/risk/risk.go index 58e567a0..d145d4e4 100644 --- a/internal/risk/risk.go +++ b/internal/risk/risk.go @@ -130,6 +130,11 @@ var toolLevels = func() map[string]Level { "nfc_emv_track2_decode", "nfc_emv_dol_decode", "nfc_emv_afl_decode", + // AIP (tag 82) bitfield decode: the card's advertised + // authentication capabilities (SDA/DDA/CDA, cardholder/issuer + // auth). Offline read of operator-supplied bytes; transmits + // nothing, so it is Low like the rest of the EMV decode family. + "nfc_emv_aip_decode", // ISO 15693 vicinity-card UID/AFI decode (the second major HF // NFC standard). Offline deterministic read of an // operator-supplied UID; transmits nothing, so it is Low. diff --git a/internal/tools/emv_aip.go b/internal/tools/emv_aip.go new file mode 100644 index 00000000..e17410ad --- /dev/null +++ b/internal/tools/emv_aip.go @@ -0,0 +1,87 @@ +// emv_aip.go — host-side EMV Application Interchange Profile (tag 82) decoder +// Spec, delegating to internal/emv.DecodeAIP. +// +// Wrap-vs-native: native — the AIP is a fixed 2-byte EMV Book 3 Annex C1 +// bitfield in which the card advertises its authentication capabilities +// (SDA/DDA/CDA, cardholder verification, issuer authentication). nfc_emv_decode's +// BER-TLV walker surfaces tag 82's value raw; this cracks it into the named +// capability bits and the single most security-relevant takeaway — which (if +// any) offline data-authentication method the card supports. The bit names come +// from the published EMV table with raw bytes always surfaced. Offline read, no +// hardware. + +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/xunholy/promptzero/internal/emv" + "github.com/xunholy/promptzero/internal/risk" +) + +func init() { //nolint:gochecknoinits + Register(emvAIPDecodeSpec) +} + +var emvAIPDecodeSpec = Spec{ + Name: "nfc_emv_aip_decode", + Description: "Decode an EMV Application Interchange Profile (tag 82) — the 2-byte bitfield in which a " + + "payment card advertises which authentication and verification capabilities it supports. This is " + + "the single most security-relevant field on an EMV card dump: it tells you the card's offline " + + "data-authentication method (SDA / DDA / CDA), whether cardholder verification and issuer " + + "authentication are supported, and whether terminal risk management is requested. " + + "nfc_emv_decode's BER-TLV walker surfaces tag 82's value raw; this cracks it.\n\n" + + "Byte 1's seven defined bits are decoded per EMV Book 3, Annex C1: SDA (0x40), DDA (0x20), " + + "cardholder verification (0x10), terminal risk management (0x08), issuer authentication (0x04), " + + "on-device cardholder verification (0x02, EMV 4.3+) and CDA (0x01). The offline_data_authentication " + + "headline picks the strongest method present (CDA > DDA > SDA), flagging SDA-only cards as " + + "replayable/clone-prone and no-offline-auth cards as online-only. Byte 1 bit 8 and the whole of " + + "byte 2 are RFU in the contact profile (contactless kernels repurpose byte 2) and are surfaced " + + "raw with a note rather than guessed — no confidently-wrong output. Gated structurally: exactly " + + "2 bytes.\n\n" + + "Pass the AIP value hex directly (tag 82's value_hex from nfc_emv_decode); a full EMV BER-TLV " + + "blob is also accepted and tag 82 is extracted automatically. Offline transform — reads bytes, " + + "transmits nothing, so it is Low risk. Wrap-vs-native: native — fixed-layout bit decode + a " + + "bounded EMV name table.", + Schema: json.RawMessage(`{ + "type":"object", + "properties":{ + "hex":{"type":"string","description":"AIP value hex (tag 82's 2-byte value, e.g. \"7C00\"), or a full EMV BER-TLV blob containing tag 82. Accepts ':' / '-' / '_' / whitespace separators."} + }, + "required":["hex"] + }`), + Required: []string{"hex"}, + Risk: risk.Low, + Group: GroupHostTools, + AgentOnly: false, + Handler: emvAIPDecodeHandler, +} + +func emvAIPDecodeHandler(_ context.Context, _ *Deps, p map[string]any) (string, error) { + raw := str(p, "hex") + if strings.TrimSpace(raw) == "" { + return "", fmt.Errorf("nfc_emv_aip_decode: 'hex' is required") + } + source := "aip" + res, err := emv.DecodeAIPHex(raw) + if err != nil { + // Fall back to treating the input as a full TLV blob and pulling tag 82. + if tlvs, perr := emv.Parse(raw); perr == nil { + if v, ok := searchTag(tlvs, 0x82); ok { + source = "tag82-extracted" + res, err = emv.DecodeAIP(v) + } + } + } + if err != nil { + return "", fmt.Errorf("nfc_emv_aip_decode: %w", err) + } + out, _ := json.MarshalIndent(map[string]any{ + "source": source, + "aip": res, + }, "", " ") + return string(out), nil +} diff --git a/internal/tools/registry_size_test.go b/internal/tools/registry_size_test.go index 0c32e458..6193257c 100644 --- a/internal/tools/registry_size_test.go +++ b/internal/tools/registry_size_test.go @@ -2969,7 +2969,13 @@ func TestRegistrySize(t *testing.T) { // synonym map, so an operator finds a tool by task ("garage door", "wifi // password") instead of guessing its exact name. Available on every surface // that reaches the registry (CLI/Web/MCP). Read-only (risk.Low). - const expected = 676 + // v0.717.0 added nfc_emv_aip_decode (EMV Application Interchange Profile, + // tag 82 → the card's advertised authentication capabilities: SDA/DDA/CDA + // offline data authentication, cardholder verification, issuer + // authentication, terminal risk management; byte-1 bits per EMV Book 3 + // Annex C1, byte 2 surfaced raw as RFU/kernel-specific). Extends the + // nfc_emv_* decode family alongside cvm/dol/afl/track2. internal/emv. + const expected = 677 if initialRegistrySize != expected { t.Errorf("registry names at init = %d, want %d (wave-by-wave checked in §D of runbook)", initialRegistrySize, expected)