Add bambu-nfc dump parse, Key-A HKDF, and spool merge.

Parse filanfc-dump/v1 zip/JSON, Bambu 1K images, and merge stickers by tray UID. dump and mifare packages stay importable for a later common library.
This commit is contained in:
2026-08-23 15:23:11 -05:00
parent a794db4838
commit b38f7f643b
29 changed files with 893 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
// Package dump reads brand-blind filanfc-dump/v1 JSON and zip archives.
package dump
import (
"archive/zip"
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"git.s1d3sw1ped.com/s1d3sw1ped/bambu-nfc/mifare"
)
var (
ErrInvalidDump = errors.New("invalid dump")
ErrImageSize = errors.New("image size")
)
const FormatV1 = "filanfc-dump/v1"
// Dump is one sticker: chip UID plus a MIFARE Classic 1K image.
type Dump struct {
Format string
ScannedAt time.Time
ChipUID string
SAK string
ATQA string
Tech []string
SectorsOK []int
SectorsFail []int
Image []byte
}
type fileDump struct {
Format string `json:"format"`
ScannedAt string `json:"scanned_at"`
ChipUID string `json:"chip_uid"`
SAK string `json:"sak"`
ATQA string `json:"atqa"`
Tech []string `json:"tech"`
SectorsOK []int `json:"sectors_ok"`
SectorsFail []int `json:"sectors_fail"`
DumpHex string `json:"dump_hex"`
DumpB64 string `json:"dump_b64"`
}
// ParseJSON reads one filanfc-dump/v1 object.
func ParseJSON(r io.Reader) (Dump, error) {
var raw fileDump
dec := json.NewDecoder(r)
if err := dec.Decode(&raw); err != nil {
return Dump{}, fmt.Errorf("%w: %v", ErrInvalidDump, err)
}
return parseRaw(raw)
}
func parseRaw(raw fileDump) (Dump, error) {
if raw.Format != FormatV1 {
return Dump{}, fmt.Errorf("%w: format %q", ErrInvalidDump, raw.Format)
}
uid := strings.ToLower(strings.TrimSpace(raw.ChipUID))
if uid == "" || len(uid)%2 != 0 {
return Dump{}, fmt.Errorf("%w: chip_uid %q", ErrInvalidDump, raw.ChipUID)
}
if _, err := hex.DecodeString(uid); err != nil {
return Dump{}, fmt.Errorf("%w: chip_uid: %v", ErrInvalidDump, err)
}
var imgHex, imgB64 []byte
var err error
if raw.DumpHex != "" {
imgHex, err = hex.DecodeString(raw.DumpHex)
if err != nil {
return Dump{}, fmt.Errorf("%w: dump_hex: %v", ErrInvalidDump, err)
}
}
if raw.DumpB64 != "" {
imgB64, err = base64.StdEncoding.DecodeString(raw.DumpB64)
if err != nil {
return Dump{}, fmt.Errorf("%w: dump_b64: %v", ErrInvalidDump, err)
}
}
var image []byte
switch {
case len(imgHex) != 0 && len(imgB64) != 0:
if !bytes.Equal(imgHex, imgB64) {
return Dump{}, fmt.Errorf("%w: dump_hex and dump_b64 disagree", ErrInvalidDump)
}
image = imgHex
case len(imgHex) != 0:
image = imgHex
case len(imgB64) != 0:
image = imgB64
default:
return Dump{}, fmt.Errorf("%w: missing image", ErrInvalidDump)
}
if len(image) != mifare.Size1K {
return Dump{}, fmt.Errorf("%w: want %d got %d", ErrImageSize, mifare.Size1K, len(image))
}
var scanned time.Time
if raw.ScannedAt != "" {
scanned, err = time.Parse(time.RFC3339Nano, raw.ScannedAt)
if err != nil {
scanned, err = time.Parse(time.RFC3339, raw.ScannedAt)
if err != nil {
return Dump{}, fmt.Errorf("%w: scanned_at: %v", ErrInvalidDump, err)
}
}
}
return Dump{
Format: raw.Format,
ScannedAt: scanned,
ChipUID: uid,
SAK: raw.SAK,
ATQA: raw.ATQA,
Tech: raw.Tech,
SectorsOK: raw.SectorsOK,
SectorsFail: raw.SectorsFail,
Image: image,
}, nil
}
// ParseZip reads every {chip_uid}.json entry in a dump zip.
func ParseZip(r io.Reader) ([]Dump, error) {
body, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("%w: read zip: %v", ErrInvalidDump, err)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
return nil, fmt.Errorf("%w: zip: %v", ErrInvalidDump, err)
}
var dumps []Dump
for _, f := range zr.File {
if !strings.HasSuffix(strings.ToLower(f.Name), ".json") {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("%w: open %s: %v", ErrInvalidDump, f.Name, err)
}
d, err := ParseJSON(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidDump, f.Name, err)
}
dumps = append(dumps, d)
}
if len(dumps) == 0 {
return nil, fmt.Errorf("%w: zip has no json entries", ErrInvalidDump)
}
return dumps, nil
}
+58
View File
@@ -0,0 +1,58 @@
package dump
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseJSONFixture(t *testing.T) {
t.Parallel()
f, err := os.Open(filepath.Join("..", "testdata", "dumps", "e27be276.json"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
d, err := ParseJSON(f)
if err != nil {
t.Fatal(err)
}
if d.ChipUID != "e27be276" {
t.Errorf("ChipUID = %q", d.ChipUID)
}
if d.Format != FormatV1 {
t.Errorf("Format = %q", d.Format)
}
if len(d.Image) != 1024 {
t.Errorf("Image len = %d", len(d.Image))
}
if len(d.SectorsFail) != 0 {
t.Errorf("SectorsFail = %v", d.SectorsFail)
}
}
func TestParseZipFixture(t *testing.T) {
t.Parallel()
f, err := os.Open(filepath.Join("..", "testdata", "dumps.zip"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
dumps, err := ParseZip(f)
if err != nil {
t.Fatal(err)
}
if len(dumps) != 6 {
t.Errorf("len = %d; want 6", len(dumps))
}
}
func TestParseJSONRejectsBadFormat(t *testing.T) {
t.Parallel()
_, err := ParseJSON(strings.NewReader(`{"format":"nope","chip_uid":"aa","dump_hex":"` + strings.Repeat("00", 1024) + `"}`))
if !errors.Is(err, ErrInvalidDump) {
t.Fatalf("err = %v; want ErrInvalidDump", err)
}
}