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
2 changes: 1 addition & 1 deletion docs/catalog/gap-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | — | — | — |
Expand Down
118 changes: 118 additions & 0 deletions internal/emv/aip.go
Original file line number Diff line number Diff line change
@@ -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)
}
112 changes: 112 additions & 0 deletions internal/emv/aip_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 5 additions & 0 deletions internal/risk/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions internal/tools/emv_aip.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 7 additions & 1 deletion internal/tools/registry_size_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading