Files
bambu-nfc/dump/dump.go
T
s1d3sw1ped b38f7f643b 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.
2026-08-23 15:23:11 -05:00

161 lines
4.0 KiB
Go

// 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
}