mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fcc106a3a | ||
|
|
21e2592f92 | ||
|
|
96c6df2fff | ||
|
|
c33f651cfe | ||
|
|
443dba5bba | ||
|
|
3bcdc14106 | ||
|
|
f2de0e3892 | ||
|
|
cd1a2fe808 | ||
|
|
6d76fecde9 |
@@ -22,11 +22,3 @@ jobs:
|
||||
- run: npm run build
|
||||
env:
|
||||
AUTH_SECRET: ci-build-secret-at-least-32-characters
|
||||
|
||||
docker:
|
||||
name: docker-build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build production image
|
||||
run: docker build -t rfid-database:ci .
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# GHCR requires lowercase image names
|
||||
IMAGE_NAME: ghcr.io/chewbaccalakis/rfid-database
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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
|
||||
|
||||
@@ -39,28 +39,56 @@ Open http://localhost:3000 and sign in.
|
||||
|
||||
### Enabling OIDC
|
||||
|
||||
1. In your IdP, create a **confidential** OIDC application.
|
||||
2. Set the redirect / callback URI to:
|
||||
1. **Pull latest and rebuild** (OIDC button detection changed recently):
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
2. In your IdP, create a **confidential** OIDC application.
|
||||
3. Set the redirect / callback URI to **exactly**:
|
||||
```
|
||||
{AUTH_URL}/api/auth/callback/oidc
|
||||
```
|
||||
Example: `http://localhost:3000/api/auth/callback/oidc`
|
||||
3. Put these in `.env` (Docker Compose reads `.env` automatically):
|
||||
Example: `https://rfid.atlashorizon.net/api/auth/callback/oidc`
|
||||
4. Put these in `.env`:
|
||||
```bash
|
||||
AUTH_URL=http://localhost:3000
|
||||
AUTH_URL=https://rfid.atlashorizon.net
|
||||
AUTH_SECRET=...long random...
|
||||
AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
|
||||
AUTH_OIDC_CLIENT_ID=...
|
||||
AUTH_OIDC_ISSUER=https://auth.atlashorizon.net
|
||||
AUTH_OIDC_CLIENT_ID=rfiddb
|
||||
AUTH_OIDC_CLIENT_SECRET=...
|
||||
AUTH_OIDC_NAME=Authentik
|
||||
AUTH_OIDC_NAME=AtlasHorizon
|
||||
```
|
||||
4. Restart the app (`docker compose up -d` or restart `npm run dev`).
|
||||
5. Open `/login` — you should see **Sign in with Authentik** (or your `AUTH_OIDC_NAME`).
|
||||
5. Restart, then verify the running app sees config (no secrets returned):
|
||||
```bash
|
||||
curl -s https://rfid.atlashorizon.net/api/v1/auth/config | jq
|
||||
```
|
||||
You want `"oidcEnabled": true` and `"callbackUrl"` matching your IdP.
|
||||
6. Open `/login` — you should see **Sign in with AtlasHorizon**.
|
||||
|
||||
Notes:
|
||||
- `AUTH_OIDC_ISSUER` must be exactly the issuer value from discovery (trailing slash matters for some IdPs).
|
||||
- The IdP must return an **email** claim; accounts are created/linked by email.
|
||||
- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`).
|
||||
- The app requests `openid email profile` and loads profile from **UserInfo** (Authelia puts `email` there by default).
|
||||
- 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.
|
||||
- If the button is missing, you are almost certainly on an old image — rebuild.
|
||||
|
||||
## REST API (`/api/v1`)
|
||||
|
||||
@@ -74,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 |
|
||||
@@ -96,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 "[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
|
||||
|
||||
```bash
|
||||
@@ -107,10 +156,26 @@ npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
GitHub Actions runs on every pull request and push to `main`:
|
||||
GitHub Actions on every pull request and push to `main`:
|
||||
|
||||
1. **lint / typecheck / test / Next.js build**
|
||||
2. **`docker build`** — catches Dockerfile parse/build failures (the class of break that conflict markers previously caused)
|
||||
2. **Docker image** — build on PRs; on `main` (and `v*` tags) push to GHCR:
|
||||
`ghcr.io/chewbaccalakis/rfid-database`
|
||||
|
||||
### Pull the published image
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/chewbaccalakis/rfid-database:latest
|
||||
# or a specific commit: ghcr.io/chewbaccalakis/rfid-database:sha-<shortsha>
|
||||
```
|
||||
|
||||
If the package is private, authenticate first:
|
||||
|
||||
```bash
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
|
||||
```
|
||||
|
||||
Or in Compose, set `image: ghcr.io/chewbaccalakis/rfid-database:latest` and comment out `build:`.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
services:
|
||||
rfid-database:
|
||||
# Local build (default):
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: rfid-database:local
|
||||
# Or pull from GHCR instead of building:
|
||||
# image: ghcr.io/chewbaccalakis/rfid-database:latest
|
||||
# pull_policy: always
|
||||
container_name: rfid-database
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { jsonOk } from "@/lib/api/errors";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Public auth diagnostics (no secrets).
|
||||
* Useful to verify the running container sees OIDC env vars.
|
||||
*/
|
||||
export async function GET() {
|
||||
const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, "");
|
||||
const oidcEnabled = isOidcConfigured();
|
||||
|
||||
return jsonOk({
|
||||
oidcEnabled,
|
||||
oidcName: process.env.AUTH_OIDC_NAME || "SSO",
|
||||
issuer: process.env.AUTH_OIDC_ISSUER || null,
|
||||
clientIdSet: Boolean(process.env.AUTH_OIDC_CLIENT_ID?.trim()),
|
||||
clientSecretSet: Boolean(process.env.AUTH_OIDC_CLIENT_SECRET?.trim()),
|
||||
authUrl: authUrl || null,
|
||||
authSecretSet: Boolean(
|
||||
process.env.AUTH_SECRET?.trim() &&
|
||||
process.env.AUTH_SECRET !== "change-me-to-a-long-random-string"
|
||||
),
|
||||
callbackUrl: authUrl
|
||||
? `${authUrl}/api/auth/callback/oidc`
|
||||
: "/api/auth/callback/oidc",
|
||||
hint: oidcEnabled
|
||||
? "OIDC looks configured. Register callbackUrl exactly in your IdP."
|
||||
: "Set AUTH_OIDC_ISSUER, AUTH_OIDC_CLIENT_ID, and AUTH_OIDC_CLIENT_SECRET, then restart.",
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
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),
|
||||
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { LoginPageClient } from "@/components/LoginPageClient";
|
||||
|
||||
// OIDC env is only available at runtime (Docker). Never prerender this page
|
||||
// at build time or the SSO button stays permanently hidden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<LoginPageClient
|
||||
|
||||
+240
-26
@@ -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<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(
|
||||
() => (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 (
|
||||
<div>
|
||||
<h1 className="page-title">New tag</h1>
|
||||
<p className="page-sub">Store a dump for later write-out</p>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{notice && <div className="success-banner">{notice}</div>}
|
||||
|
||||
<div className="row" style={{ marginBottom: "1rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
||||
onClick={() => setImportMode(false)}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
||||
@@ -140,12 +221,24 @@ function NewTagForm() {
|
||||
>
|
||||
Import dump
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
||||
onClick={() => setImportMode(false)}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="panel stack" onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label className="label">Site</label>
|
||||
<select className="select" value={siteId} onChange={(e) => setSiteId(e.target.value)} required>
|
||||
<select
|
||||
className="select"
|
||||
value={siteId}
|
||||
onChange={(e) => setSiteId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select site
|
||||
</option>
|
||||
@@ -159,7 +252,13 @@ function NewTagForm() {
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Label</label>
|
||||
<input className="input" value={label} onChange={(e) => setLabel(e.target.value)} required={!importMode} placeholder="Front door fob" />
|
||||
<input
|
||||
className="input"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
required={!importMode}
|
||||
placeholder="Front door fob"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!importMode && (
|
||||
@@ -187,26 +286,93 @@ function NewTagForm() {
|
||||
|
||||
<div className="field">
|
||||
<label className="label">UID</label>
|
||||
<input className="input mono" value={uid} onChange={(e) => setUid(e.target.value)} required placeholder="04:A1:B2:C3" />
|
||||
<input
|
||||
className="input mono"
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
required
|
||||
placeholder="04:A1:B2:C3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">dumpData (JSON, optional — defaults applied)</label>
|
||||
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} placeholder='{"size":"1K","sectors":[...]}' />
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={dumpText}
|
||||
onChange={(e) => setDumpText(e.target.value)}
|
||||
placeholder='{"size":"1K","sectors":[...]}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">keys (JSON, optional)</label>
|
||||
<textarea className="textarea" value={keysText} onChange={(e) => setKeysText(e.target.value)} placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}' />
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={keysText}
|
||||
onChange={(e) => setKeysText(e.target.value)}
|
||||
placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importMode && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label className="label">Paste JSON / MCT / Proxmark dump</label>
|
||||
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} required style={{ minHeight: "14rem" }} />
|
||||
<label className="label">Dump file</label>
|
||||
<input
|
||||
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 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">
|
||||
@@ -227,6 +393,54 @@ 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() {
|
||||
return (
|
||||
<Suspense>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
@@ -10,14 +10,56 @@ type Props = {
|
||||
oidcName: string;
|
||||
};
|
||||
|
||||
type AuthConfig = {
|
||||
oidcEnabled: boolean;
|
||||
oidcName: string;
|
||||
};
|
||||
|
||||
function authErrorMessage(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
switch (code) {
|
||||
case "EmailRequired":
|
||||
return "Your IdP did not return an email claim. Enable the email scope/claim for this OIDC client.";
|
||||
case "OAuthCallbackError":
|
||||
case "Callback":
|
||||
return "OIDC callback failed. Check redirect URI, client secret, and container logs.";
|
||||
case "OAuthSignin":
|
||||
return "Could not start OIDC login. Check AUTH_OIDC_ISSUER discovery and client id.";
|
||||
case "Configuration":
|
||||
return "Auth configuration error. Verify AUTH_SECRET, AUTH_URL, and OIDC env vars.";
|
||||
case "AccessDenied":
|
||||
return "Access denied by the identity provider.";
|
||||
default:
|
||||
return `Sign-in error: ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
function LoginForm({ oidcEnabled, oidcName }: Props) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const callbackUrl = params.get("callbackUrl") || "/";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(
|
||||
authErrorMessage(params.get("error"))
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [runtimeOidc, setRuntimeOidc] = useState<AuthConfig | null>(null);
|
||||
|
||||
// Belt-and-suspenders: ask the live API so a stale static shell can't hide SSO
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/auth/config")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: AuthConfig | null) => {
|
||||
if (data) setRuntimeOidc(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showOidc = runtimeOidc?.oidcEnabled ?? oidcEnabled;
|
||||
const displayName = runtimeOidc?.oidcName || oidcName;
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -75,13 +117,13 @@ function LoginForm({ oidcEnabled, oidcName }: Props) {
|
||||
<button className="btn" type="submit" disabled={loading}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
{oidcEnabled && (
|
||||
{showOidc && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => signIn("oidc", { callbackUrl })}
|
||||
>
|
||||
Sign in with {oidcName}
|
||||
Sign in with {displayName}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
+47
-6
@@ -6,9 +6,9 @@ import { eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db/client";
|
||||
import { users } from "@/db/schema";
|
||||
import { authConfig } from "@/lib/auth/auth.config";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
import { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
export { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
export { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
function buildProviders(): Provider[] {
|
||||
const providers: Provider[] = [
|
||||
@@ -36,15 +36,44 @@ function buildProviders(): Provider[] {
|
||||
];
|
||||
|
||||
if (isOidcConfigured()) {
|
||||
const issuer = process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, "");
|
||||
providers.push({
|
||||
id: "oidc",
|
||||
name: process.env.AUTH_OIDC_NAME || "SSO",
|
||||
type: "oidc",
|
||||
issuer: process.env.AUTH_OIDC_ISSUER!,
|
||||
issuer,
|
||||
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
||||
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
||||
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: {
|
||||
params: {
|
||||
scope: "openid email profile",
|
||||
},
|
||||
},
|
||||
client: {
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
},
|
||||
// Link OIDC logins to existing local users by email
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
profile(profile: Record<string, unknown>) {
|
||||
const email = emailFromOidcProfile(profile);
|
||||
return {
|
||||
id: String(profile.sub ?? email ?? crypto.randomUUID()),
|
||||
name:
|
||||
(typeof profile.name === "string" && profile.name) ||
|
||||
(typeof profile.preferred_username === "string" &&
|
||||
profile.preferred_username) ||
|
||||
email ||
|
||||
"OIDC user",
|
||||
email: email ?? undefined,
|
||||
image: typeof profile.picture === "string" ? profile.picture : null,
|
||||
};
|
||||
},
|
||||
} as Provider);
|
||||
}
|
||||
|
||||
@@ -56,12 +85,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: buildProviders(),
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
async signIn({ user, account, profile }) {
|
||||
if (account?.provider === "credentials") return true;
|
||||
if (!user.email) return false;
|
||||
|
||||
const email =
|
||||
user.email?.trim().toLowerCase() ||
|
||||
emailFromOidcProfile((profile ?? {}) as Record<string, unknown>);
|
||||
|
||||
if (!email) {
|
||||
console.error(
|
||||
"[auth] OIDC sign-in rejected: no email in profile. Claim keys:",
|
||||
profile ? Object.keys(profile) : []
|
||||
);
|
||||
return "/login?error=EmailRequired";
|
||||
}
|
||||
|
||||
user.email = email;
|
||||
|
||||
const db = getDb();
|
||||
const email = user.email.toLowerCase();
|
||||
let existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (!existing) {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
@@ -5,3 +5,23 @@ export function isOidcConfigured(): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
+394
-34
@@ -1,68 +1,292 @@
|
||||
import type { CanonicalTag } from "@/lib/rfid/types";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
import type { Protocol } from "@/lib/rfid/protocols";
|
||||
import type { MifareClassicDump } from "@/lib/validation/rfid";
|
||||
import type { MifareClassicDump, TagKeys } 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(
|
||||
content: string,
|
||||
filenameHint?: string
|
||||
filenameHint?: string,
|
||||
extra?: Omit<ParseDumpOptions, "text" | "filename">
|
||||
): CanonicalTag {
|
||||
const trimmed = content.trim();
|
||||
const lower = (filenameHint ?? "").toLowerCase();
|
||||
return parseDump({
|
||||
text: content,
|
||||
filename: filenameHint,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
|
||||
return parseJsonDump(trimmed);
|
||||
export function parseDump(input: ParseDumpOptions): CanonicalTag {
|
||||
const filename = (input.filename ?? "").toLowerCase();
|
||||
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));
|
||||
}
|
||||
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
|
||||
return parseMct(trimmed);
|
||||
}
|
||||
// Heuristic: MCT-like or hex lines
|
||||
if (trimmed.includes("+Sector:") || trimmed.includes("+UID:")) {
|
||||
return parseMct(trimmed);
|
||||
// JSON/MCT uploaded as application/octet-stream should not be treated as dump.bin.
|
||||
bytes = undefined;
|
||||
}
|
||||
|
||||
if (bytes && bytes.length > 0 && looksLikeKeyBin(filename, bytes) && !text.trim()) {
|
||||
throw new Error(
|
||||
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
|
||||
`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.`
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonDump(content: string): CanonicalTag {
|
||||
const data = JSON.parse(content) as Record<string, unknown>;
|
||||
let tag: CanonicalTag;
|
||||
|
||||
// Proxmark-style
|
||||
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
|
||||
return parseProxmarkJson(data);
|
||||
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 {
|
||||
let data: 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();
|
||||
|
||||
if (
|
||||
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) {
|
||||
return {
|
||||
label: typeof data.label === "string" ? data.label : undefined,
|
||||
frequency: (data.frequency as "LF" | "HF") ?? "HF",
|
||||
protocol: data.protocol as Protocol,
|
||||
protocol: data.protocol as CanonicalTag["protocol"],
|
||||
uid: normalizeUid(String(data.uid)),
|
||||
dumpData: data.dumpData,
|
||||
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
|
||||
keys: isKeySet(data.keys) ? data.keys : undefined,
|
||||
notes: typeof data.notes === "string" ? data.notes : null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
|
||||
throw new Error(
|
||||
"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 {
|
||||
const start = content.indexOf("{");
|
||||
const end = content.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) return null;
|
||||
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");
|
||||
}
|
||||
|
||||
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
|
||||
const card = (data.Card as { UID?: string } | undefined) ?? {};
|
||||
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
|
||||
const blocks = (data.blocks as string[]) ?? [];
|
||||
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 {
|
||||
frequency: "HF",
|
||||
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||
uid,
|
||||
dumpData,
|
||||
keys: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
|
||||
const size = normalized.length > 64 ? "4K" : "1K";
|
||||
@@ -81,7 +305,10 @@ function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
||||
}
|
||||
|
||||
export function parseMct(content: string): CanonicalTag {
|
||||
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
const lines = unwrapDumpText(content)
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
let uid = "";
|
||||
const sectors: MifareClassicDump["sectors"] = [];
|
||||
let current: { index: number; blocks: string[] } | null = null;
|
||||
@@ -106,21 +333,154 @@ export function parseMct(content: string): CanonicalTag {
|
||||
if (current) sectors.push(current);
|
||||
|
||||
if (!uid) {
|
||||
// try first block of sector 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");
|
||||
|
||||
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
|
||||
const size = totalBlocks > 64 ? "4K" : "1K";
|
||||
const dumpData: MifareClassicDump = { size, sectors };
|
||||
|
||||
return {
|
||||
frequency: "HF",
|
||||
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||
uid,
|
||||
dumpData: { size, sectors },
|
||||
dumpData,
|
||||
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()),
|
||||
]);
|
||||
|
||||
export const keysSchema = z
|
||||
export const tagKeysObjectSchema = z
|
||||
.object({
|
||||
A: z.array(z.string()).optional(),
|
||||
B: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional();
|
||||
.passthrough();
|
||||
|
||||
export const keysSchema = tagKeysObjectSchema.nullable().optional();
|
||||
|
||||
export const siteCreateSchema = z.object({
|
||||
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 LfDump = z.infer<typeof lfDumpSchema>;
|
||||
export type DumpData = z.infer<typeof dumpDataSchema>;
|
||||
export type TagKeys = z.infer<typeof keysSchema>;
|
||||
export type TagKeys = z.infer<typeof tagKeysObjectSchema>;
|
||||
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"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,4 +270,38 @@ FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMct, parseImport } from "@/lib/rfid/parsers";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseMct, parseImport, parseDump, parseKeyBin } from "@/lib/rfid/parsers";
|
||||
import { exportTag } from "@/lib/rfid/exporters";
|
||||
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
|
||||
import { isMifareClassicDump } from "@/lib/rfid/dump-types";
|
||||
|
||||
const sampleMct = `+UID: 04A1B2C3
|
||||
+Sector: 0
|
||||
@@ -60,3 +63,91 @@ describe("MCT parser/exporter", () => {
|
||||
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