mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f4ec0a9c3 |
+4
-20
@@ -1,10 +1,4 @@
|
|||||||
AUTH_SECRET=change-me-to-a-long-random-string
|
AUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
# Public URL of this app (important behind reverse proxies / Docker)
|
|
||||||
# Example: http://localhost:3000 or https://rfid.example.com
|
|
||||||
# AUTH_URL=http://localhost:3000
|
|
||||||
# AUTH_TRUST_HOST=true
|
|
||||||
|
|
||||||
# Optional: path to SQLite file (default ./data/rfid.db)
|
# Optional: path to SQLite file (default ./data/rfid.db)
|
||||||
# RFID_DB_PATH=./data/rfid.db
|
# RFID_DB_PATH=./data/rfid.db
|
||||||
|
|
||||||
@@ -16,20 +10,10 @@ AUTH_SECRET=change-me-to-a-long-random-string
|
|||||||
# CREATE_USER_PASSWORD=changeme
|
# CREATE_USER_PASSWORD=changeme
|
||||||
# CREATE_USER_NAME=Admin
|
# CREATE_USER_NAME=Admin
|
||||||
|
|
||||||
# Optional OIDC (Authentik, Keycloak, Authelia, Google, etc.)
|
# Optional OIDC (Authentik, Keycloak, Authelia, etc.)
|
||||||
# Setting these three enables the "Sign in with SSO" button automatically
|
|
||||||
# (no NEXT_PUBLIC_* flag needed).
|
|
||||||
#
|
|
||||||
# In your IdP, create a confidential OIDC client with redirect URI:
|
|
||||||
# {AUTH_URL}/api/auth/callback/oidc
|
|
||||||
# e.g. http://localhost:3000/api/auth/callback/oidc
|
|
||||||
#
|
|
||||||
# AUTH_OIDC_ISSUER must be the issuer that serves
|
|
||||||
# {issuer}/.well-known/openid-configuration
|
|
||||||
# Authentik example: https://sso.example.com/application/o/rfid/
|
|
||||||
# Keycloak example: https://sso.example.com/realms/myrealm
|
|
||||||
#
|
|
||||||
# AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
|
# AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
|
||||||
# AUTH_OIDC_CLIENT_ID=
|
# AUTH_OIDC_CLIENT_ID=
|
||||||
# AUTH_OIDC_CLIENT_SECRET=
|
# AUTH_OIDC_CLIENT_SECRET=
|
||||||
# AUTH_OIDC_NAME=Authentik
|
# AUTH_OIDC_NAME=SSO
|
||||||
|
# NEXT_PUBLIC_AUTH_OIDC_ENABLED=1
|
||||||
|
# NEXT_PUBLIC_AUTH_OIDC_NAME=SSO
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
env:
|
env:
|
||||||
AUTH_SECRET: ci-build-secret-at-least-32-characters
|
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 .
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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
|
- 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
|
||||||
|
|
||||||
@@ -30,65 +30,13 @@ Open http://localhost:3000 and sign in.
|
|||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `AUTH_SECRET` | yes | NextAuth secret |
|
| `AUTH_SECRET` | yes | NextAuth secret |
|
||||||
| `AUTH_URL` | recommended | Public app URL (e.g. `https://rfid.example.com`) |
|
|
||||||
| `RFID_DB_PATH` | no | SQLite path (default `./data/rfid.db`) |
|
| `RFID_DB_PATH` | no | SQLite path (default `./data/rfid.db`) |
|
||||||
| `AUTH_OIDC_ISSUER` | for OIDC | Issuer URL (must expose `/.well-known/openid-configuration`) |
|
| `AUTH_OIDC_ISSUER` | no | OIDC issuer URL |
|
||||||
| `AUTH_OIDC_CLIENT_ID` | for OIDC | OIDC client id |
|
| `AUTH_OIDC_CLIENT_ID` | no | OIDC client id |
|
||||||
| `AUTH_OIDC_CLIENT_SECRET` | for OIDC | OIDC client secret |
|
| `AUTH_OIDC_CLIENT_SECRET` | no | OIDC client secret |
|
||||||
| `AUTH_OIDC_NAME` | no | SSO button label (default `SSO`) |
|
| `AUTH_OIDC_NAME` | no | Button label (default `SSO`) |
|
||||||
|
| `NEXT_PUBLIC_AUTH_OIDC_ENABLED` | no | Set `1` to show SSO button |
|
||||||
### Enabling OIDC
|
| `NEXT_PUBLIC_AUTH_OIDC_NAME` | no | Public SSO button label |
|
||||||
|
|
||||||
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: `https://rfid.atlashorizon.net/api/auth/callback/oidc`
|
|
||||||
4. Put these in `.env`:
|
|
||||||
```bash
|
|
||||||
AUTH_URL=https://rfid.atlashorizon.net
|
|
||||||
AUTH_SECRET=...long random...
|
|
||||||
AUTH_OIDC_ISSUER=https://auth.atlashorizon.net
|
|
||||||
AUTH_OIDC_CLIENT_ID=rfiddb
|
|
||||||
AUTH_OIDC_CLIENT_SECRET=...
|
|
||||||
AUTH_OIDC_NAME=AtlasHorizon
|
|
||||||
```
|
|
||||||
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 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`)
|
## REST API (`/api/v1`)
|
||||||
|
|
||||||
@@ -102,7 +50,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 +72,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
|
||||||
@@ -156,26 +83,10 @@ npm run typecheck
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions on every pull request and push to `main`:
|
GitHub Actions runs on every pull request and push to `main`:
|
||||||
|
|
||||||
1. **lint / typecheck / test / Next.js build**
|
1. **lint / typecheck / test / Next.js build**
|
||||||
2. **Docker image** — build on PRs; on `main` (and `v*` tags) push to GHCR:
|
2. **`docker build`** — catches Dockerfile parse/build failures (the class of break that conflict markers previously caused)
|
||||||
`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
|
## Docker
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -1,13 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
rfid-database:
|
rfid-database:
|
||||||
# Local build (default):
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: rfid-database:local
|
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
|
container_name: rfid-database
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -20,12 +16,15 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
AUTH_TRUST_HOST: "true"
|
AUTH_TRUST_HOST: "true"
|
||||||
# OIDC: set AUTH_OIDC_ISSUER / CLIENT_ID / CLIENT_SECRET (and AUTH_URL)
|
|
||||||
# in .env — the login page picks them up at runtime.
|
|
||||||
# Optional bootstrap (only creates if missing):
|
# Optional bootstrap (only creates if missing):
|
||||||
# CREATE_USER_EMAIL: [email protected]
|
# CREATE_USER_EMAIL: [email protected]
|
||||||
# CREATE_USER_PASSWORD: change-me
|
# CREATE_USER_PASSWORD: change-me
|
||||||
# CREATE_USER_NAME: Admin
|
# CREATE_USER_NAME: Admin
|
||||||
|
# Optional OIDC — also set NEXT_PUBLIC_* in .env if using SSO button
|
||||||
|
# AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-}
|
||||||
|
# AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-}
|
||||||
|
# AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-}
|
||||||
|
# AUTH_OIDC_NAME: ${AUTH_OIDC_NAME:-SSO}
|
||||||
volumes:
|
volumes:
|
||||||
- rfid-data:/data
|
- rfid-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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,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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
+89
-9
@@ -1,15 +1,95 @@
|
|||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
"use client";
|
||||||
import { LoginPageClient } from "@/components/LoginPageClient";
|
|
||||||
|
|
||||||
// OIDC env is only available at runtime (Docker). Never prerender this page
|
import { FormEvent, useState } from "react";
|
||||||
// at build time or the SSO button stays permanently hidden.
|
import { signIn } from "next-auth/react";
|
||||||
export const dynamic = "force-dynamic";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const oidcName = process.env.NEXT_PUBLIC_AUTH_OIDC_NAME || "SSO";
|
||||||
|
const oidcEnabled = process.env.NEXT_PUBLIC_AUTH_OIDC_ENABLED === "1";
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res?.error) {
|
||||||
|
setError("Invalid email or password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 400, margin: "3rem auto" }}>
|
||||||
|
<h1 className="page-title">RFID Database</h1>
|
||||||
|
<p className="page-sub">Sign in to manage site tag dumps</p>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<form className="panel stack" onSubmit={onSubmit}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn" type="submit" disabled={loading}>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
{oidcEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => signIn("oidc", { callbackUrl })}
|
||||||
|
>
|
||||||
|
Sign in with {oidcName}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<LoginPageClient
|
<Suspense>
|
||||||
oidcEnabled={isOidcConfigured()}
|
<LoginForm />
|
||||||
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
</Suspense>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
oidcEnabled: boolean;
|
|
||||||
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>(
|
|
||||||
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();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await signIn("credentials", {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
if (res?.error) {
|
|
||||||
setError("Invalid email or password");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(callbackUrl);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ maxWidth: 400, margin: "3rem auto" }}>
|
|
||||||
<h1 className="page-title">RFID Database</h1>
|
|
||||||
<p className="page-sub">Sign in to manage site tag dumps</p>
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
|
||||||
<form className="panel stack" onSubmit={onSubmit}>
|
|
||||||
<div className="field">
|
|
||||||
<label className="label" htmlFor="email">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
className="input"
|
|
||||||
type="email"
|
|
||||||
autoComplete="username"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="label" htmlFor="password">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
className="input"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className="btn" type="submit" disabled={loading}>
|
|
||||||
{loading ? "Signing in…" : "Sign in"}
|
|
||||||
</button>
|
|
||||||
{showOidc && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => signIn("oidc", { callbackUrl })}
|
|
||||||
>
|
|
||||||
Sign in with {displayName}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoginPageClient(props: Props) {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<LoginForm {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+17
-51
@@ -6,9 +6,6 @@ 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";
|
|
||||||
|
|
||||||
export { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
|
||||||
|
|
||||||
function buildProviders(): Provider[] {
|
function buildProviders(): Provider[] {
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
@@ -35,45 +32,18 @@ function buildProviders(): Provider[] {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isOidcConfigured()) {
|
const issuer = process.env.AUTH_OIDC_ISSUER;
|
||||||
const issuer = process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, "");
|
const clientId = process.env.AUTH_OIDC_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
|
||||||
|
if (issuer && clientId && clientSecret) {
|
||||||
providers.push({
|
providers.push({
|
||||||
id: "oidc",
|
id: "oidc",
|
||||||
name: process.env.AUTH_OIDC_NAME || "SSO",
|
name: process.env.AUTH_OIDC_NAME || "SSO",
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
issuer,
|
issuer,
|
||||||
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
clientId,
|
||||||
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
clientSecret,
|
||||||
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,
|
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);
|
} as Provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,24 +55,12 @@ 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) 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 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();
|
||||||
@@ -124,3 +82,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function isOidcConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.AUTH_OIDC_ISSUER &&
|
||||||
|
process.env.AUTH_OIDC_CLIENT_ID &&
|
||||||
|
process.env.AUTH_OIDC_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
export function isOidcConfigured(): boolean {
|
|
||||||
return Boolean(
|
|
||||||
process.env.AUTH_OIDC_ISSUER?.trim() &&
|
|
||||||
process.env.AUTH_OIDC_CLIENT_ID?.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,42 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
|
||||||
|
|
||||||
describe("isOidcConfigured", () => {
|
|
||||||
const keys = [
|
|
||||||
"AUTH_OIDC_ISSUER",
|
|
||||||
"AUTH_OIDC_CLIENT_ID",
|
|
||||||
"AUTH_OIDC_CLIENT_SECRET",
|
|
||||||
] as const;
|
|
||||||
const previous: Record<string, string | undefined> = {};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const key of keys) {
|
|
||||||
if (previous[key] === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = previous[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
for (const key of keys) previous[key] = process.env[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
it("is false when any var is missing", () => {
|
|
||||||
save();
|
|
||||||
delete process.env.AUTH_OIDC_ISSUER;
|
|
||||||
delete process.env.AUTH_OIDC_CLIENT_ID;
|
|
||||||
delete process.env.AUTH_OIDC_CLIENT_SECRET;
|
|
||||||
expect(isOidcConfigured()).toBe(false);
|
|
||||||
|
|
||||||
process.env.AUTH_OIDC_ISSUER = "https://sso.example.com/";
|
|
||||||
process.env.AUTH_OIDC_CLIENT_ID = "id";
|
|
||||||
expect(isOidcConfigured()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is true when all three are set", () => {
|
|
||||||
save();
|
|
||||||
process.env.AUTH_OIDC_ISSUER = "https://sso.example.com/";
|
|
||||||
process.env.AUTH_OIDC_CLIENT_ID = "id";
|
|
||||||
process.env.AUTH_OIDC_CLIENT_SECRET = "secret";
|
|
||||||
expect(isOidcConfigured()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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