diff --git a/docs/catalog/gap-analysis.md b/docs/catalog/gap-analysis.md index d92609d2..eddd58bc 100644 --- a/docs/catalog/gap-analysis.md +++ b/docs/catalog/gap-analysis.md @@ -97,7 +97,7 @@ audit (`docs/refactor/v0.8-team-audit.md`). | 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). | | 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_raw_analyze` | — | ✅ pm3 | **NEW** ⟶ `rfid_pacs_decode` | +| 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) | — | — | — | | FDX-B / DCF77 / niche LF synth | apps NFC | ⚠️ `rfid_build` covers EM4100 only; **FDX-B offline *decode* shipped v0.464.0 (`fdxb_decode`)**, DCF77 telegram-synth v0.375 | — | — | Low-priority gaps (LF *synth*/build side remains EM4100-only) | | UHF EPC Gen2 (M6E-Nano) | apps `uhf_rfid` | ⚠️ | — | — | Adjacent-HW gap for *reading*; **offline EPC *decode* shipped v0.468.0 (`epc_decode`, SGTIN-96)** | diff --git a/internal/tools/decoder_fuzz_safety_test.go b/internal/tools/decoder_fuzz_safety_test.go index 9ac360a2..ed714be2 100644 --- a/internal/tools/decoder_fuzz_safety_test.go +++ b/internal/tools/decoder_fuzz_safety_test.go @@ -197,6 +197,122 @@ func TestTextDecoders_MalformedInputNeverPanics(t *testing.T) { t.Logf("panic-safety swept %d text decoders", tested) } +// isMultiParamHostDecoder reports whether a Spec is a pure offline host +// tool with two or more required params — the family the single-param hex +// and text guards above deliberately skip (each requires exactly one +// param, so the multi-param encoders, KDFs and hashcat-line formatters +// fall through both). Encoders (rfid_pacs_encode, ioprox_encode, +// dcf77_synth), KDFs (wpa_pmk_derive, hmac_compute, postgres_password) +// and the wifi_*_hc22000 formatters all live here: each parses structured +// hex / string / integer fields an operator may paste truncated or +// malformed, and none may panic. GroupHostTools + risk.Low keeps the +// sweep to host-only tools that ignore Deps, so a nil Deps is safe (the +// same exclusion the single-param guards rely on). +func isMultiParamHostDecoder(s Spec) bool { + return s.Handler != nil && + s.Group == GroupHostTools && + s.Risk == risk.Low && + len(s.Required) >= 2 +} + +// malformedByType returns degenerate values appropriate to a required +// param's JSON-schema type. Numeric params get boundary and overflow +// values (a count field trusted before allocation/slicing); everything +// else is treated as a string and fed the truncated-hex / empty / +// stray-separator battery that has historically tripped length-field +// parsers. +func malformedByType(jsonType string) []any { + switch jsonType { + case "integer", "number": + return []any{0, 1, -1, -2147483648, 2147483647, 4294967296, 9999999999, 64, 256} + default: + return []any{"", " ", "0", "zz", "::::", "ffffffffffffffff", "\x00\xff", "...", "-1", + strings.Repeat("f", 9000)} + } +} + +// TestMultiParamDecoders_MalformedInputNeverPanics extends the panic-safety +// net to every pure offline host tool with two or more required params — +// the gap the single-param hex/text guards leave open. For each such tool +// it sweeps one param at a time across its type's degenerate battery while +// the remaining required params hold a fixed degenerate anchor, plus an +// all-params-degenerate pass. A new multi-field encoder/decoder added +// without bounds-checking a short hex field or an out-of-range count would +// pass its own happy-path tests but trip here. +// +// Errors are expected and fine — the contract is "return an error or a +// benign Result, never panic". +func TestMultiParamDecoders_MalformedInputNeverPanics(t *testing.T) { + tested := 0 + for _, s := range All() { + if !isMultiParamHostDecoder(s) { + continue + } + var parsed struct { + Properties map[string]struct { + Type string `json:"type"` + } `json:"properties"` + } + if err := json.Unmarshal(s.Schema, &parsed); err != nil { + continue + } + tested++ + + // A fixed type-appropriate anchor for the params we are not + // currently varying, so the handler clears each param's early + // validation and reaches the deeper field-parsing code with a + // single hostile value at a time. + anchor := func(p string) any { + if parsed.Properties[p].Type == "integer" || parsed.Properties[p].Type == "number" { + return 1 + } + return "ffffffffffffffff" + } + + call := func(args map[string]any) { + defer func() { + if r := recover(); r != nil { + t.Errorf("%s panicked on args=%v: %v", s.Name, args, r) + } + }() + _, _ = s.Handler(context.Background(), nil, args) + } + + // Sweep each required param across its battery, anchoring the rest. + for _, target := range s.Required { + for _, bad := range malformedByType(parsed.Properties[target].Type) { + args := map[string]any{} + for _, p := range s.Required { + args[p] = anchor(p) + } + args[target] = bad + call(args) + } + } + + // All params degenerate at once: empty strings / zero ints, then + // max-ish ints / long hex. + for _, mode := range []int{0, 1} { + args := map[string]any{} + for _, p := range s.Required { + b := malformedByType(parsed.Properties[p].Type) + if mode == 0 { + args[p] = b[0] // "" or 0 + } else { + args[p] = b[len(b)-1] // long-hex or 9999999999 + } + } + call(args) + } + } + // Sanity floor: the multi-param host family is ~15 tools today. If + // this collapses the filter regressed and the guard is inert. + if tested < 10 { + t.Errorf("only %d multi-param host decoders exercised; expected 10+ — filter may have regressed", tested) + } + t.Logf("panic-safety swept %d multi-param host decoders", tested) +} + // hexDecoderHandlers caches the pure hex-decoder handlers once so the // fuzz body doesn't re-scan the registry on every input. func hexDecoderHandlers() []Spec {