mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba92654c0 |
@@ -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
|
- Multi-user auth (shared workspace, no RBAC yet) — local password + optional OIDC
|
||||||
- Personal API tokens (Settings UI) for Bearer auth
|
- Personal API tokens (Settings UI) for Bearer auth
|
||||||
- Export: JSON, Proxmark, MCT, hex
|
- Export: JSON, Proxmark, MCT, hex
|
||||||
- Import dumps (file upload or paste) + full JSON backup
|
- Import dumps + full JSON backup
|
||||||
- Installable PWA with offline read of cached pages
|
- Installable PWA with offline read of cached pages
|
||||||
- Vitest unit/integration tests + GitHub Actions CI
|
- Vitest unit/integration tests + GitHub Actions CI
|
||||||
|
|
||||||
@@ -67,26 +67,8 @@ Open http://localhost:3000 and sign in.
|
|||||||
6. Open `/login` — you should see **Sign in with AtlasHorizon**.
|
6. Open `/login` — you should see **Sign in with AtlasHorizon**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`).
|
- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`). Your AtlasHorizon issuer at `https://auth.atlashorizon.net` is valid.
|
||||||
- The app requests `openid email profile` and loads profile from **UserInfo** (Authelia puts `email` there by default).
|
- The IdP must return an **email** claim (we request `openid email profile`).
|
||||||
- Optional Authelia hardening — also put email on the ID token:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
identity_providers:
|
|
||||||
oidc:
|
|
||||||
claims_policies:
|
|
||||||
rfiddb:
|
|
||||||
id_token:
|
|
||||||
- 'email'
|
|
||||||
- 'email_verified'
|
|
||||||
- 'preferred_username'
|
|
||||||
- 'name'
|
|
||||||
clients:
|
|
||||||
- client_id: 'rfiddb'
|
|
||||||
claims_policy: 'rfiddb'
|
|
||||||
# ...rest of client...
|
|
||||||
```
|
|
||||||
|
|
||||||
- Local password login stays available alongside SSO.
|
- Local password login stays available alongside SSO.
|
||||||
- If the button is missing, you are almost certainly on an old image — rebuild.
|
- If the button is missing, you are almost certainly on an old image — rebuild.
|
||||||
|
|
||||||
@@ -102,7 +84,7 @@ Authenticate with a session cookie **or** `Authorization: Bearer rfid_…` (crea
|
|||||||
| GET/PATCH/DELETE | `/api/v1/tags/{id}` | Tag detail |
|
| GET/PATCH/DELETE | `/api/v1/tags/{id}` | Tag detail |
|
||||||
| PUT | `/api/v1/tags/by-uid/{uid}?siteId=` | Upsert by UID |
|
| PUT | `/api/v1/tags/by-uid/{uid}?siteId=` | Upsert by UID |
|
||||||
| GET | `/api/v1/tags/{id}/export?format=` | `json` \| `proxmark` \| `mct` \| `hex` |
|
| GET | `/api/v1/tags/{id}/export?format=` | `json` \| `proxmark` \| `mct` \| `hex` |
|
||||||
| POST | `/api/v1/tags/import` | Parse dump → create tag (JSON body or multipart files) |
|
| POST | `/api/v1/tags/import` | Parse dump → create tag |
|
||||||
| GET | `/api/v1/search?q=` | Cross-site search |
|
| GET | `/api/v1/search?q=` | Cross-site search |
|
||||||
| GET/POST | `/api/v1/backup` | Full backup export / import |
|
| GET/POST | `/api/v1/backup` | Full backup export / import |
|
||||||
| GET/POST | `/api/v1/tokens` | List / create PATs |
|
| GET/POST | `/api/v1/tokens` | List / create PATs |
|
||||||
@@ -124,29 +106,8 @@ curl -sH "Authorization: Bearer $TOKEN" \
|
|||||||
curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
-X PUT "$HOST/api/v1/tags/by-uid/04A1B2C3?siteId=$SITE" \
|
-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"]}}'
|
-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 "[email protected]" \
|
|
||||||
-F "[email protected]" \
|
|
||||||
"$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-<UID>-dump.json` | `FileType` `mfc v2` (blocks as an object + `SectorKeys`) or older `mfcard` (blocks array) |
|
|
||||||
| Binary dump | `hf-mf-<UID>.bin` / `dump.bin` | 1024 bytes (1K) or 4096 bytes (4K) |
|
|
||||||
| Keys | `hf-mf-<UID>-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
|
## Tests & CI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,62 +3,36 @@ import { eq } from "drizzle-orm";
|
|||||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||||
import { getDb } from "@/db/client";
|
import { getDb } from "@/db/client";
|
||||||
import { sites, tagRecords } from "@/db/schema";
|
import { sites, tagRecords } from "@/db/schema";
|
||||||
import { bytesFromBase64, parseDump } from "@/lib/rfid/parsers";
|
import { parseImport } from "@/lib/rfid/parsers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const importMetaSchema = z
|
const importMetaSchema = z.object({
|
||||||
.object({
|
|
||||||
siteId: z.string().uuid(),
|
siteId: z.string().uuid(),
|
||||||
label: z.string().min(1).max(200).optional(),
|
label: z.string().min(1).max(200).optional(),
|
||||||
notes: z.string().max(5000).nullable().optional(),
|
notes: z.string().max(5000).nullable().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().min(1),
|
||||||
contentBase64: z.string().optional(),
|
|
||||||
filename: 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) {
|
export async function POST(request: NextRequest) {
|
||||||
const userOrRes = await requireApiUser(request);
|
const userOrRes = await requireApiUser(request);
|
||||||
if (userOrRes instanceof Response) return userOrRes;
|
if (userOrRes instanceof Response) return userOrRes;
|
||||||
|
|
||||||
let fields: ImportFields;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
fields = await readImportFields(request);
|
body = await request.json();
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (e instanceof SyntaxError) {
|
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON or multipart form data");
|
|
||||||
}
|
|
||||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fields.text?.trim() && !(fields.bytes && fields.bytes.length > 0)) {
|
const parsed = importMetaSchema.safeParse(body);
|
||||||
return jsonError(400, "VALIDATION_ERROR", "Provide dump content or an uploaded dump file");
|
if (!parsed.success) {
|
||||||
|
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
let imported;
|
let imported;
|
||||||
try {
|
try {
|
||||||
imported = parseDump({
|
imported = parseImport(parsed.data.content, parsed.data.filename);
|
||||||
text: fields.text,
|
|
||||||
filename: fields.filename,
|
|
||||||
bytes: fields.bytes,
|
|
||||||
keysFilename: fields.keysFilename,
|
|
||||||
keysBytes: fields.keysBytes,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -67,25 +41,26 @@ export async function POST(request: NextRequest) {
|
|||||||
const site = db
|
const site = db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.id, fields.siteId))
|
.where(eq(sites.id, parsed.data.siteId))
|
||||||
.get();
|
.get();
|
||||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const label = fields.label || imported.label || `Imported ${imported.uid}`;
|
const label =
|
||||||
|
parsed.data.label || imported.label || `Imported ${imported.uid}`;
|
||||||
|
|
||||||
db.insert(tagRecords)
|
db.insert(tagRecords)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
siteId: fields.siteId,
|
siteId: parsed.data.siteId,
|
||||||
label,
|
label,
|
||||||
frequency: imported.frequency,
|
frequency: imported.frequency,
|
||||||
protocol: imported.protocol,
|
protocol: imported.protocol,
|
||||||
uid: imported.uid,
|
uid: imported.uid,
|
||||||
dumpData: imported.dumpData,
|
dumpData: imported.dumpData,
|
||||||
keys: imported.keys ?? null,
|
keys: imported.keys ?? null,
|
||||||
notes: fields.notes ?? imported.notes ?? null,
|
notes: parsed.data.notes ?? imported.notes ?? null,
|
||||||
createdById: userOrRes.id,
|
createdById: userOrRes.id,
|
||||||
updatedById: userOrRes.id,
|
updatedById: userOrRes.id,
|
||||||
lastWrittenAt: null,
|
lastWrittenAt: null,
|
||||||
@@ -106,88 +81,9 @@ export async function POST(request: NextRequest) {
|
|||||||
dumpData: row.dumpData,
|
dumpData: row.dumpData,
|
||||||
keys: row.keys,
|
keys: row.keys,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readImportFields(request: NextRequest): Promise<ImportFields> {
|
|
||||||
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<ImportFields> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import { LoginPageClient } from "@/components/LoginPageClient";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, "");
|
||||||
|
const callbackUrlHint = authUrl
|
||||||
|
? `${authUrl}/api/auth/callback/oidc`
|
||||||
|
: "https://<your-host>/api/auth/callback/oidc";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginPageClient
|
<LoginPageClient
|
||||||
oidcEnabled={isOidcConfigured()}
|
oidcEnabled={isOidcConfigured()}
|
||||||
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
||||||
|
callbackUrlHint={callbackUrlHint}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-240
@@ -1,13 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FormEvent, Suspense, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { PROTOCOLS, LF_PROTOCOLS, type Protocol } from "@/lib/rfid/protocols";
|
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 };
|
type Site = { id: string; name: string; code: string };
|
||||||
|
|
||||||
@@ -22,13 +19,8 @@ function NewTagForm() {
|
|||||||
const [dumpText, setDumpText] = useState("");
|
const [dumpText, setDumpText] = useState("");
|
||||||
const [keysText, setKeysText] = useState("");
|
const [keysText, setKeysText] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [importMode, setImportMode] = useState(true);
|
const [importMode, setImportMode] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
const [dumpFile, setDumpFile] = useState<File | null>(null);
|
|
||||||
const [keysFile, setKeysFile] = useState<File | null>(null);
|
|
||||||
const [preview, setPreview] = useState<CanonicalTag | null>(null);
|
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const frequency = useMemo(
|
const frequency = useMemo(
|
||||||
() => (LF_PROTOCOLS.includes(protocol) ? "LF" : "HF"),
|
() => (LF_PROTOCOLS.includes(protocol) ? "LF" : "HF"),
|
||||||
@@ -44,40 +36,6 @@ function NewTagForm() {
|
|||||||
});
|
});
|
||||||
}, [siteId]);
|
}, [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 {
|
function defaultDump(): unknown {
|
||||||
if (LF_PROTOCOLS.includes(protocol)) {
|
if (LF_PROTOCOLS.includes(protocol)) {
|
||||||
return { uidBytes: uid.replace(/[^0-9a-fA-F]/g, "").toUpperCase(), format: protocol };
|
return { uidBytes: uid.replace(/[^0-9a-fA-F]/g, "").toUpperCase(), format: protocol };
|
||||||
@@ -88,67 +46,27 @@ function NewTagForm() {
|
|||||||
if (protocol.includes("CLASSIC")) {
|
if (protocol.includes("CLASSIC")) {
|
||||||
return {
|
return {
|
||||||
size: protocol.includes("4K") ? "4K" : "1K",
|
size: protocol.includes("4K") ? "4K" : "1K",
|
||||||
sectors: [
|
sectors: [{ index: 0, blocks: ["00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000", "FFFFFFFFFFFFFF078069FFFFFFFFFFFF"] }],
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
blocks: [
|
|
||||||
"00000000000000000000000000000000",
|
|
||||||
"00000000000000000000000000000000",
|
|
||||||
"00000000000000000000000000000000",
|
|
||||||
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {};
|
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) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (importMode) {
|
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", {
|
const res = await fetch("/api/v1/tags/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteId,
|
||||||
|
label: label || undefined,
|
||||||
|
notes: notes || null,
|
||||||
|
content: dumpText,
|
||||||
|
filename: "import.json",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -201,26 +119,13 @@ function NewTagForm() {
|
|||||||
router.push(`/tags/${data.id}`);
|
router.push(`/tags/${data.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectorCount =
|
|
||||||
preview && isMifareClassicDump(preview.dumpData)
|
|
||||||
? preview.dumpData.sectors.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">New tag</h1>
|
<h1 className="page-title">New tag</h1>
|
||||||
<p className="page-sub">Store a dump for later write-out</p>
|
<p className="page-sub">Store a dump for later write-out</p>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{notice && <div className="success-banner">{notice}</div>}
|
|
||||||
|
|
||||||
<div className="row" style={{ marginBottom: "1rem" }}>
|
<div className="row" style={{ marginBottom: "1rem" }}>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
|
||||||
onClick={() => setImportMode(true)}
|
|
||||||
>
|
|
||||||
Import dump
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
||||||
@@ -228,17 +133,19 @@ function NewTagForm() {
|
|||||||
>
|
>
|
||||||
Manual
|
Manual
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
||||||
|
onClick={() => setImportMode(true)}
|
||||||
|
>
|
||||||
|
Import dump
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="panel stack" onSubmit={onSubmit}>
|
<form className="panel stack" onSubmit={onSubmit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Site</label>
|
<label className="label">Site</label>
|
||||||
<select
|
<select className="select" value={siteId} onChange={(e) => setSiteId(e.target.value)} required>
|
||||||
className="select"
|
|
||||||
value={siteId}
|
|
||||||
onChange={(e) => setSiteId(e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select site
|
Select site
|
||||||
</option>
|
</option>
|
||||||
@@ -252,13 +159,7 @@ function NewTagForm() {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Label</label>
|
<label className="label">Label</label>
|
||||||
<input
|
<input className="input" value={label} onChange={(e) => setLabel(e.target.value)} required={!importMode} placeholder="Front door fob" />
|
||||||
className="input"
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
required={!importMode}
|
|
||||||
placeholder="Front door fob"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!importMode && (
|
{!importMode && (
|
||||||
@@ -286,93 +187,26 @@ function NewTagForm() {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">UID</label>
|
<label className="label">UID</label>
|
||||||
<input
|
<input className="input mono" value={uid} onChange={(e) => setUid(e.target.value)} required placeholder="04:A1:B2:C3" />
|
||||||
className="input mono"
|
|
||||||
value={uid}
|
|
||||||
onChange={(e) => setUid(e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="04:A1:B2:C3"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">dumpData (JSON, optional — defaults applied)</label>
|
<label className="label">dumpData (JSON, optional — defaults applied)</label>
|
||||||
<textarea
|
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} placeholder='{"size":"1K","sectors":[...]}' />
|
||||||
className="textarea"
|
|
||||||
value={dumpText}
|
|
||||||
onChange={(e) => setDumpText(e.target.value)}
|
|
||||||
placeholder='{"size":"1K","sectors":[...]}'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">keys (JSON, optional)</label>
|
<label className="label">keys (JSON, optional)</label>
|
||||||
<textarea
|
<textarea className="textarea" value={keysText} onChange={(e) => setKeysText(e.target.value)} placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}' />
|
||||||
className="textarea"
|
|
||||||
value={keysText}
|
|
||||||
onChange={(e) => setKeysText(e.target.value)}
|
|
||||||
placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{importMode && (
|
{importMode && (
|
||||||
<>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Dump file</label>
|
<label className="label">Paste JSON / MCT / Proxmark dump</label>
|
||||||
<input
|
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} required style={{ minHeight: "14rem" }} />
|
||||||
className="input"
|
|
||||||
type="file"
|
|
||||||
accept=".json,.mct,.eml,.txt,.bin,.dump,application/json,text/plain,application/octet-stream"
|
|
||||||
onChange={(e) => onDumpFile(e.target.files?.[0] ?? null)}
|
|
||||||
/>
|
|
||||||
<p className="muted" style={{ margin: "0.35rem 0 0", fontSize: "0.8rem" }}>
|
|
||||||
Proxmark <span className="mono">hf-mf-<UID>-dump.json</span>, MCT, EML, or{" "}
|
|
||||||
<span className="mono">dump.bin</span> (1024/4096 bytes)
|
|
||||||
{dumpFile ? ` — ${dumpFile.name}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label className="label">Key file (optional)</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="file"
|
|
||||||
accept=".bin,.dic,.txt,application/octet-stream"
|
|
||||||
onChange={(e) => onKeysFile(e.target.files?.[0] ?? null)}
|
|
||||||
/>
|
|
||||||
<p className="muted" style={{ margin: "0.35rem 0 0", fontSize: "0.8rem" }}>
|
|
||||||
Proxmark <span className="mono">hf-mf-<UID>-key.bin</span> is a binary file
|
|
||||||
(192 bytes for 1K). Skip this if the dump JSON already has SectorKeys.
|
|
||||||
{keysFile ? ` — ${keysFile.name}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="label">Or paste dump text</label>
|
|
||||||
<textarea
|
|
||||||
className="textarea"
|
|
||||||
value={dumpText}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDumpText(e.target.value);
|
|
||||||
if (e.target.value) setDumpFile(null);
|
|
||||||
}}
|
|
||||||
style={{ minHeight: "12rem" }}
|
|
||||||
placeholder='Paste Proxmark JSON ({ "FileType": "mfc v2", ... }) or MCT here'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{preview && (
|
|
||||||
<div className="success-banner">
|
|
||||||
Parsed UID <span className="mono">{formatUid(preview.uid)}</span>
|
|
||||||
{" · "}
|
|
||||||
{preview.protocol.replaceAll("_", " ")}
|
|
||||||
{sectorCount != null ? ` · ${sectorCount} sectors` : ""}
|
|
||||||
{preview.keys?.A?.length
|
|
||||||
? ` · ${preview.keys.A.length} sector keys`
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{previewError && <div className="error-banner">{previewError}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -393,54 +227,6 @@ function NewTagForm() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeKeyFile(file: File): boolean {
|
|
||||||
return isKeyBinFilename(file.name) || file.size === 192 || file.size === 480;
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeDumpFile(file: File): boolean {
|
|
||||||
const name = file.name.toLowerCase();
|
|
||||||
return (
|
|
||||||
name.endsWith(".json") ||
|
|
||||||
name.endsWith(".mct") ||
|
|
||||||
name.endsWith(".eml") ||
|
|
||||||
name.includes("-dump.bin") ||
|
|
||||||
file.size === 1024 ||
|
|
||||||
file.size === 4096
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseClientDump(
|
|
||||||
dumpFile: File | null,
|
|
||||||
dumpText: string,
|
|
||||||
keysFile: File | null
|
|
||||||
): Promise<CanonicalTag> {
|
|
||||||
let text: string | undefined;
|
|
||||||
let bytes: Uint8Array | undefined;
|
|
||||||
let filename: string | undefined;
|
|
||||||
|
|
||||||
if (dumpFile) {
|
|
||||||
filename = dumpFile.name;
|
|
||||||
const buf = new Uint8Array(await dumpFile.arrayBuffer());
|
|
||||||
const lower = dumpFile.name.toLowerCase();
|
|
||||||
if (lower.endsWith(".bin") || lower.endsWith(".dump")) {
|
|
||||||
bytes = buf;
|
|
||||||
} else {
|
|
||||||
text = new TextDecoder().decode(buf);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text = dumpText;
|
|
||||||
filename = "import.json";
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseDump({
|
|
||||||
text,
|
|
||||||
bytes,
|
|
||||||
filename,
|
|
||||||
keysBytes: keysFile ? new Uint8Array(await keysFile.arrayBuffer()) : undefined,
|
|
||||||
keysFilename: keysFile?.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewTagPage() {
|
export default function NewTagPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { Suspense } from "react";
|
|||||||
type Props = {
|
type Props = {
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcName: string;
|
oidcName: string;
|
||||||
|
callbackUrlHint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthConfig = {
|
type AuthConfig = {
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcName: string;
|
oidcName: string;
|
||||||
|
callbackUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function authErrorMessage(code: string | null): string | null {
|
function authErrorMessage(code: string | null): string | null {
|
||||||
@@ -34,7 +36,7 @@ function authErrorMessage(code: string | null): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginForm({ oidcEnabled, oidcName }: Props) {
|
function LoginForm({ oidcEnabled, oidcName, callbackUrlHint }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const callbackUrl = params.get("callbackUrl") || "/";
|
const callbackUrl = params.get("callbackUrl") || "/";
|
||||||
@@ -60,6 +62,7 @@ function LoginForm({ oidcEnabled, oidcName }: Props) {
|
|||||||
|
|
||||||
const showOidc = runtimeOidc?.oidcEnabled ?? oidcEnabled;
|
const showOidc = runtimeOidc?.oidcEnabled ?? oidcEnabled;
|
||||||
const displayName = runtimeOidc?.oidcName || oidcName;
|
const displayName = runtimeOidc?.oidcName || oidcName;
|
||||||
|
const redirectHint = runtimeOidc?.callbackUrl || callbackUrlHint;
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -118,6 +121,7 @@ function LoginForm({ oidcEnabled, oidcName }: Props) {
|
|||||||
{loading ? "Signing in…" : "Sign in"}
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
{showOidc && (
|
{showOidc && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -125,6 +129,12 @@ function LoginForm({ oidcEnabled, oidcName }: Props) {
|
|||||||
>
|
>
|
||||||
Sign in with {displayName}
|
Sign in with {displayName}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: "0.75rem" }}>
|
||||||
|
IdP redirect URI must be exactly:
|
||||||
|
<br />
|
||||||
|
<code className="mono">{redirectHint}</code>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-21
@@ -6,9 +6,9 @@ import { eq } from "drizzle-orm";
|
|||||||
import { getDb } from "@/db/client";
|
import { getDb } from "@/db/client";
|
||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { authConfig } from "@/lib/auth/auth.config";
|
import { authConfig } from "@/lib/auth/auth.config";
|
||||||
import { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
export { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
export { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
function buildProviders(): Provider[] {
|
function buildProviders(): Provider[] {
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
@@ -45,11 +45,6 @@ function buildProviders(): Provider[] {
|
|||||||
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
||||||
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
||||||
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!,
|
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!,
|
||||||
// Auth.js OIDC defaults to PKCE-only; Authelia requires a strong `state`
|
|
||||||
checks: ["pkce", "state"],
|
|
||||||
// Authelia (and many IdPs) put `email` on UserInfo, not the ID token.
|
|
||||||
// Auth.js OIDC otherwise only reads ID-token claims.
|
|
||||||
idToken: false,
|
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
scope: "openid email profile",
|
scope: "openid email profile",
|
||||||
@@ -61,16 +56,21 @@ function buildProviders(): Provider[] {
|
|||||||
// Link OIDC logins to existing local users by email
|
// Link OIDC logins to existing local users by email
|
||||||
allowDangerousEmailAccountLinking: true,
|
allowDangerousEmailAccountLinking: true,
|
||||||
profile(profile: Record<string, unknown>) {
|
profile(profile: Record<string, unknown>) {
|
||||||
const email = emailFromOidcProfile(profile);
|
const email =
|
||||||
|
(typeof profile.email === "string" && profile.email) ||
|
||||||
|
(typeof profile.preferred_username === "string" &&
|
||||||
|
String(profile.preferred_username).includes("@")
|
||||||
|
? String(profile.preferred_username)
|
||||||
|
: null);
|
||||||
return {
|
return {
|
||||||
id: String(profile.sub ?? email ?? crypto.randomUUID()),
|
id: String(profile.sub ?? ""),
|
||||||
name:
|
name:
|
||||||
(typeof profile.name === "string" && profile.name) ||
|
(typeof profile.name === "string" && profile.name) ||
|
||||||
(typeof profile.preferred_username === "string" &&
|
(typeof profile.preferred_username === "string" &&
|
||||||
profile.preferred_username) ||
|
profile.preferred_username) ||
|
||||||
email ||
|
email ||
|
||||||
"OIDC user",
|
"OIDC user",
|
||||||
email: email ?? undefined,
|
email,
|
||||||
image: typeof profile.picture === "string" ? profile.picture : null,
|
image: typeof profile.picture === "string" ? profile.picture : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -85,24 +85,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
providers: buildProviders(),
|
providers: buildProviders(),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
async signIn({ user, account, profile }) {
|
async signIn({ user, account }) {
|
||||||
if (account?.provider === "credentials") return true;
|
if (account?.provider === "credentials") return true;
|
||||||
|
if (!user.email) {
|
||||||
const email =
|
|
||||||
user.email?.trim().toLowerCase() ||
|
|
||||||
emailFromOidcProfile((profile ?? {}) as Record<string, unknown>);
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
console.error(
|
console.error(
|
||||||
"[auth] OIDC sign-in rejected: no email in profile. Claim keys:",
|
"[auth] OIDC sign-in rejected: IdP did not return an email claim. Enable the email scope/claim on the client."
|
||||||
profile ? Object.keys(profile) : []
|
|
||||||
);
|
);
|
||||||
return "/login?error=EmailRequired";
|
return "/login?error=EmailRequired";
|
||||||
}
|
}
|
||||||
|
|
||||||
user.email = email;
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
let existing = db.select().from(users).where(eq(users.email, email)).get();
|
let existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|||||||
@@ -5,23 +5,3 @@ export function isOidcConfigured(): boolean {
|
|||||||
process.env.AUTH_OIDC_CLIENT_SECRET?.trim()
|
process.env.AUTH_OIDC_CLIENT_SECRET?.trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pull an email out of common OIDC claim shapes (Authelia, Keycloak, etc.). */
|
|
||||||
export function emailFromOidcProfile(
|
|
||||||
profile: Record<string, unknown>
|
|
||||||
): string | null {
|
|
||||||
const candidates = [
|
|
||||||
profile.email,
|
|
||||||
profile.preferred_username,
|
|
||||||
profile.upn,
|
|
||||||
profile.mail,
|
|
||||||
(profile.user as { email?: unknown } | undefined)?.email,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const value of candidates) {
|
|
||||||
if (typeof value !== "string") continue;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed.includes("@")) return trimmed.toLowerCase();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
+33
-393
@@ -1,292 +1,68 @@
|
|||||||
import type { CanonicalTag } from "@/lib/rfid/types";
|
import type { CanonicalTag } from "@/lib/rfid/types";
|
||||||
import { normalizeUid } from "@/lib/rfid/uid";
|
import { normalizeUid } from "@/lib/rfid/uid";
|
||||||
import type { MifareClassicDump, TagKeys } from "@/lib/validation/rfid";
|
import type { Protocol } from "@/lib/rfid/protocols";
|
||||||
|
import type { MifareClassicDump } from "@/lib/validation/rfid";
|
||||||
export type ParseDumpOptions = {
|
|
||||||
filename?: string;
|
|
||||||
/** UTF-8 dump text (JSON, MCT, EML, hex). */
|
|
||||||
text?: string;
|
|
||||||
/** Raw dump bytes (.bin card dump). */
|
|
||||||
bytes?: Uint8Array;
|
|
||||||
keysFilename?: string;
|
|
||||||
/** Proxmark hf-mf-*-key.bin (or a hex key dictionary). */
|
|
||||||
keysBytes?: Uint8Array;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseImport(
|
export function parseImport(
|
||||||
content: string,
|
content: string,
|
||||||
filenameHint?: string,
|
filenameHint?: string
|
||||||
extra?: Omit<ParseDumpOptions, "text" | "filename">
|
|
||||||
): CanonicalTag {
|
): CanonicalTag {
|
||||||
return parseDump({
|
const trimmed = content.trim();
|
||||||
text: content,
|
const lower = (filenameHint ?? "").toLowerCase();
|
||||||
filename: filenameHint,
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseDump(input: ParseDumpOptions): CanonicalTag {
|
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
|
||||||
const filename = (input.filename ?? "").toLowerCase();
|
return parseJsonDump(trimmed);
|
||||||
let bytes = input.bytes;
|
|
||||||
let text = unwrapDumpText(input.text ?? "");
|
|
||||||
|
|
||||||
if (bytes && bytes.length > 0 && looksLikeUtf8TextDump(bytes)) {
|
|
||||||
if (!text.trim()) {
|
|
||||||
text = unwrapDumpText(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
|
||||||
}
|
}
|
||||||
// JSON/MCT uploaded as application/octet-stream should not be treated as dump.bin.
|
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
|
||||||
bytes = undefined;
|
return parseMct(trimmed);
|
||||||
|
}
|
||||||
|
// Heuristic: MCT-like or hex lines
|
||||||
|
if (trimmed.includes("+Sector:") || trimmed.includes("+UID:")) {
|
||||||
|
return parseMct(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bytes && bytes.length > 0 && looksLikeKeyBin(filename, bytes) && !text.trim()) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`This file looks like a Proxmark key.bin (${bytes.length} bytes: KeyA+KeyB per sector), not a card dump. Upload hf-mf-*-dump.json or dump.bin as the dump, and attach this file as the key file.`
|
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let tag: CanonicalTag;
|
|
||||||
|
|
||||||
if (bytes && bytes.length > 0 && looksLikeBinaryDump(filename, bytes, text)) {
|
|
||||||
tag = parseClassicBinDump(bytes);
|
|
||||||
} else if (trimmedStartsWith(text, "{") || filename.endsWith(".json")) {
|
|
||||||
tag = parseJsonDump(text);
|
|
||||||
} else if (
|
|
||||||
filename.endsWith(".mct") ||
|
|
||||||
text.includes("+Sector:") ||
|
|
||||||
text.includes("+UID:")
|
|
||||||
) {
|
|
||||||
tag = parseMct(text);
|
|
||||||
} else if (filename.endsWith(".eml") || looksLikeEml(text)) {
|
|
||||||
tag = parseEml(text);
|
|
||||||
} else if (bytes && bytes.length > 0) {
|
|
||||||
tag = parseClassicBinDump(bytes);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Unrecognized dump format. Upload Proxmark JSON (hf-mf-*-dump.json), MCT (.mct), EML, or a MIFARE dump.bin."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.keysBytes && input.keysBytes.length > 0) {
|
|
||||||
tag = {
|
|
||||||
...tag,
|
|
||||||
keys: mergeKeys(tag.keys, parseKeyBin(input.keysBytes, input.keysFilename)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapDumpText(raw: string): string {
|
|
||||||
let text = raw.replace(/^\uFEFF/, "").trim();
|
|
||||||
const fence = text.match(/^```(?:json|mct|text|plain)?\s*\n([\s\S]*?)\n```$/i);
|
|
||||||
if (fence) text = fence[1].trim();
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimmedStartsWith(text: string, prefix: string): boolean {
|
|
||||||
return text.trimStart().startsWith(prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeUtf8TextDump(bytes: Uint8Array): boolean {
|
|
||||||
let i = 0;
|
|
||||||
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
||||||
i = 3;
|
|
||||||
}
|
|
||||||
while (i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0a || bytes[i] === 0x0d)) {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
if (i >= bytes.length) return false;
|
|
||||||
const c = bytes[i];
|
|
||||||
return c === 0x7b /* { */ || c === 0x2b /* + */ || c === 0x23 /* # */;
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeBinaryDump(filename: string, bytes: Uint8Array, text: string): boolean {
|
|
||||||
if (text.trim().startsWith("{") || text.includes("+Sector:")) return false;
|
|
||||||
if (filename.endsWith(".bin") || filename.endsWith(".dump")) return true;
|
|
||||||
return bytes.length === 1024 || bytes.length === 4096;
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeKeyBin(filename: string, bytes: Uint8Array): boolean {
|
|
||||||
const named =
|
|
||||||
filename.includes("-key.bin") ||
|
|
||||||
filename.endsWith("key.bin") ||
|
|
||||||
filename.includes("keys.bin");
|
|
||||||
const sized = bytes.length === 192 || bytes.length === 480;
|
|
||||||
return named || sized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeEml(text: string): boolean {
|
|
||||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
||||||
if (lines.length < 4) return false;
|
|
||||||
return lines.slice(0, 8).every((l) => /^[0-9A-Fa-f]{32}$/.test(l));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonDump(content: string): CanonicalTag {
|
function parseJsonDump(content: string): CanonicalTag {
|
||||||
let data: Record<string, unknown>;
|
const data = JSON.parse(content) as Record<string, unknown>;
|
||||||
try {
|
|
||||||
data = JSON.parse(content) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
const extracted = extractJsonObject(content);
|
|
||||||
if (!extracted) {
|
|
||||||
throw new Error("Dump is not valid JSON");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
data = JSON.parse(extracted) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
throw new Error("Dump is not valid JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileType = String(data.FileType ?? data.Created ?? "").toLowerCase();
|
// Proxmark-style
|
||||||
|
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
|
||||||
if (
|
return parseProxmarkJson(data);
|
||||||
fileType.includes("mfc") ||
|
|
||||||
fileType.includes("mfcard") ||
|
|
||||||
data.Card ||
|
|
||||||
data.blocks ||
|
|
||||||
data.Blocks ||
|
|
||||||
data.SectorKeys
|
|
||||||
) {
|
|
||||||
return parseProxmarkClassicJson(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical
|
||||||
if (data.dumpData && data.uid && data.protocol) {
|
if (data.dumpData && data.uid && data.protocol) {
|
||||||
return {
|
return {
|
||||||
label: typeof data.label === "string" ? data.label : undefined,
|
label: typeof data.label === "string" ? data.label : undefined,
|
||||||
frequency: (data.frequency as "LF" | "HF") ?? "HF",
|
frequency: (data.frequency as "LF" | "HF") ?? "HF",
|
||||||
protocol: data.protocol as CanonicalTag["protocol"],
|
protocol: data.protocol as Protocol,
|
||||||
uid: normalizeUid(String(data.uid)),
|
uid: normalizeUid(String(data.uid)),
|
||||||
dumpData: data.dumpData,
|
dumpData: data.dumpData,
|
||||||
keys: isKeySet(data.keys) ? data.keys : undefined,
|
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
|
||||||
notes: typeof data.notes === "string" ? data.notes : null,
|
notes: typeof data.notes === "string" ? data.notes : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
|
||||||
"JSON dump missing required fields. Expected Proxmark MIFARE dump (FileType mfc v2 / mfcard, with Card/blocks) or canonical {uid, protocol, dumpData}."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJsonObject(content: string): string | null {
|
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
|
||||||
const start = content.indexOf("{");
|
const card = (data.Card as { UID?: string } | undefined) ?? {};
|
||||||
const end = content.lastIndexOf("}");
|
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
|
||||||
if (start < 0 || end <= start) return null;
|
const blocks = (data.blocks as string[]) ?? [];
|
||||||
return content.slice(start, end + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKeySet(value: unknown): value is TagKeys {
|
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProxmarkClassicJson(data: Record<string, unknown>): CanonicalTag {
|
|
||||||
const card = (data.Card as Record<string, unknown> | undefined) ?? {};
|
|
||||||
const uidRaw = card.UID ?? card.uid ?? data.UID ?? data.uid;
|
|
||||||
const blocks = extractBlockList(data.blocks ?? data.Blocks);
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
throw new Error("Proxmark dump has no blocks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dumpData = blocksToClassicDump(blocks);
|
const dumpData = blocksToClassicDump(blocks);
|
||||||
const uid =
|
|
||||||
uidRaw != null && String(uidRaw).trim()
|
|
||||||
? normalizeUid(String(uidRaw))
|
|
||||||
: uidFromBlock0(blocks[0]);
|
|
||||||
|
|
||||||
const keys = keysFromSectorKeys(data.SectorKeys) ?? keysFromTrailers(dumpData);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
frequency: "HF",
|
frequency: "HF",
|
||||||
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
uid,
|
uid,
|
||||||
dumpData,
|
dumpData,
|
||||||
keys,
|
keys: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockToHex(value: unknown): string {
|
|
||||||
if (value && typeof value === "object" && "data" in value) {
|
|
||||||
return blockToHex((value as { data: unknown }).data);
|
|
||||||
}
|
|
||||||
return String(value ?? "")
|
|
||||||
.replace(/\s/g, "")
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractBlockList(blocks: unknown): string[] {
|
|
||||||
if (Array.isArray(blocks)) {
|
|
||||||
return blocks.map(blockToHex).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (blocks && typeof blocks === "object") {
|
|
||||||
const rec = blocks as Record<string, unknown>;
|
|
||||||
const indices = Object.keys(rec)
|
|
||||||
.map((k) => Number.parseInt(k, 10))
|
|
||||||
.filter((k) => Number.isFinite(k));
|
|
||||||
if (indices.length === 0) return [];
|
|
||||||
const max = Math.max(...indices);
|
|
||||||
const out: string[] = [];
|
|
||||||
for (let i = 0; i <= max; i++) {
|
|
||||||
const raw = rec[String(i)] ?? rec[i as unknown as string];
|
|
||||||
if (raw == null) {
|
|
||||||
throw new Error(`Proxmark dump missing block ${i}`);
|
|
||||||
}
|
|
||||||
const hex = blockToHex(raw);
|
|
||||||
if (!/^[0-9A-F]+$/.test(hex) || hex.length < 32) {
|
|
||||||
throw new Error(`Proxmark dump block ${i} is not 16-byte hex`);
|
|
||||||
}
|
|
||||||
out.push(hex);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function uidFromBlock0(block: string): string {
|
|
||||||
const hex = block.replace(/\s/g, "").toUpperCase();
|
|
||||||
if (hex.length < 8) throw new Error("Could not determine UID from dump");
|
|
||||||
return hex.slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keysFromSectorKeys(raw: unknown): TagKeys | undefined {
|
|
||||||
if (!raw || typeof raw !== "object") return undefined;
|
|
||||||
const A: string[] = [];
|
|
||||||
const B: string[] = [];
|
|
||||||
const entries = sectorEntries(raw);
|
|
||||||
if (entries.length === 0) return undefined;
|
|
||||||
|
|
||||||
for (const [, value] of entries) {
|
|
||||||
const rec = (value ?? {}) as Record<string, unknown>;
|
|
||||||
const keyA = String(rec.KeyA ?? rec.keyA ?? rec.A ?? "").replace(/[^0-9A-Fa-f]/g, "");
|
|
||||||
const keyB = String(rec.KeyB ?? rec.keyB ?? rec.B ?? "").replace(/[^0-9A-Fa-f]/g, "");
|
|
||||||
A.push(keyA.toUpperCase());
|
|
||||||
B.push(keyB.toUpperCase());
|
|
||||||
}
|
|
||||||
return { A, B };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectorEntries(raw: unknown): Array<[number, unknown]> {
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
return raw.map((v, i): [number, unknown] => [i, v]);
|
|
||||||
}
|
|
||||||
return Object.entries(raw as Record<string, unknown>)
|
|
||||||
.map(([k, v]): [number, unknown] => [Number.parseInt(k, 10), v])
|
|
||||||
.filter(([k]) => Number.isFinite(k))
|
|
||||||
.sort((a, b) => a[0] - b[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keysFromTrailers(dump: MifareClassicDump): TagKeys | undefined {
|
|
||||||
const A: string[] = [];
|
|
||||||
const B: string[] = [];
|
|
||||||
for (const sector of [...dump.sectors].sort((a, b) => a.index - b.index)) {
|
|
||||||
const trailer = sector.blocks[sector.blocks.length - 1] ?? "";
|
|
||||||
const hex = trailer.replace(/\s/g, "").toUpperCase();
|
|
||||||
if (hex.length < 32) continue;
|
|
||||||
A.push(hex.slice(0, 12));
|
|
||||||
B.push(hex.slice(20, 32));
|
|
||||||
}
|
|
||||||
if (A.length === 0) return undefined;
|
|
||||||
return { A, B };
|
|
||||||
}
|
|
||||||
|
|
||||||
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
||||||
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
|
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
|
||||||
const size = normalized.length > 64 ? "4K" : "1K";
|
const size = normalized.length > 64 ? "4K" : "1K";
|
||||||
@@ -305,10 +81,7 @@ function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseMct(content: string): CanonicalTag {
|
export function parseMct(content: string): CanonicalTag {
|
||||||
const lines = unwrapDumpText(content)
|
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
let uid = "";
|
let uid = "";
|
||||||
const sectors: MifareClassicDump["sectors"] = [];
|
const sectors: MifareClassicDump["sectors"] = [];
|
||||||
let current: { index: number; blocks: string[] } | null = null;
|
let current: { index: number; blocks: string[] } | null = null;
|
||||||
@@ -333,154 +106,21 @@ export function parseMct(content: string): CanonicalTag {
|
|||||||
if (current) sectors.push(current);
|
if (current) sectors.push(current);
|
||||||
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
|
// try first block of sector 0
|
||||||
const b0 = sectors.find((s) => s.index === 0)?.blocks[0];
|
const b0 = sectors.find((s) => s.index === 0)?.blocks[0];
|
||||||
if (b0 && b0.length >= 8) uid = b0.slice(0, 8);
|
if (b0 && b0.length >= 8) {
|
||||||
|
uid = b0.slice(0, 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!uid) throw new Error("MCT dump missing UID");
|
if (!uid) throw new Error("MCT dump missing UID");
|
||||||
|
|
||||||
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
|
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
|
||||||
const size = totalBlocks > 64 ? "4K" : "1K";
|
const size = totalBlocks > 64 ? "4K" : "1K";
|
||||||
const dumpData: MifareClassicDump = { size, sectors };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
frequency: "HF",
|
frequency: "HF",
|
||||||
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
uid,
|
uid,
|
||||||
dumpData,
|
dumpData: { size, sectors },
|
||||||
keys: keysFromTrailers(dumpData),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEml(content: string): CanonicalTag {
|
|
||||||
const blocks = unwrapDumpText(content)
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => /^[0-9A-Fa-f]{32}$/.test(l))
|
|
||||||
.map((l) => l.toUpperCase());
|
|
||||||
if (blocks.length < 4) throw new Error("EML dump has too few blocks");
|
|
||||||
const dumpData = blocksToClassicDump(blocks);
|
|
||||||
return {
|
|
||||||
frequency: "HF",
|
|
||||||
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
|
||||||
uid: uidFromBlock0(blocks[0]),
|
|
||||||
dumpData,
|
|
||||||
keys: keysFromTrailers(dumpData),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseClassicBinDump(bytes: Uint8Array): CanonicalTag {
|
|
||||||
if (bytes.length !== 1024 && bytes.length !== 4096) {
|
|
||||||
if (looksLikeKeyBin("", bytes)) {
|
|
||||||
throw new Error(
|
|
||||||
`This file looks like a Proxmark key.bin (${bytes.length} bytes), not a card dump. Upload dump.json / dump.bin as the dump and this file as the key file.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`MIFARE dump.bin must be 1024 bytes (1K) or 4096 bytes (4K); got ${bytes.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const blocks: string[] = [];
|
|
||||||
for (let i = 0; i < bytes.length; i += 16) {
|
|
||||||
blocks.push(bytesToHex(bytes.subarray(i, i + 16)));
|
|
||||||
}
|
|
||||||
const dumpData = blocksToClassicDump(blocks);
|
|
||||||
return {
|
|
||||||
frequency: "HF",
|
|
||||||
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
|
||||||
uid: uidFromBlock0(blocks[0]),
|
|
||||||
dumpData,
|
|
||||||
keys: keysFromTrailers(dumpData),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxmark `hf-mf-<UID>-key.bin`: 12 bytes/sector (KeyA + KeyB).
|
|
||||||
* Also accepts a hex dictionary (one 12-char key per line).
|
|
||||||
*/
|
|
||||||
export function parseKeyBin(bytes: Uint8Array, filename?: string): TagKeys {
|
|
||||||
if (bytes.length === 0) {
|
|
||||||
throw new Error("Key file is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = (filename ?? "").toLowerCase();
|
|
||||||
const preferText = name.endsWith(".dic") || name.endsWith(".txt");
|
|
||||||
|
|
||||||
if (!preferText && bytes.length % 12 === 0) {
|
|
||||||
const A: string[] = [];
|
|
||||||
const B: string[] = [];
|
|
||||||
for (let i = 0; i < bytes.length; i += 12) {
|
|
||||||
A.push(bytesToHex(bytes.subarray(i, i + 6)));
|
|
||||||
B.push(bytesToHex(bytes.subarray(i + 6, i + 12)));
|
|
||||||
}
|
|
||||||
return { A, B };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preferText && bytes.length % 6 === 0) {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (let i = 0; i < bytes.length; i += 6) {
|
|
||||||
keys.push(bytesToHex(bytes.subarray(i, i + 6)));
|
|
||||||
}
|
|
||||||
return { A: keys, B: keys };
|
|
||||||
}
|
|
||||||
|
|
||||||
const asText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
||||||
const hexKeys = asText
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.replace(/[^0-9A-Fa-f]/g, "").toUpperCase())
|
|
||||||
.filter((l) => l.length === 12);
|
|
||||||
|
|
||||||
if (hexKeys.length > 0) {
|
|
||||||
return { A: hexKeys, B: hexKeys };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Unrecognized Proxmark key.bin size (${bytes.length} bytes). Expected 192 (1K) or 480 (4K) binary, or a hex key dictionary.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeKeys(base: TagKeys | undefined, extra: TagKeys): TagKeys {
|
|
||||||
const sectorCount = Math.max(
|
|
||||||
base?.A?.length ?? 0,
|
|
||||||
base?.B?.length ?? 0,
|
|
||||||
extra.A?.length ?? 0,
|
|
||||||
extra.B?.length ?? 0
|
|
||||||
);
|
|
||||||
if (sectorCount === 0) return extra;
|
|
||||||
|
|
||||||
const A: string[] = [];
|
|
||||||
const B: string[] = [];
|
|
||||||
for (let i = 0; i < sectorCount; i++) {
|
|
||||||
A.push(nonEmptyKey(extra.A?.[i]) || nonEmptyKey(base?.A?.[i]) || "");
|
|
||||||
B.push(nonEmptyKey(extra.B?.[i]) || nonEmptyKey(base?.B?.[i]) || "");
|
|
||||||
}
|
|
||||||
return { A, B };
|
|
||||||
}
|
|
||||||
|
|
||||||
function nonEmptyKey(value: string | undefined): string {
|
|
||||||
return value && /[0-9A-F]/i.test(value) ? value.toUpperCase() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToHex(bytes: Uint8Array): string {
|
|
||||||
return Array.from(bytes)
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("")
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bytesFromBase64(b64: string): Uint8Array {
|
|
||||||
const cleaned = b64.replace(/\s/g, "");
|
|
||||||
const bin =
|
|
||||||
typeof globalThis.atob === "function"
|
|
||||||
? globalThis.atob(cleaned)
|
|
||||||
: Buffer.from(cleaned, "base64").toString("binary");
|
|
||||||
const out = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isKeyBinFilename(name: string): boolean {
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
return lower.includes("-key.bin") || lower.endsWith("key.bin") || lower.includes("keys.bin");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { CanonicalTag } from "@/lib/rfid/types";
|
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export const dumpDataSchema = z.union([
|
|||||||
z.record(z.string(), z.unknown()),
|
z.record(z.string(), z.unknown()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const tagKeysObjectSchema = z
|
export const keysSchema = z
|
||||||
.object({
|
.object({
|
||||||
A: z.array(z.string()).optional(),
|
A: z.array(z.string()).optional(),
|
||||||
B: z.array(z.string()).optional(),
|
B: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough()
|
||||||
|
.nullable()
|
||||||
export const keysSchema = tagKeysObjectSchema.nullable().optional();
|
.optional();
|
||||||
|
|
||||||
export const siteCreateSchema = z.object({
|
export const siteCreateSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -82,4 +82,4 @@ export type MifareClassicDump = z.infer<typeof mifareClassicDumpSchema>;
|
|||||||
export type NtagDump = z.infer<typeof ntagDumpSchema>;
|
export type NtagDump = z.infer<typeof ntagDumpSchema>;
|
||||||
export type LfDump = z.infer<typeof lfDumpSchema>;
|
export type LfDump = z.infer<typeof lfDumpSchema>;
|
||||||
export type DumpData = z.infer<typeof dumpDataSchema>;
|
export type DumpData = z.infer<typeof dumpDataSchema>;
|
||||||
export type TagKeys = z.infer<typeof tagKeysObjectSchema>;
|
export type TagKeys = z.infer<typeof keysSchema>;
|
||||||
|
|||||||
-269
@@ -1,269 +0,0 @@
|
|||||||
{
|
|
||||||
"Created": "proxmark3",
|
|
||||||
"FileType": "mfc v2",
|
|
||||||
"Card": {
|
|
||||||
"UID": "91A2F020",
|
|
||||||
"ATQA": "0400",
|
|
||||||
"SAK": "08"
|
|
||||||
},
|
|
||||||
"blocks": {
|
|
||||||
"0": "91A2F020E30804000497F47A359E4798",
|
|
||||||
"1": "54C017569969134569ED95BD2C8A95FE",
|
|
||||||
"2": "8A000400010000000000000000000000",
|
|
||||||
"3": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"4": "2200020000000000000000C10000001E",
|
|
||||||
"5": "2200020000000000000000C10000001E",
|
|
||||||
"6": "00000000000000000000000000000000",
|
|
||||||
"7": "2A2C13CC242AFF078069FFFFFFFFFFFF",
|
|
||||||
"8": "00000000000000000000000000000000",
|
|
||||||
"9": "00000000000000000000000000000000",
|
|
||||||
"10": "00000000000000000000000000000000",
|
|
||||||
"11": "FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
|
|
||||||
"12": "00000000000000000000000000000000",
|
|
||||||
"13": "00000000000000000000000000000000",
|
|
||||||
"14": "00000000000000000000000000000000",
|
|
||||||
"15": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"16": "AE48B81460060028AE48B81C200600F0",
|
|
||||||
"17": "00000000000000000000000000000000",
|
|
||||||
"18": "00000000000000000000000000000000",
|
|
||||||
"19": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"20": "00000000000000000000000000000000",
|
|
||||||
"21": "00000000000000000000000000000000",
|
|
||||||
"22": "00000000000000000000000000000000",
|
|
||||||
"23": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"24": "00000000000000000000000000000000",
|
|
||||||
"25": "00000000000000000000000000000000",
|
|
||||||
"26": "00000000000000000000000000000000",
|
|
||||||
"27": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"28": "00000000000000000000000000000000",
|
|
||||||
"29": "00000000000000000000000000000000",
|
|
||||||
"30": "00000000000000000000000000000000",
|
|
||||||
"31": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"32": "00000000000000000000000000000000",
|
|
||||||
"33": "00000000000000000000000000000000",
|
|
||||||
"34": "00000000000000000000000000000000",
|
|
||||||
"35": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"36": "00000000000000000000000000000000",
|
|
||||||
"37": "00000000000000000000000000000000",
|
|
||||||
"38": "00000000000000000000000000000000",
|
|
||||||
"39": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"40": "00000000000000000000000000000000",
|
|
||||||
"41": "00000000000000000000000000000000",
|
|
||||||
"42": "00000000000000000000000000000000",
|
|
||||||
"43": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"44": "00000000000000000000000000000000",
|
|
||||||
"45": "00000000000000000000000000000000",
|
|
||||||
"46": "00000000000000000000000000000000",
|
|
||||||
"47": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"48": "00000000000000000000000000000000",
|
|
||||||
"49": "00000000000000000000000000000000",
|
|
||||||
"50": "00000000000000000000000000000000",
|
|
||||||
"51": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"52": "00000000000000000000000000000000",
|
|
||||||
"53": "00000000000000000000000000000000",
|
|
||||||
"54": "00000000000000000000000000000000",
|
|
||||||
"55": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"56": "00000000000000000000000000000000",
|
|
||||||
"57": "00000000000000000000000000000000",
|
|
||||||
"58": "00000000000000000000000000000000",
|
|
||||||
"59": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
|
||||||
"60": "00000000000000000000000000000000",
|
|
||||||
"61": "00000000000000000000000000000000",
|
|
||||||
"62": "00000000000000000000000000000000",
|
|
||||||
"63": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF"
|
|
||||||
},
|
|
||||||
"SectorKeys": {
|
|
||||||
"0": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block0": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block1": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block2": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block3": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"1": {
|
|
||||||
"KeyA": "2A2C13CC242A",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block4": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block5": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block6": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block7": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"KeyA": "FFFFFFFFFFFF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block8": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block9": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block10": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block11": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block12": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block13": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block14": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block15": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"4": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block16": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block17": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block18": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block19": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"5": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block20": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block21": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block22": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block23": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block24": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block25": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block26": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block27": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block28": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block29": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block30": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block31": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block32": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block33": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block34": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block35": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"9": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block36": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block37": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block38": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block39": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"10": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block40": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block41": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block42": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block43": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block44": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block45": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block46": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block47": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block48": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block49": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block50": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block51": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block52": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block53": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block54": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block55": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"14": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block56": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block57": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block58": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block59": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"15": {
|
|
||||||
"KeyA": "A1D4FAFD4CAF",
|
|
||||||
"KeyB": "FFFFFFFFFFFF",
|
|
||||||
"AccessConditions": "FF078069",
|
|
||||||
"AccessConditionsText": {
|
|
||||||
"block60": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block61": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block62": "read AB; write AB; increment AB; decrement transfer restore AB",
|
|
||||||
"block63": "write A by A; read/write ACCESS by A; read/write B by A",
|
|
||||||
"UserData": "69"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -270,38 +270,4 @@ FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
|||||||
);
|
);
|
||||||
expect(restore.status).toBe(200);
|
expect(restore.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("imports Proxmark mfc v2 JSON dumps", async () => {
|
|
||||||
const auth = { Authorization: `Bearer ${bearer}` };
|
|
||||||
const siteRes = await postSite(
|
|
||||||
req("http://localhost/api/v1/sites", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...auth, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ code: "PM3", name: "Proxmark" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const site = await siteRes.json();
|
|
||||||
const dump = fs.readFileSync(
|
|
||||||
path.join(import.meta.dirname, "../fixtures/hf-mf-91A2F020-dump.json"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
const imported = await importTag(
|
|
||||||
req("http://localhost/api/v1/tags/import", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...auth, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
siteId: site.id,
|
|
||||||
label: "Proxmark v2",
|
|
||||||
content: dump,
|
|
||||||
filename: "hf-mf-91A2F020-dump.json",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(imported.status).toBe(201);
|
|
||||||
const tag = await imported.json();
|
|
||||||
expect(tag.uid).toBe("91A2F020");
|
|
||||||
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
|
||||||
expect(tag.keys.A[0]).toBe("A1D4FAFD4CAF");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { emailFromOidcProfile } from "@/lib/auth/oidc";
|
|
||||||
|
|
||||||
describe("emailFromOidcProfile", () => {
|
|
||||||
it("reads email claim", () => {
|
|
||||||
expect(emailFromOidcProfile({ email: "[email protected]" })).toBe(
|
|
||||||
"[email protected]"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to preferred_username when it looks like an email", () => {
|
|
||||||
expect(
|
|
||||||
emailFromOidcProfile({ preferred_username: "[email protected]" })
|
|
||||||
).toBe("[email protected]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-email preferred_username", () => {
|
|
||||||
expect(emailFromOidcProfile({ preferred_username: "nick" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when nothing usable is present", () => {
|
|
||||||
expect(emailFromOidcProfile({ sub: "abc", name: "Nick" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import fs from "node:fs";
|
import { parseMct, parseImport } from "@/lib/rfid/parsers";
|
||||||
import path from "node:path";
|
|
||||||
import { parseMct, parseImport, parseDump, parseKeyBin } from "@/lib/rfid/parsers";
|
|
||||||
import { exportTag } from "@/lib/rfid/exporters";
|
import { exportTag } from "@/lib/rfid/exporters";
|
||||||
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
|
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
|
||||||
import { isMifareClassicDump } from "@/lib/rfid/dump-types";
|
|
||||||
|
|
||||||
const sampleMct = `+UID: 04A1B2C3
|
const sampleMct = `+UID: 04A1B2C3
|
||||||
+Sector: 0
|
+Sector: 0
|
||||||
@@ -63,91 +60,3 @@ describe("MCT parser/exporter", () => {
|
|||||||
expect(tag.label).toBe("Dock");
|
expect(tag.label).toBe("Dock");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Proxmark dump import", () => {
|
|
||||||
const fixturePath = path.join(
|
|
||||||
import.meta.dirname,
|
|
||||||
"../fixtures/hf-mf-91A2F020-dump.json"
|
|
||||||
);
|
|
||||||
const proxmarkV2 = fs.readFileSync(fixturePath, "utf8");
|
|
||||||
|
|
||||||
it("parses mfc v2 JSON with object blocks and SectorKeys", () => {
|
|
||||||
const tag = parseImport(proxmarkV2, "hf-mf-91A2F020-dump.json");
|
|
||||||
expect(tag.uid).toBe("91A2F020");
|
|
||||||
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
|
||||||
expect(mifareClassicDumpSchema.safeParse(tag.dumpData).success).toBe(true);
|
|
||||||
expect(isMifareClassicDump(tag.dumpData)).toBe(true);
|
|
||||||
if (isMifareClassicDump(tag.dumpData)) {
|
|
||||||
expect(tag.dumpData.sectors).toHaveLength(16);
|
|
||||||
expect(tag.dumpData.sectors[0].blocks[0]).toBe(
|
|
||||||
"91A2F020E30804000497F47A359E4798"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(tag.keys?.A?.[0]).toBe("A1D4FAFD4CAF");
|
|
||||||
expect(tag.keys?.B?.[0]).toBe("FFFFFFFFFFFF");
|
|
||||||
expect(tag.keys?.A?.[1]).toBe("2A2C13CC242A");
|
|
||||||
expect(tag.keys?.A).toHaveLength(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses mfc v2 JSON pasted inside a markdown fence", () => {
|
|
||||||
const tag = parseImport(
|
|
||||||
"```json\n" + proxmarkV2 + "\n```",
|
|
||||||
"paste.txt"
|
|
||||||
);
|
|
||||||
expect(tag.uid).toBe("91A2F020");
|
|
||||||
expect(tag.keys?.A?.[0]).toBe("A1D4FAFD4CAF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a 192-byte 1K key.bin", () => {
|
|
||||||
const bytes = new Uint8Array(192);
|
|
||||||
bytes.set(hexToBytes("A1D4FAFD4CAF"), 0);
|
|
||||||
bytes.set(hexToBytes("FFFFFFFFFFFF"), 6);
|
|
||||||
bytes.set(hexToBytes("2A2C13CC242A"), 12);
|
|
||||||
bytes.set(hexToBytes("FFFFFFFFFFFF"), 18);
|
|
||||||
const keys = parseKeyBin(bytes, "hf-mf-91A2F020-key.bin");
|
|
||||||
expect(keys.A).toHaveLength(16);
|
|
||||||
expect(keys.A?.[0]).toBe("A1D4FAFD4CAF");
|
|
||||||
expect(keys.B?.[0]).toBe("FFFFFFFFFFFF");
|
|
||||||
expect(keys.A?.[1]).toBe("2A2C13CC242A");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges key.bin onto a dump that has no SectorKeys", () => {
|
|
||||||
const dump = JSON.parse(proxmarkV2) as { SectorKeys?: unknown };
|
|
||||||
delete dump.SectorKeys;
|
|
||||||
const keys = new Uint8Array(192);
|
|
||||||
keys.set(hexToBytes("AABBCCDDEEFF"), 0);
|
|
||||||
keys.set(hexToBytes("112233445566"), 6);
|
|
||||||
const tag = parseDump({
|
|
||||||
text: JSON.stringify(dump),
|
|
||||||
filename: "hf-mf-91A2F020-dump.json",
|
|
||||||
keysBytes: keys,
|
|
||||||
keysFilename: "hf-mf-91A2F020-key.bin",
|
|
||||||
});
|
|
||||||
expect(tag.keys?.A?.[0]).toBe("AABBCCDDEEFF");
|
|
||||||
expect(tag.keys?.B?.[0]).toBe("112233445566");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses a 1024-byte dump.bin", () => {
|
|
||||||
const bytes = new Uint8Array(1024);
|
|
||||||
const block0 = hexToBytes("91A2F020E30804000497F47A359E4798");
|
|
||||||
bytes.set(block0, 0);
|
|
||||||
const tag = parseDump({ bytes, filename: "hf-mf-91A2F020.bin" });
|
|
||||||
expect(tag.uid).toBe("91A2F020");
|
|
||||||
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects key.bin uploaded as the dump", () => {
|
|
||||||
const bytes = new Uint8Array(192);
|
|
||||||
expect(() =>
|
|
||||||
parseDump({ bytes, filename: "hf-mf-91A2F020-key.bin" })
|
|
||||||
).toThrow(/key\.bin/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function hexToBytes(hex: string): Uint8Array {
|
|
||||||
const out = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < out.length; i++) {
|
|
||||||
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user