From 5fcc106a3aa9da02d4a0a7e67fca0db29b7a204c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 07:12:44 +0000 Subject: [PATCH] Accept Proxmark dump uploads, mfc v2 JSON, and key.bin Parse object-shaped mfc v2 dumps (the format Proxmark writes today) and let the new-tag form upload dump.json/dump.bin plus an optional binary hf-mf-*-key.bin instead of only pasting text. --- README.md | 25 +- src/app/api/v1/tags/import/route.ts | 146 ++++++-- src/app/tags/new/page.tsx | 270 +++++++++++++-- src/lib/rfid/parsers/index.ts | 428 ++++++++++++++++++++++-- src/lib/validation/rfid.ts | 10 +- tests/fixtures/hf-mf-91A2F020-dump.json | 269 +++++++++++++++ tests/integration/api.test.ts | 34 ++ tests/unit/parsers.test.ts | 93 ++++- 8 files changed, 1184 insertions(+), 91 deletions(-) create mode 100644 tests/fixtures/hf-mf-91A2F020-dump.json diff --git a/README.md b/README.md index da1e5a3..37c140b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PWA + REST API for storing LF/HF RFID tag dumps organized by site. Use the web U - Multi-user auth (shared workspace, no RBAC yet) — local password + optional OIDC - Personal API tokens (Settings UI) for Bearer auth - Export: JSON, Proxmark, MCT, hex -- Import dumps + full JSON backup +- Import dumps (file upload or paste) + full JSON backup - Installable PWA with offline read of cached pages - Vitest unit/integration tests + GitHub Actions CI @@ -102,7 +102,7 @@ Authenticate with a session cookie **or** `Authorization: Bearer rfid_…` (crea | GET/PATCH/DELETE | `/api/v1/tags/{id}` | Tag detail | | PUT | `/api/v1/tags/by-uid/{uid}?siteId=` | Upsert by UID | | GET | `/api/v1/tags/{id}/export?format=` | `json` \| `proxmark` \| `mct` \| `hex` | -| POST | `/api/v1/tags/import` | Parse dump → create tag | +| POST | `/api/v1/tags/import` | Parse dump → create tag (JSON body or multipart files) | | GET | `/api/v1/search?q=` | Cross-site search | | GET/POST | `/api/v1/backup` | Full backup export / import | | GET/POST | `/api/v1/tokens` | List / create PATs | @@ -124,8 +124,29 @@ curl -sH "Authorization: Bearer $TOKEN" \ curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -X PUT "$HOST/api/v1/tags/by-uid/04A1B2C3?siteId=$SITE" \ -d '{"label":"Dock fob","frequency":"HF","protocol":"MIFARE_CLASSIC_1K","uid":"04A1B2C3","dumpData":{"size":"1K","sectors":[]},"keys":{"A":["FFFFFFFFFFFF"]}}' + +# Import a Proxmark dump.json (optional key.bin is binary — use multipart) +curl -sH "Authorization: Bearer $TOKEN" \ + -F "siteId=$SITE" \ + -F "label=Front door" \ + -F "dump=@hf-mf-91A2F020-dump.json" \ + -F "keys=@hf-mf-91A2F020-key.bin" \ + "$HOST/api/v1/tags/import" ``` +### Proxmark files + +On **New tag → Import dump** you can upload files or paste JSON: + +| File | Typical Proxmark name | Notes | +|------|------------------------|--------| +| Dump JSON | `hf-mf--dump.json` | `FileType` `mfc v2` (blocks as an object + `SectorKeys`) or older `mfcard` (blocks array) | +| Binary dump | `hf-mf-.bin` / `dump.bin` | 1024 bytes (1K) or 4096 bytes (4K) | +| Keys | `hf-mf--key.bin` | **Binary**, 12 bytes/sector (192 for 1K, 480 for 4K). Not needed if the JSON already has `SectorKeys`. | +| MCT / EML | `*.mct` / `*.eml` | Text dumps | + +Pasting `mfc v2` JSON is supported; older parsers rejected object-shaped `blocks`. + ## Tests & CI ```bash diff --git a/src/app/api/v1/tags/import/route.ts b/src/app/api/v1/tags/import/route.ts index d7343a1..89239b6 100644 --- a/src/app/api/v1/tags/import/route.ts +++ b/src/app/api/v1/tags/import/route.ts @@ -3,36 +3,62 @@ import { eq } from "drizzle-orm"; import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard"; import { getDb } from "@/db/client"; import { sites, tagRecords } from "@/db/schema"; -import { parseImport } from "@/lib/rfid/parsers"; +import { bytesFromBase64, parseDump } from "@/lib/rfid/parsers"; import { z } from "zod"; -const importMetaSchema = z.object({ - siteId: z.string().uuid(), - label: z.string().min(1).max(200).optional(), - notes: z.string().max(5000).nullable().optional(), - content: z.string().min(1), - filename: z.string().optional(), -}); +const importMetaSchema = z + .object({ + siteId: z.string().uuid(), + label: z.string().min(1).max(200).optional(), + notes: z.string().max(5000).nullable().optional(), + content: z.string().optional(), + contentBase64: z.string().optional(), + filename: z.string().optional(), + keysBase64: z.string().optional(), + keysFilename: z.string().optional(), + }) + .refine((d) => Boolean(d.content?.trim() || d.contentBase64?.trim()), { + message: "Provide dump content or an uploaded dump file", + }); + +type ImportFields = { + siteId: string; + label?: string; + notes?: string | null; + text?: string; + filename?: string; + bytes?: Uint8Array; + keysFilename?: string; + keysBytes?: Uint8Array; +}; export async function POST(request: NextRequest) { const userOrRes = await requireApiUser(request); if (userOrRes instanceof Response) return userOrRes; - let body: unknown; + let fields: ImportFields; try { - body = await request.json(); - } catch { - return jsonError(400, "INVALID_JSON", "Request body must be JSON"); + fields = await readImportFields(request); + } catch (e) { + if (e instanceof SyntaxError) { + return jsonError(400, "INVALID_JSON", "Request body must be JSON or multipart form data"); + } + return jsonError(400, "VALIDATION_ERROR", (e as Error).message); } - const parsed = importMetaSchema.safeParse(body); - if (!parsed.success) { - return jsonError(400, "VALIDATION_ERROR", parsed.error.message); + if (!fields.text?.trim() && !(fields.bytes && fields.bytes.length > 0)) { + return jsonError(400, "VALIDATION_ERROR", "Provide dump content or an uploaded dump file"); } let imported; try { - imported = parseImport(parsed.data.content, parsed.data.filename); + imported = parseDump({ + text: fields.text, + filename: fields.filename, + bytes: fields.bytes, + keysFilename: fields.keysFilename, + keysBytes: fields.keysBytes, + }); } catch (e) { return jsonError(400, "PARSE_ERROR", (e as Error).message); } @@ -41,26 +67,25 @@ export async function POST(request: NextRequest) { const site = db .select() .from(sites) - .where(eq(sites.id, parsed.data.siteId)) + .where(eq(sites.id, fields.siteId)) .get(); if (!site) return jsonError(404, "NOT_FOUND", "Site not found"); const id = crypto.randomUUID(); const now = new Date(); - const label = - parsed.data.label || imported.label || `Imported ${imported.uid}`; + const label = fields.label || imported.label || `Imported ${imported.uid}`; db.insert(tagRecords) .values({ id, - siteId: parsed.data.siteId, + siteId: fields.siteId, label, frequency: imported.frequency, protocol: imported.protocol, uid: imported.uid, dumpData: imported.dumpData, keys: imported.keys ?? null, - notes: parsed.data.notes ?? imported.notes ?? null, + notes: fields.notes ?? imported.notes ?? null, createdById: userOrRes.id, updatedById: userOrRes.id, lastWrittenAt: null, @@ -81,9 +106,88 @@ export async function POST(request: NextRequest) { dumpData: row.dumpData, keys: row.keys, notes: row.notes, + lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }, 201 ); } + +async function readImportFields(request: NextRequest): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("multipart/form-data")) { + return readMultipart(await request.formData()); + } + + const body: unknown = await request.json(); + const parsed = importMetaSchema.safeParse(body); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? parsed.error.message); + } + + return { + siteId: parsed.data.siteId, + label: parsed.data.label, + notes: parsed.data.notes, + text: parsed.data.content, + filename: parsed.data.filename, + bytes: parsed.data.contentBase64 + ? bytesFromBase64(parsed.data.contentBase64) + : undefined, + keysFilename: parsed.data.keysFilename, + keysBytes: parsed.data.keysBase64 + ? bytesFromBase64(parsed.data.keysBase64) + : undefined, + }; +} + +async function readMultipart(form: FormData): Promise { + const siteId = String(form.get("siteId") ?? ""); + if (!z.string().uuid().safeParse(siteId).success) { + throw new Error("siteId must be a UUID"); + } + + const labelRaw = form.get("label"); + const notesRaw = form.get("notes"); + const pasted = form.get("content"); + const dump = form.get("dump") ?? form.get("file"); + const keys = form.get("keys"); + + let text = typeof pasted === "string" ? pasted : undefined; + let filename: string | undefined; + let bytes: Uint8Array | undefined; + + if (dump instanceof File && dump.size > 0) { + filename = dump.name; + const buf = new Uint8Array(await dump.arrayBuffer()); + const lower = dump.name.toLowerCase(); + if (lower.endsWith(".bin") || lower.endsWith(".dump")) { + bytes = buf; + } else { + text = new TextDecoder().decode(buf); + } + } + + let keysFilename: string | undefined; + let keysBytes: Uint8Array | undefined; + if (keys instanceof File && keys.size > 0) { + keysFilename = keys.name; + keysBytes = new Uint8Array(await keys.arrayBuffer()); + } + + const label = typeof labelRaw === "string" && labelRaw.trim() ? labelRaw.trim() : undefined; + const notes = + typeof notesRaw === "string" ? (notesRaw.trim() ? notesRaw : null) : undefined; + + return { + siteId, + label, + notes, + text, + filename, + bytes, + keysFilename, + keysBytes, + }; +} diff --git a/src/app/tags/new/page.tsx b/src/app/tags/new/page.tsx index 7767226..9525144 100644 --- a/src/app/tags/new/page.tsx +++ b/src/app/tags/new/page.tsx @@ -1,10 +1,13 @@ "use client"; import Link from "next/link"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, Suspense, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense } from "react"; import { PROTOCOLS, LF_PROTOCOLS, type Protocol } from "@/lib/rfid/protocols"; +import { formatUid } from "@/lib/rfid/uid"; +import { isKeyBinFilename, parseDump } from "@/lib/rfid/parsers"; +import { isMifareClassicDump } from "@/lib/rfid/dump-types"; +import type { CanonicalTag } from "@/lib/rfid/types"; type Site = { id: string; name: string; code: string }; @@ -19,8 +22,13 @@ function NewTagForm() { const [dumpText, setDumpText] = useState(""); const [keysText, setKeysText] = useState(""); const [notes, setNotes] = useState(""); - const [importMode, setImportMode] = useState(false); + const [importMode, setImportMode] = useState(true); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [dumpFile, setDumpFile] = useState(null); + const [keysFile, setKeysFile] = useState(null); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); const frequency = useMemo( () => (LF_PROTOCOLS.includes(protocol) ? "LF" : "HF"), @@ -36,6 +44,40 @@ function NewTagForm() { }); }, [siteId]); + useEffect(() => { + if (!importMode) { + setPreview(null); + setPreviewError(null); + return; + } + if (!dumpFile && !dumpText.trim()) { + setPreview(null); + setPreviewError(null); + return; + } + + let cancelled = false; + const timer = window.setTimeout(async () => { + try { + const parsed = await parseClientDump(dumpFile, dumpText, keysFile); + if (!cancelled) { + setPreview(parsed); + setPreviewError(null); + } + } catch (e) { + if (!cancelled) { + setPreview(null); + setPreviewError((e as Error).message); + } + } + }, 200); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [importMode, dumpFile, dumpText, keysFile]); + function defaultDump(): unknown { if (LF_PROTOCOLS.includes(protocol)) { return { uidBytes: uid.replace(/[^0-9a-fA-F]/g, "").toUpperCase(), format: protocol }; @@ -46,27 +88,67 @@ function NewTagForm() { if (protocol.includes("CLASSIC")) { return { size: protocol.includes("4K") ? "4K" : "1K", - sectors: [{ index: 0, blocks: ["00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000", "FFFFFFFFFFFFFF078069FFFFFFFFFFFF"] }], + sectors: [ + { + index: 0, + blocks: [ + "00000000000000000000000000000000", + "00000000000000000000000000000000", + "00000000000000000000000000000000", + "FFFFFFFFFFFFFF078069FFFFFFFFFFFF", + ], + }, + ], }; } return {}; } + function onDumpFile(file: File | null) { + setNotice(null); + if (file && looksLikeKeyFile(file)) { + setKeysFile(file); + setDumpFile(null); + setNotice( + "That file looks like a Proxmark key.bin, so it was attached as the key file. Add dump.json or dump.bin as the dump." + ); + return; + } + setDumpFile(file); + if (file) setDumpText(""); + } + + function onKeysFile(file: File | null) { + setNotice(null); + if (file && looksLikeDumpFile(file) && !looksLikeKeyFile(file)) { + setDumpFile(file); + setKeysFile(null); + setNotice("That file looks like a card dump, so it was attached as the dump file."); + return; + } + setKeysFile(file); + } + async function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); if (importMode) { + if (!dumpFile && !dumpText.trim()) { + setError("Upload a dump file or paste dump contents"); + return; + } + const form = new FormData(); + form.append("siteId", siteId); + if (label.trim()) form.append("label", label.trim()); + if (notes.trim()) form.append("notes", notes.trim()); + if (dumpFile) form.append("dump", dumpFile); + else form.append("content", dumpText); + if (keysFile) form.append("keys", keysFile); + const res = await fetch("/api/v1/tags/import", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - siteId, - label: label || undefined, - notes: notes || null, - content: dumpText, - filename: "import.json", - }), + body: form, }); const data = await res.json().catch(() => null); if (!res.ok) { @@ -119,20 +201,19 @@ function NewTagForm() { router.push(`/tags/${data.id}`); } + const sectorCount = + preview && isMifareClassicDump(preview.dumpData) + ? preview.dumpData.sectors.length + : null; + return (

New tag

Store a dump for later write-out

{error &&
{error}
} + {notice &&
{notice}
}
- +
- setSiteId(e.target.value)} + required + > @@ -159,7 +252,13 @@ function NewTagForm() {
- setLabel(e.target.value)} required={!importMode} placeholder="Front door fob" /> + setLabel(e.target.value)} + required={!importMode} + placeholder="Front door fob" + />
{!importMode && ( @@ -187,26 +286,93 @@ function NewTagForm() {
- setUid(e.target.value)} required placeholder="04:A1:B2:C3" /> + setUid(e.target.value)} + required + placeholder="04:A1:B2:C3" + />
-