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 @@ -99,7 +99,7 @@ audit (`docs/refactor/v0.8-team-audit.md`).
| 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) | — | — | — |
| 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) |
| FDX-B / DCF77 / niche LF synth | apps NFC | ⚠️ `rfid_build` covers EM4100 only; **FDX-B offline *decode* shipped v0.464.0 (`fdxb_decode`) + *encode* v0.723.0 (`fdxb_encode`, the inverse generator: national+country+flags+CRC-16, round-trip-verified)**, DCF77 telegram-synth v0.375 | — | — | FDX-B decode+encode shipped; remaining LF *synth* gaps (Oregon/niche) low-priority |
| UHF EPC Gen2 (M6E-Nano) | apps `uhf_rfid` | ⚠️ | — | — | Adjacent-HW gap for *reading*; **offline EPC *decode* shipped v0.468.0 (`epc_decode`, SGTIN-96)** |
| Mag-stripe wireless emulation (MagSpoof) | apps top-20 #9 | ⚠️ partial | — | — | offline half ✅ `magstripe_decode` (v0.453 — ISO 7813 Track 1/2 ASCII swipe parser); the wireless-coil TX emulation remains a hardware loader step |

Expand Down
85 changes: 85 additions & 0 deletions internal/fdxb/encode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package fdxb

import (
"encoding/hex"
"fmt"
"strings"
)

// maxNationalCode is the largest 38-bit national identification number.
const maxNationalCode = (uint64(1) << 38) - 1

// maxCountryCode is the largest 10-bit country code.
const maxCountryCode = (1 << 10) - 1

// Encode builds the 10-byte FDX-B ID+CRC data block (the 8-byte ID block plus
// the 2-byte CRC-16) from a national identification number and country code,
// with optional animal-application and extended-data-block flags. It is the
// exact inverse of Decode: the same LSB-first field layout (national 38 bits at
// offset 0, country 10 bits at offset 38, the two flag bits at 48/49, the
// reserved bits 50..63 left zero) packed MSB-first into bytes, then the same
// crc16FDXB over the first 8 bytes appended LSB-first.
//
// The 24-bit extended data block is out of scope here exactly as in Decode
// (vendor-specific); dataBlock only sets the presence flag. Reserved bits are
// emitted as zero, so the output is the canonical encoding — a real tag that
// carried vendor bits in the reserved field would round-trip its national /
// country / flags / CRC but not those raw reserved bits.
func Encode(nationalCode uint64, countryCode int, animal, dataBlock bool) ([]byte, error) {
if nationalCode > maxNationalCode {
return nil, fmt.Errorf("fdxb: national code %d exceeds the 38-bit maximum %d", nationalCode, maxNationalCode)
}
if countryCode < 0 || countryCode > maxCountryCode {
return nil, fmt.Errorf("fdxb: country code %d out of range 0..%d", countryCode, maxCountryCode)
}

bits := make([]int, 80) // 8-byte ID block + 2-byte CRC
writeLSBF(bits, 0, 38, nationalCode)
writeLSBF(bits, 38, 10, uint64(countryCode))
if dataBlock {
bits[48] = 1
}
if animal {
bits[49] = 1
}
// bits 50..63 reserved — left zero.

idBlock := bitsToBytesMSBF(bits[:64])
crc := crc16FDXB(idBlock)
writeLSBF(bits, 64, 16, uint64(crc))
return bitsToBytesMSBF(bits), nil
}

// EncodeHex is the hex-string convenience wrapper, returning the upper-case hex
// of the 10-byte block.
func EncodeHex(nationalCode uint64, countryCode int, animal, dataBlock bool) (string, error) {
b, err := Encode(nationalCode, countryCode, animal, dataBlock)
if err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(b)), nil
}

// writeLSBF writes the low n bits of v into bits[off..off+n), least-significant
// bit first — the inverse of readLSBF.
func writeLSBF(bits []int, off, n int, v uint64) {
for i := 0; i < n; i++ {
bits[off+i] = int((v >> uint(i)) & 1)
}
}

// bitsToBytesMSBF packs a bit slice (length a multiple of 8) into bytes,
// most-significant bit first within each byte — the inverse of toBitsMSBF.
func bitsToBytesMSBF(bits []int) []byte {
out := make([]byte, len(bits)/8)
for i := range out {
var x byte
for j := 0; j < 8; j++ {
x |= byte(bits[i*8+j]&1) << uint(7-j)
}
out[i] = x
}
return out
}
94 changes: 94 additions & 0 deletions internal/fdxb/encode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package fdxb

import (
"encoding/hex"
"strings"
"testing"
)

// Encode then Decode must reproduce the inputs with a valid CRC — the strongest
// verification for an inverse generator (no external vector needed; the decoder
// is the independent oracle).
func TestEncode_RoundTrip(t *testing.T) {
cases := []struct {
national uint64
country int
animal bool
dataBlk bool
}{
{140000795552, 528, false, false}, // the decoder's anchor vector
{1500030037, 999, true, false}, // the decoder's second anchor (test range)
{0, 0, false, false}, // minimum
{maxNationalCode, maxCountryCode, true, true}, // maxima + both flags
{1, 1, true, false},
{1234567890, 250, false, true},
}
for _, c := range cases {
b, err := Encode(c.national, c.country, c.animal, c.dataBlk)
if err != nil {
t.Fatalf("Encode(%d,%d): %v", c.national, c.country, err)
}
if len(b) != 10 {
t.Fatalf("Encode(%d,%d): got %d bytes, want 10", c.national, c.country, len(b))
}
got, err := Decode(b)
if err != nil {
t.Fatalf("Decode of encoded block: %v", err)
}
if got.NationalCode != c.national {
t.Errorf("national: got %d, want %d", got.NationalCode, c.national)
}
if got.CountryCode != c.country {
t.Errorf("country: got %d, want %d", got.CountryCode, c.country)
}
if got.AnimalApplication != c.animal {
t.Errorf("animal flag: got %v, want %v", got.AnimalApplication, c.animal)
}
if got.DataBlockPresent != c.dataBlk {
t.Errorf("data-block flag: got %v, want %v", got.DataBlockPresent, c.dataBlk)
}
if got.CRCValid == nil || !*got.CRCValid {
t.Errorf("CRC not valid for encoded block %X (stored %s, computed %s)", b, got.CRCStored, got.CRCComputed)
}
}
}

// The package doc anchors the decoder to a real tag: country 528 / national
// 140000795552 with ID block 05 D9 4D 19 04 21 00 01. The identity-bearing
// portion — the 38-bit national + 10-bit country (bytes 0..5) and the flag byte
// (byte 6) — must reproduce that real tag byte-for-byte. The real tag also
// carries a set reserved bit in byte 7 (bit 63, in the RFU range 50..63); the
// canonical encoder emits reserved bits as zero, so byte 7 is 00 here. This is
// a published-vector anchor on the identity bytes, on top of the full
// round-trip (national/country/flags/CRC) in TestEncode_RoundTrip.
func TestEncode_AnchorVector(t *testing.T) {
b, err := Encode(140000795552, 528, false, false)
if err != nil {
t.Fatal(err)
}
// Bytes 0..6 carry national + country + flags (all non-reserved); these
// must equal the real tag exactly.
got := strings.ToUpper(hex.EncodeToString(b[:7]))
const want = "05D94D19042100" // bytes 0..6 of 05D94D1904210001
if got != want {
t.Errorf("identity bytes = %s, want %s (the decoder's published vector, bytes 0..6)", got, want)
}
// Byte 7 is pure reserved; the canonical encoder zeroes it.
if b[7] != 0x00 {
t.Errorf("byte 7 (reserved) = 0x%02X, want 0x00 (canonical encoding zeroes reserved bits)", b[7])
}
}

func TestEncode_Rejects(t *testing.T) {
if _, err := Encode(maxNationalCode+1, 0, false, false); err == nil {
t.Error("expected error for national code exceeding 38 bits")
}
if _, err := Encode(0, maxCountryCode+1, false, false); err == nil {
t.Error("expected error for country code exceeding 10 bits")
}
if _, err := Encode(0, -1, false, false); err == nil {
t.Error("expected error for negative country code")
}
}
5 changes: 5 additions & 0 deletions internal/risk/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ var toolLevels = func() map[string]Level {
// Offline deterministic parse of an operator-supplied block;
// transmits nothing, so it is Low.
"fdxb_decode",
// fdxb_encode builds the 10-byte FDX-B ID+CRC block (the inverse
// of fdxb_decode) from a national + country code, round-trip-
// verified. Generation only — produces a block, transmits nothing
// and writes to no device, so it is Low like the decoder.
"fdxb_encode",
// IO Prox (Kantech XSF) 125 kHz LF access-control credential
// decode from its 64-bit block (facility code + version + 16-bit
// card number + 8-bit checksum, with a structural marker/separator
Expand Down
94 changes: 94 additions & 0 deletions internal/tools/fdxb_encode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// fdxb_encode.go — host-side FDX-B (ISO 11784/11785) LF data-block generator
// Spec, the inverse of fdxb_decode, delegating to internal/fdxb.Encode.
//
// Wrap-vs-native: native — fixed LSB-first bit placement in the 64-bit ID block
// + the Proxmark3 FDX-B CRC-16; stdlib only. Round-trips with fdxb_decode,
// extending the LF clone-generation set (em4100_encode / ioprox_encode /
// noralsy_encode / viking_encode / jablotron_encode / presco_encode) to the
// animal-microchip format. Offline transform, transmits nothing.

package tools

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

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

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

// fdxbMaxNational is the largest 38-bit national identification number.
const fdxbMaxNational = (1 << 38) - 1

var fdxbEncodeSpec = Spec{
Name: "fdxb_encode",
Description: "Generate the 10-byte **FDX-B (ISO 11784/11785)** LF data block — the 134.2 kHz animal / pet " +
"microchip transponder format — from a national identification number and country code. The inverse of " +
"`fdxb_decode`, extending the LF **clone-generation set** (`em4100_encode`, `ioprox_encode`, " +
"`noralsy_encode`, `viking_encode`, `jablotron_encode`, `presco_encode`) to the animal-tag format: the " +
"block is the de-stuffed ID block you would feed a transponder writer to clone an FDX-B credential for " +
"an authorized test.\n\n" +
"Builds the documented data block — the 38-bit national code (LSB-first at bit 0), the 10-bit country " +
"code (bit 38), the data-block-status and animal-application flag bits (48/49), the reserved bits " +
"(50-63, emitted as zero) — packed MSB-first into 8 bytes, then appends the 2-byte FDX-B CRC-16 (CCITT " +
"polynomial 0x1021, init 0, output-reflected, over the first 8 bytes). **No confidently-wrong output**: " +
"the layout + CRC are the same Proxmark3-referenced ones `fdxb_decode` uses, and the encoder " +
"**round-trips** with it (decoding the emitted block reproduces the national / country / flags with a " +
"valid CRC) and reproduces the decoder's published real-tag vector on the identity bytes (country 528 / " +
"national 140000795552 → ID bytes `05D94D19042100…`). The on-air framing (11-bit preamble + the control " +
"'1' after every 8 bits) is the transponder writer's concern and out of scope, exactly as on the decode " +
"side. Produces the block only — it transmits nothing and writes to no device, so it is Low risk.\n\n" +
"Inputs: **national_code** (0-274877906943, 38 bits), **country_code** (0-1023, 10 bits), optional " +
"**animal_application** (bool, the animal-tag flag) and **data_block** (bool, the extended-block-present " +
"flag; the 24-bit extended block itself is vendor-specific and out of scope). Wrap-vs-native: native — " +
"fixed bit placement + a CRC-16, stdlib only, no new go.mod dep.",
Schema: json.RawMessage(`{
"type":"object",
"properties":{
"national_code":{"type":"integer","description":"38-bit national identification number (0-274877906943)."},
"country_code":{"type":"integer","description":"10-bit country code (0-1023; 900-999 are manufacturer/test ranges)."},
"animal_application":{"type":"boolean","description":"Set the animal-application flag (bit 49). Default false."},
"data_block":{"type":"boolean","description":"Set the extended-data-block-present flag (bit 48). Default false; the 24-bit extended block itself is out of scope."}
},
"required":["national_code","country_code"]
}`),
Required: []string{"national_code", "country_code"},
Risk: risk.Low,
Group: GroupHostTools,
AgentOnly: false,
Handler: fdxbEncodeHandler,
}

func fdxbEncodeHandler(_ context.Context, _ *Deps, p map[string]any) (string, error) {
national, err := intField(p, "national_code", 0, fdxbMaxNational)
if err != nil {
return "", fmt.Errorf("fdxb_encode: %w", err)
}
country, err := intField(p, "country_code", 0, 1023)
if err != nil {
return "", fmt.Errorf("fdxb_encode: %w", err)
}
animal := boolOr(p, "animal_application", false)
dataBlock := boolOr(p, "data_block", false)

block, err := fdxb.EncodeHex(uint64(national), country, animal, dataBlock)
if err != nil {
return "", fmt.Errorf("fdxb_encode: %w", err)
}
out := map[string]any{
"format": "FDX-B (ISO 11784/11785)",
"national_code": national,
"country_code": country,
"animal_application": animal,
"data_block_present": dataBlock,
"id_crc_block_hex": block,
"note": "de-stuffed 10-byte ID+CRC block (8-byte ID + 2-byte CRC-16); on-air preamble/bit-stuffing is the writer's concern. Generation only — transmits nothing.",
}
b, _ := json.MarshalIndent(out, "", " ")
return string(b), nil
}
7 changes: 6 additions & 1 deletion internal/tools/registry_size_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2992,7 +2992,12 @@ func TestRegistrySize(t *testing.T) {
// cardholder verification, card/terminal risk management, issuer auth, script
// processing; 6 bits per EMV Book 3 Annex C6, RFU surfaced raw). Completes the
// EMV transaction-outcome trio with aip (tag 82) + tvr (tag 95). internal/emv.
const expected = 680
// v0.723.0 added fdxb_encode (FDX-B ISO 11784/11785 animal-microchip LF data
// block generator → inverse of fdxb_decode: national + country code + flags
// packed LSB-first with the Proxmark3 CRC-16, round-trip-verified; extends the
// LF clone-generation set em4100/ioprox/noralsy/viking/jablotron/presco_encode).
// internal/fdxb.
const expected = 681
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