mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fcc106a3a | ||
|
|
21e2592f92 | ||
|
|
96c6df2fff | ||
|
|
c33f651cfe | ||
|
|
443dba5bba | ||
|
|
3bcdc14106 | ||
|
|
f2de0e3892 | ||
|
|
cd1a2fe808 | ||
|
|
6d76fecde9 | ||
|
|
0e50c537ee | ||
|
|
5aa5fef9af | ||
|
|
d3ed4566b7 |
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
coverage
|
||||||
|
tests
|
||||||
|
.github
|
||||||
|
.cursor
|
||||||
|
**/*.md
|
||||||
|
!README.md
|
||||||
+28
-4
@@ -1,11 +1,35 @@
|
|||||||
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
|
||||||
|
|
||||||
# Optional OIDC (Authentik, Keycloak, Authelia, etc.)
|
# Docker Compose published port (default 3000)
|
||||||
|
# RFID_PORT=3000
|
||||||
|
|
||||||
|
# Optional first-boot user when using Docker entrypoint
|
||||||
|
# [email protected]
|
||||||
|
# CREATE_USER_PASSWORD=changeme
|
||||||
|
# CREATE_USER_NAME=Admin
|
||||||
|
|
||||||
|
# Optional OIDC (Authentik, Keycloak, Authelia, Google, 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=SSO
|
# AUTH_OIDC_NAME=Authentik
|
||||||
# NEXT_PUBLIC_AUTH_OIDC_ENABLED=1
|
|
||||||
# NEXT_PUBLIC_AUTH_OIDC_NAME=SSO
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
name: lint-typecheck-test-build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -18,3 +19,6 @@ jobs:
|
|||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run typecheck
|
- run: npm run typecheck
|
||||||
- run: npm test
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
env:
|
||||||
|
AUTH_SECRET: ci-build-secret-at-least-32-characters
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
# GHCR requires lowercase image names
|
||||||
|
IMAGE_NAME: ghcr.io/chewbaccalakis/rfid-database
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: build-and-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+31
-12
@@ -2,18 +2,21 @@
|
|||||||
|
|
||||||
FROM node:22-bookworm-slim AS deps
|
FROM node:22-bookworm-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV AUTH_SECRET=build-time-placeholder-secret-32chars
|
ENV AUTH_SECRET=build-time-placeholder-secret-32chars
|
||||||
RUN npm run build
|
RUN npm run build \
|
||||||
|
&& npm prune --omit=dev
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runner
|
FROM node:22-bookworm-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -21,16 +24,32 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV RFID_DB_PATH=/data/rfid.db
|
ENV RFID_DB_PATH=/data/rfid.db
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
RUN apt-get update && apt-get install -y libstdc++6 && rm -rf /var/lib/apt/lists/*
|
ENV HOSTNAME=0.0.0.0
|
||||||
RUN mkdir -p /data && chown node:node /data
|
ENV AUTH_TRUST_HOST=true
|
||||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
RUN apt-get update && apt-get install -y --no-install-recommends libstdc++6 curl \
|
||||||
COPY --from=builder /app/.next ./.next
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
COPY --from=builder /app/public ./public
|
&& mkdir -p /data \
|
||||||
COPY --from=builder /app/scripts ./scripts
|
&& chown node:node /data
|
||||||
COPY --from=builder /app/src ./src
|
|
||||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=node:node /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=node:node /app/public ./public
|
||||||
|
COPY --from=builder --chown=node:node /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=node:node /app/src ./src
|
||||||
|
COPY --from=builder --chown=node:node /app/tsconfig.json ./tsconfig.json
|
||||||
|
# tsx is needed for create-user / entrypoint bootstrap (devDependency pruned above)
|
||||||
|
RUN npm install --omit=dev [email protected] \
|
||||||
|
&& chown -R node:node /app/node_modules
|
||||||
|
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:3000/login >/dev/null || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
@@ -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 + full JSON backup
|
- Import dumps (file upload or paste) + 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,13 +30,65 @@ 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` | no | OIDC issuer URL |
|
| `AUTH_OIDC_ISSUER` | for OIDC | Issuer URL (must expose `/.well-known/openid-configuration`) |
|
||||||
| `AUTH_OIDC_CLIENT_ID` | no | OIDC client id |
|
| `AUTH_OIDC_CLIENT_ID` | for OIDC | OIDC client id |
|
||||||
| `AUTH_OIDC_CLIENT_SECRET` | no | OIDC client secret |
|
| `AUTH_OIDC_CLIENT_SECRET` | for OIDC | OIDC client secret |
|
||||||
| `AUTH_OIDC_NAME` | no | Button label (default `SSO`) |
|
| `AUTH_OIDC_NAME` | no | SSO button label (default `SSO`) |
|
||||||
| `NEXT_PUBLIC_AUTH_OIDC_ENABLED` | no | Set `1` to show SSO button |
|
|
||||||
| `NEXT_PUBLIC_AUTH_OIDC_NAME` | no | Public SSO button label |
|
### Enabling OIDC
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
@@ -50,7 +102,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 |
|
| POST | `/api/v1/tags/import` | Parse dump → create tag (JSON body or multipart files) |
|
||||||
| 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 |
|
||||||
@@ -72,30 +124,101 @@ 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
|
||||||
npm test
|
npm test
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions runs lint, typecheck, and tests on every pull request and push to `main`.
|
GitHub Actions on every pull request and push to `main`:
|
||||||
|
|
||||||
|
1. **lint / typecheck / test / Next.js build**
|
||||||
|
2. **Docker image** — build on PRs; on `main` (and `v*` tags) push to GHCR:
|
||||||
|
`ghcr.io/chewbaccalakis/rfid-database`
|
||||||
|
|
||||||
|
### Pull the published image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/chewbaccalakis/rfid-database:latest
|
||||||
|
# or a specific commit: ghcr.io/chewbaccalakis/rfid-database:sha-<shortsha>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the package is private, authenticate first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in Compose, set `image: ghcr.io/chewbaccalakis/rfid-database:latest` and comment out `build:`.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
### docker compose (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set AUTH_SECRET to a long random string
|
||||||
|
|
||||||
|
# optional first user on boot:
|
||||||
|
# echo '[email protected]' >> .env
|
||||||
|
# echo 'CREATE_USER_PASSWORD=changeme' >> .env
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App: http://localhost:3000 — SQLite persists in the `rfid-data` volume.
|
||||||
|
|
||||||
|
Create a user later (if you skipped bootstrap):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec rfid-database \
|
||||||
|
npx tsx scripts/create-user.ts --email [email protected] --password 'secret' --name You
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop / remove (keeps volume):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plain docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t rfid-database .
|
docker build -t rfid-database .
|
||||||
docker run --rm -p 3000:3000 \
|
docker run --rm -p 3000:3000 \
|
||||||
-e AUTH_SECRET=your-long-secret \
|
-e AUTH_SECRET=your-long-secret \
|
||||||
|
-e [email protected] \
|
||||||
|
-e CREATE_USER_PASSWORD=changeme \
|
||||||
-v rfid-data:/data \
|
-v rfid-data:/data \
|
||||||
rfid-database
|
rfid-database
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the first user against the mounted DB (exec into the container or run `create-user` with `RFID_DB_PATH` pointed at the volume).
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Tag dumps often include sector keys. Keep the app behind HTTPS, do not expose it publicly without auth, and treat `data/rfid.db` as sensitive.
|
Tag dumps often include sector keys. Keep the app behind HTTPS, do not expose it publicly without auth, and treat `data/rfid.db` as sensitive.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
rfid-database:
|
||||||
|
# Local build (default):
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: rfid-database:local
|
||||||
|
# Or pull from GHCR instead of building:
|
||||||
|
# image: ghcr.io/chewbaccalakis/rfid-database:latest
|
||||||
|
# pull_policy: always
|
||||||
|
container_name: rfid-database
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${RFID_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
RFID_DB_PATH: /data/rfid.db
|
||||||
|
PORT: "3000"
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
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):
|
||||||
|
# CREATE_USER_EMAIL: [email protected]
|
||||||
|
# CREATE_USER_PASSWORD: change-me
|
||||||
|
# CREATE_USER_NAME: Admin
|
||||||
|
volumes:
|
||||||
|
- rfid-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/login"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rfid-data:
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$AUTH_SECRET" ] || [ "$AUTH_SECRET" = "change-me-to-a-long-random-string" ]; then
|
||||||
|
echo "ERROR: AUTH_SECRET must be set to a long random value." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${RFID_DB_PATH:-/data/rfid.db}")"
|
||||||
|
|
||||||
|
# Optional first-boot user: set CREATE_USER_EMAIL + CREATE_USER_PASSWORD
|
||||||
|
if [ -n "$CREATE_USER_EMAIL" ] && [ -n "$CREATE_USER_PASSWORD" ]; then
|
||||||
|
echo "Ensuring user $CREATE_USER_EMAIL exists (ignored if already present)..."
|
||||||
|
npx tsx scripts/create-user.ts \
|
||||||
|
--email "$CREATE_USER_EMAIL" \
|
||||||
|
--password "$CREATE_USER_PASSWORD" \
|
||||||
|
--name "${CREATE_USER_NAME:-Admin}" \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { jsonOk } from "@/lib/api/errors";
|
||||||
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public auth diagnostics (no secrets).
|
||||||
|
* Useful to verify the running container sees OIDC env vars.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, "");
|
||||||
|
const oidcEnabled = isOidcConfigured();
|
||||||
|
|
||||||
|
return jsonOk({
|
||||||
|
oidcEnabled,
|
||||||
|
oidcName: process.env.AUTH_OIDC_NAME || "SSO",
|
||||||
|
issuer: process.env.AUTH_OIDC_ISSUER || null,
|
||||||
|
clientIdSet: Boolean(process.env.AUTH_OIDC_CLIENT_ID?.trim()),
|
||||||
|
clientSecretSet: Boolean(process.env.AUTH_OIDC_CLIENT_SECRET?.trim()),
|
||||||
|
authUrl: authUrl || null,
|
||||||
|
authSecretSet: Boolean(
|
||||||
|
process.env.AUTH_SECRET?.trim() &&
|
||||||
|
process.env.AUTH_SECRET !== "change-me-to-a-long-random-string"
|
||||||
|
),
|
||||||
|
callbackUrl: authUrl
|
||||||
|
? `${authUrl}/api/auth/callback/oidc`
|
||||||
|
: "/api/auth/callback/oidc",
|
||||||
|
hint: oidcEnabled
|
||||||
|
? "OIDC looks configured. Register callbackUrl exactly in your IdP."
|
||||||
|
: "Set AUTH_OIDC_ISSUER, AUTH_OIDC_CLIENT_ID, and AUTH_OIDC_CLIENT_SECRET, then restart.",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,36 +3,62 @@ import { eq } from "drizzle-orm";
|
|||||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
import { 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 { parseImport } from "@/lib/rfid/parsers";
|
import { bytesFromBase64, parseDump } from "@/lib/rfid/parsers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const importMetaSchema = z.object({
|
const importMetaSchema = z
|
||||||
|
.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().min(1),
|
content: z.string().optional(),
|
||||||
|
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 body: unknown;
|
let fields: ImportFields;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
fields = await readImportFields(request);
|
||||||
} catch {
|
} catch (e) {
|
||||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
if (e instanceof SyntaxError) {
|
||||||
|
return jsonError(400, "INVALID_JSON", "Request body must be JSON or multipart form data");
|
||||||
|
}
|
||||||
|
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = importMetaSchema.safeParse(body);
|
if (!fields.text?.trim() && !(fields.bytes && fields.bytes.length > 0)) {
|
||||||
if (!parsed.success) {
|
return jsonError(400, "VALIDATION_ERROR", "Provide dump content or an uploaded dump file");
|
||||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let imported;
|
let imported;
|
||||||
try {
|
try {
|
||||||
imported = parseImport(parsed.data.content, parsed.data.filename);
|
imported = parseDump({
|
||||||
|
text: fields.text,
|
||||||
|
filename: fields.filename,
|
||||||
|
bytes: fields.bytes,
|
||||||
|
keysFilename: fields.keysFilename,
|
||||||
|
keysBytes: fields.keysBytes,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -41,26 +67,25 @@ export async function POST(request: NextRequest) {
|
|||||||
const site = db
|
const site = db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.id, parsed.data.siteId))
|
.where(eq(sites.id, fields.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 =
|
const label = fields.label || imported.label || `Imported ${imported.uid}`;
|
||||||
parsed.data.label || imported.label || `Imported ${imported.uid}`;
|
|
||||||
|
|
||||||
db.insert(tagRecords)
|
db.insert(tagRecords)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
siteId: parsed.data.siteId,
|
siteId: fields.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: parsed.data.notes ?? imported.notes ?? null,
|
notes: fields.notes ?? imported.notes ?? null,
|
||||||
createdById: userOrRes.id,
|
createdById: userOrRes.id,
|
||||||
updatedById: userOrRes.id,
|
updatedById: userOrRes.id,
|
||||||
lastWrittenAt: null,
|
lastWrittenAt: null,
|
||||||
@@ -81,9 +106,88 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+9
-89
@@ -1,95 +1,15 @@
|
|||||||
"use client";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
import { LoginPageClient } from "@/components/LoginPageClient";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
// OIDC env is only available at runtime (Docker). Never prerender this page
|
||||||
import { signIn } from "next-auth/react";
|
// at build time or the SSO button stays permanently hidden.
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
export const dynamic = "force-dynamic";
|
||||||
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 (
|
||||||
<Suspense>
|
<LoginPageClient
|
||||||
<LoginForm />
|
oidcEnabled={isOidcConfigured()}
|
||||||
</Suspense>
|
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-26
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { 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 };
|
||||||
|
|
||||||
@@ -19,8 +22,13 @@ 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(false);
|
const [importMode, setImportMode] = useState(true);
|
||||||
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"),
|
||||||
@@ -36,6 +44,40 @@ 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 };
|
||||||
@@ -46,27 +88,67 @@ 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: [{ index: 0, blocks: ["00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000", "FFFFFFFFFFFFFF078069FFFFFFFFFFFF"] }],
|
sectors: [
|
||||||
|
{
|
||||||
|
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",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: form,
|
||||||
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) {
|
||||||
@@ -119,20 +201,19 @@ 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(false)}
|
|
||||||
>
|
|
||||||
Manual
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
||||||
@@ -140,12 +221,24 @@ function NewTagForm() {
|
|||||||
>
|
>
|
||||||
Import dump
|
Import dump
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
||||||
|
onClick={() => setImportMode(false)}
|
||||||
|
>
|
||||||
|
Manual
|
||||||
|
</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 className="select" value={siteId} onChange={(e) => setSiteId(e.target.value)} required>
|
<select
|
||||||
|
className="select"
|
||||||
|
value={siteId}
|
||||||
|
onChange={(e) => setSiteId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select site
|
Select site
|
||||||
</option>
|
</option>
|
||||||
@@ -159,7 +252,13 @@ function NewTagForm() {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Label</label>
|
<label className="label">Label</label>
|
||||||
<input className="input" value={label} onChange={(e) => setLabel(e.target.value)} required={!importMode} placeholder="Front door fob" />
|
<input
|
||||||
|
className="input"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
required={!importMode}
|
||||||
|
placeholder="Front door fob"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!importMode && (
|
{!importMode && (
|
||||||
@@ -187,26 +286,93 @@ function NewTagForm() {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">UID</label>
|
<label className="label">UID</label>
|
||||||
<input className="input mono" value={uid} onChange={(e) => setUid(e.target.value)} required placeholder="04:A1:B2:C3" />
|
<input
|
||||||
|
className="input mono"
|
||||||
|
value={uid}
|
||||||
|
onChange={(e) => setUid(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="04:A1:B2:C3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} placeholder='{"size":"1K","sectors":[...]}' />
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={dumpText}
|
||||||
|
onChange={(e) => setDumpText(e.target.value)}
|
||||||
|
placeholder='{"size":"1K","sectors":[...]}'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">keys (JSON, optional)</label>
|
<label className="label">keys (JSON, optional)</label>
|
||||||
<textarea className="textarea" value={keysText} onChange={(e) => setKeysText(e.target.value)} placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}' />
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={keysText}
|
||||||
|
onChange={(e) => setKeysText(e.target.value)}
|
||||||
|
placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{importMode && (
|
{importMode && (
|
||||||
|
<>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Paste JSON / MCT / Proxmark dump</label>
|
<label className="label">Dump file</label>
|
||||||
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} required style={{ minHeight: "14rem" }} />
|
<input
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.mct,.eml,.txt,.bin,.dump,application/json,text/plain,application/octet-stream"
|
||||||
|
onChange={(e) => onDumpFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<p className="muted" style={{ margin: "0.35rem 0 0", fontSize: "0.8rem" }}>
|
||||||
|
Proxmark <span className="mono">hf-mf-<UID>-dump.json</span>, MCT, EML, or{" "}
|
||||||
|
<span className="mono">dump.bin</span> (1024/4096 bytes)
|
||||||
|
{dumpFile ? ` — ${dumpFile.name}` : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
||||||
@@ -227,6 +393,54 @@ function NewTagForm() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeKeyFile(file: File): boolean {
|
||||||
|
return isKeyBinFilename(file.name) || file.size === 192 || file.size === 480;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeDumpFile(file: File): boolean {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
name.endsWith(".json") ||
|
||||||
|
name.endsWith(".mct") ||
|
||||||
|
name.endsWith(".eml") ||
|
||||||
|
name.includes("-dump.bin") ||
|
||||||
|
file.size === 1024 ||
|
||||||
|
file.size === 4096
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseClientDump(
|
||||||
|
dumpFile: File | null,
|
||||||
|
dumpText: string,
|
||||||
|
keysFile: File | null
|
||||||
|
): Promise<CanonicalTag> {
|
||||||
|
let text: string | undefined;
|
||||||
|
let bytes: Uint8Array | undefined;
|
||||||
|
let filename: string | undefined;
|
||||||
|
|
||||||
|
if (dumpFile) {
|
||||||
|
filename = dumpFile.name;
|
||||||
|
const buf = new Uint8Array(await dumpFile.arrayBuffer());
|
||||||
|
const lower = dumpFile.name.toLowerCase();
|
||||||
|
if (lower.endsWith(".bin") || lower.endsWith(".dump")) {
|
||||||
|
bytes = buf;
|
||||||
|
} else {
|
||||||
|
text = new TextDecoder().decode(buf);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = dumpText;
|
||||||
|
filename = "import.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseDump({
|
||||||
|
text,
|
||||||
|
bytes,
|
||||||
|
filename,
|
||||||
|
keysBytes: keysFile ? new Uint8Array(await keysFile.arrayBuffer()) : undefined,
|
||||||
|
keysFilename: keysFile?.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewTagPage() {
|
export default function NewTagPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+51
-17
@@ -6,6 +6,9 @@ import { eq } from "drizzle-orm";
|
|||||||
import { getDb } from "@/db/client";
|
import { getDb } from "@/db/client";
|
||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { authConfig } from "@/lib/auth/auth.config";
|
import { authConfig } from "@/lib/auth/auth.config";
|
||||||
|
import { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
|
export { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
function buildProviders(): Provider[] {
|
function buildProviders(): Provider[] {
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
@@ -32,18 +35,45 @@ function buildProviders(): Provider[] {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const issuer = process.env.AUTH_OIDC_ISSUER;
|
if (isOidcConfigured()) {
|
||||||
const clientId = process.env.AUTH_OIDC_CLIENT_ID;
|
const issuer = process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, "");
|
||||||
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,
|
||||||
clientId,
|
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
||||||
clientSecret,
|
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!,
|
||||||
|
// Auth.js OIDC defaults to PKCE-only; Authelia requires a strong `state`
|
||||||
|
checks: ["pkce", "state"],
|
||||||
|
// Authelia (and many IdPs) put `email` on UserInfo, not the ID token.
|
||||||
|
// Auth.js OIDC otherwise only reads ID-token claims.
|
||||||
|
idToken: false,
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: "openid email profile",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
token_endpoint_auth_method: "client_secret_post",
|
||||||
|
},
|
||||||
|
// Link OIDC logins to existing local users by email
|
||||||
allowDangerousEmailAccountLinking: true,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +85,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
providers: buildProviders(),
|
providers: buildProviders(),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
async signIn({ user, account }) {
|
async signIn({ user, account, profile }) {
|
||||||
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();
|
||||||
@@ -82,11 +124,3 @@ 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
+393
-33
@@ -1,68 +1,292 @@
|
|||||||
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 { Protocol } from "@/lib/rfid/protocols";
|
import type { MifareClassicDump, TagKeys } from "@/lib/validation/rfid";
|
||||||
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 {
|
||||||
const trimmed = content.trim();
|
return parseDump({
|
||||||
const lower = (filenameHint ?? "").toLowerCase();
|
text: content,
|
||||||
|
filename: filenameHint,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
|
export function parseDump(input: ParseDumpOptions): CanonicalTag {
|
||||||
return parseJsonDump(trimmed);
|
const filename = (input.filename ?? "").toLowerCase();
|
||||||
|
let bytes = input.bytes;
|
||||||
|
let text = unwrapDumpText(input.text ?? "");
|
||||||
|
|
||||||
|
if (bytes && bytes.length > 0 && looksLikeUtf8TextDump(bytes)) {
|
||||||
|
if (!text.trim()) {
|
||||||
|
text = unwrapDumpText(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
||||||
}
|
}
|
||||||
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
|
// JSON/MCT uploaded as application/octet-stream should not be treated as dump.bin.
|
||||||
return parseMct(trimmed);
|
bytes = undefined;
|
||||||
}
|
|
||||||
// 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(
|
||||||
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
|
`This file looks like a Proxmark key.bin (${bytes.length} bytes: KeyA+KeyB per sector), not a card dump. Upload hf-mf-*-dump.json or dump.bin as the dump, and attach this file as the key file.`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
const data = JSON.parse(content) as Record<string, unknown>;
|
let data: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
const extracted = extractJsonObject(content);
|
||||||
|
if (!extracted) {
|
||||||
|
throw new Error("Dump is not valid JSON");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = JSON.parse(extracted) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Dump is not valid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Proxmark-style
|
const fileType = String(data.FileType ?? data.Created ?? "").toLowerCase();
|
||||||
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
|
|
||||||
return parseProxmarkJson(data);
|
if (
|
||||||
|
fileType.includes("mfc") ||
|
||||||
|
fileType.includes("mfcard") ||
|
||||||
|
data.Card ||
|
||||||
|
data.blocks ||
|
||||||
|
data.Blocks ||
|
||||||
|
data.SectorKeys
|
||||||
|
) {
|
||||||
|
return parseProxmarkClassicJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canonical
|
|
||||||
if (data.dumpData && data.uid && data.protocol) {
|
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 Protocol,
|
protocol: data.protocol as CanonicalTag["protocol"],
|
||||||
uid: normalizeUid(String(data.uid)),
|
uid: normalizeUid(String(data.uid)),
|
||||||
dumpData: data.dumpData,
|
dumpData: data.dumpData,
|
||||||
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
|
keys: isKeySet(data.keys) ? data.keys : undefined,
|
||||||
notes: typeof data.notes === "string" ? data.notes : null,
|
notes: typeof data.notes === "string" ? data.notes : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
|
throw new Error(
|
||||||
|
"JSON dump missing required fields. Expected Proxmark MIFARE dump (FileType mfc v2 / mfcard, with Card/blocks) or canonical {uid, protocol, dumpData}."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
|
function extractJsonObject(content: string): string | null {
|
||||||
const card = (data.Card as { UID?: string } | undefined) ?? {};
|
const start = content.indexOf("{");
|
||||||
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
|
const end = content.lastIndexOf("}");
|
||||||
const blocks = (data.blocks as string[]) ?? [];
|
if (start < 0 || end <= start) return null;
|
||||||
|
return content.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeySet(value: unknown): value is TagKeys {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProxmarkClassicJson(data: Record<string, unknown>): CanonicalTag {
|
||||||
|
const card = (data.Card as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const uidRaw = card.UID ?? card.uid ?? data.UID ?? data.uid;
|
||||||
|
const blocks = extractBlockList(data.blocks ?? data.Blocks);
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
throw new Error("Proxmark dump has no blocks");
|
||||||
|
}
|
||||||
|
|
||||||
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: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
|
keys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blockToHex(value: unknown): string {
|
||||||
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
|
return blockToHex((value as { data: unknown }).data);
|
||||||
|
}
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/\s/g, "")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBlockList(blocks: unknown): string[] {
|
||||||
|
if (Array.isArray(blocks)) {
|
||||||
|
return blocks.map(blockToHex).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (blocks && typeof blocks === "object") {
|
||||||
|
const rec = blocks as Record<string, unknown>;
|
||||||
|
const indices = Object.keys(rec)
|
||||||
|
.map((k) => Number.parseInt(k, 10))
|
||||||
|
.filter((k) => Number.isFinite(k));
|
||||||
|
if (indices.length === 0) return [];
|
||||||
|
const max = Math.max(...indices);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i <= max; i++) {
|
||||||
|
const raw = rec[String(i)] ?? rec[i as unknown as string];
|
||||||
|
if (raw == null) {
|
||||||
|
throw new Error(`Proxmark dump missing block ${i}`);
|
||||||
|
}
|
||||||
|
const hex = blockToHex(raw);
|
||||||
|
if (!/^[0-9A-F]+$/.test(hex) || hex.length < 32) {
|
||||||
|
throw new Error(`Proxmark dump block ${i} is not 16-byte hex`);
|
||||||
|
}
|
||||||
|
out.push(hex);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uidFromBlock0(block: string): string {
|
||||||
|
const hex = block.replace(/\s/g, "").toUpperCase();
|
||||||
|
if (hex.length < 8) throw new Error("Could not determine UID from dump");
|
||||||
|
return hex.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysFromSectorKeys(raw: unknown): TagKeys | undefined {
|
||||||
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
|
const A: string[] = [];
|
||||||
|
const B: string[] = [];
|
||||||
|
const entries = sectorEntries(raw);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
|
||||||
|
for (const [, value] of entries) {
|
||||||
|
const rec = (value ?? {}) as Record<string, unknown>;
|
||||||
|
const keyA = String(rec.KeyA ?? rec.keyA ?? rec.A ?? "").replace(/[^0-9A-Fa-f]/g, "");
|
||||||
|
const keyB = String(rec.KeyB ?? rec.keyB ?? rec.B ?? "").replace(/[^0-9A-Fa-f]/g, "");
|
||||||
|
A.push(keyA.toUpperCase());
|
||||||
|
B.push(keyB.toUpperCase());
|
||||||
|
}
|
||||||
|
return { A, B };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectorEntries(raw: unknown): Array<[number, unknown]> {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((v, i): [number, unknown] => [i, v]);
|
||||||
|
}
|
||||||
|
return Object.entries(raw as Record<string, unknown>)
|
||||||
|
.map(([k, v]): [number, unknown] => [Number.parseInt(k, 10), v])
|
||||||
|
.filter(([k]) => Number.isFinite(k))
|
||||||
|
.sort((a, b) => a[0] - b[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysFromTrailers(dump: MifareClassicDump): TagKeys | undefined {
|
||||||
|
const A: string[] = [];
|
||||||
|
const B: string[] = [];
|
||||||
|
for (const sector of [...dump.sectors].sort((a, b) => a.index - b.index)) {
|
||||||
|
const trailer = sector.blocks[sector.blocks.length - 1] ?? "";
|
||||||
|
const hex = trailer.replace(/\s/g, "").toUpperCase();
|
||||||
|
if (hex.length < 32) continue;
|
||||||
|
A.push(hex.slice(0, 12));
|
||||||
|
B.push(hex.slice(20, 32));
|
||||||
|
}
|
||||||
|
if (A.length === 0) return undefined;
|
||||||
|
return { A, B };
|
||||||
|
}
|
||||||
|
|
||||||
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
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";
|
||||||
@@ -81,7 +305,10 @@ function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseMct(content: string): CanonicalTag {
|
export function parseMct(content: string): CanonicalTag {
|
||||||
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
const lines = unwrapDumpText(content)
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
let uid = "";
|
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;
|
||||||
@@ -106,21 +333,154 @@ 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) {
|
if (b0 && b0.length >= 8) uid = b0.slice(0, 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: { size, sectors },
|
dumpData,
|
||||||
|
keys: keysFromTrailers(dumpData),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseEml(content: string): CanonicalTag {
|
||||||
|
const blocks = unwrapDumpText(content)
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => /^[0-9A-Fa-f]{32}$/.test(l))
|
||||||
|
.map((l) => l.toUpperCase());
|
||||||
|
if (blocks.length < 4) throw new Error("EML dump has too few blocks");
|
||||||
|
const dumpData = blocksToClassicDump(blocks);
|
||||||
|
return {
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
|
uid: uidFromBlock0(blocks[0]),
|
||||||
|
dumpData,
|
||||||
|
keys: keysFromTrailers(dumpData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClassicBinDump(bytes: Uint8Array): CanonicalTag {
|
||||||
|
if (bytes.length !== 1024 && bytes.length !== 4096) {
|
||||||
|
if (looksLikeKeyBin("", bytes)) {
|
||||||
|
throw new Error(
|
||||||
|
`This file looks like a Proxmark key.bin (${bytes.length} bytes), not a card dump. Upload dump.json / dump.bin as the dump and this file as the key file.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`MIFARE dump.bin must be 1024 bytes (1K) or 4096 bytes (4K); got ${bytes.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += 16) {
|
||||||
|
blocks.push(bytesToHex(bytes.subarray(i, i + 16)));
|
||||||
|
}
|
||||||
|
const dumpData = blocksToClassicDump(blocks);
|
||||||
|
return {
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
|
uid: uidFromBlock0(blocks[0]),
|
||||||
|
dumpData,
|
||||||
|
keys: keysFromTrailers(dumpData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxmark `hf-mf-<UID>-key.bin`: 12 bytes/sector (KeyA + KeyB).
|
||||||
|
* Also accepts a hex dictionary (one 12-char key per line).
|
||||||
|
*/
|
||||||
|
export function parseKeyBin(bytes: Uint8Array, filename?: string): TagKeys {
|
||||||
|
if (bytes.length === 0) {
|
||||||
|
throw new Error("Key file is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (filename ?? "").toLowerCase();
|
||||||
|
const preferText = name.endsWith(".dic") || name.endsWith(".txt");
|
||||||
|
|
||||||
|
if (!preferText && bytes.length % 12 === 0) {
|
||||||
|
const A: string[] = [];
|
||||||
|
const B: string[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += 12) {
|
||||||
|
A.push(bytesToHex(bytes.subarray(i, i + 6)));
|
||||||
|
B.push(bytesToHex(bytes.subarray(i + 6, i + 12)));
|
||||||
|
}
|
||||||
|
return { A, B };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferText && bytes.length % 6 === 0) {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += 6) {
|
||||||
|
keys.push(bytesToHex(bytes.subarray(i, i + 6)));
|
||||||
|
}
|
||||||
|
return { A: keys, B: keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
const asText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||||
|
const hexKeys = asText
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.replace(/[^0-9A-Fa-f]/g, "").toUpperCase())
|
||||||
|
.filter((l) => l.length === 12);
|
||||||
|
|
||||||
|
if (hexKeys.length > 0) {
|
||||||
|
return { A: hexKeys, B: hexKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unrecognized Proxmark key.bin size (${bytes.length} bytes). Expected 192 (1K) or 480 (4K) binary, or a hex key dictionary.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeKeys(base: TagKeys | undefined, extra: TagKeys): TagKeys {
|
||||||
|
const sectorCount = Math.max(
|
||||||
|
base?.A?.length ?? 0,
|
||||||
|
base?.B?.length ?? 0,
|
||||||
|
extra.A?.length ?? 0,
|
||||||
|
extra.B?.length ?? 0
|
||||||
|
);
|
||||||
|
if (sectorCount === 0) return extra;
|
||||||
|
|
||||||
|
const A: string[] = [];
|
||||||
|
const B: string[] = [];
|
||||||
|
for (let i = 0; i < sectorCount; i++) {
|
||||||
|
A.push(nonEmptyKey(extra.A?.[i]) || nonEmptyKey(base?.A?.[i]) || "");
|
||||||
|
B.push(nonEmptyKey(extra.B?.[i]) || nonEmptyKey(base?.B?.[i]) || "");
|
||||||
|
}
|
||||||
|
return { A, B };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyKey(value: string | undefined): string {
|
||||||
|
return value && /[0-9A-F]/i.test(value) ? value.toUpperCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesFromBase64(b64: string): Uint8Array {
|
||||||
|
const cleaned = b64.replace(/\s/g, "");
|
||||||
|
const bin =
|
||||||
|
typeof globalThis.atob === "function"
|
||||||
|
? globalThis.atob(cleaned)
|
||||||
|
: Buffer.from(cleaned, "base64").toString("binary");
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKeyBinFilename(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return lower.includes("-key.bin") || lower.endsWith("key.bin") || lower.includes("keys.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CanonicalTag } from "@/lib/rfid/types";
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export const dumpDataSchema = z.union([
|
|||||||
z.record(z.string(), z.unknown()),
|
z.record(z.string(), z.unknown()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const keysSchema = z
|
export const tagKeysObjectSchema = 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()
|
|
||||||
.optional();
|
export const keysSchema = tagKeysObjectSchema.nullable().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 keysSchema>;
|
export type TagKeys = z.infer<typeof tagKeysObjectSchema>;
|
||||||
|
|||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
{
|
||||||
|
"Created": "proxmark3",
|
||||||
|
"FileType": "mfc v2",
|
||||||
|
"Card": {
|
||||||
|
"UID": "91A2F020",
|
||||||
|
"ATQA": "0400",
|
||||||
|
"SAK": "08"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"0": "91A2F020E30804000497F47A359E4798",
|
||||||
|
"1": "54C017569969134569ED95BD2C8A95FE",
|
||||||
|
"2": "8A000400010000000000000000000000",
|
||||||
|
"3": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"4": "2200020000000000000000C10000001E",
|
||||||
|
"5": "2200020000000000000000C10000001E",
|
||||||
|
"6": "00000000000000000000000000000000",
|
||||||
|
"7": "2A2C13CC242AFF078069FFFFFFFFFFFF",
|
||||||
|
"8": "00000000000000000000000000000000",
|
||||||
|
"9": "00000000000000000000000000000000",
|
||||||
|
"10": "00000000000000000000000000000000",
|
||||||
|
"11": "FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
|
||||||
|
"12": "00000000000000000000000000000000",
|
||||||
|
"13": "00000000000000000000000000000000",
|
||||||
|
"14": "00000000000000000000000000000000",
|
||||||
|
"15": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"16": "AE48B81460060028AE48B81C200600F0",
|
||||||
|
"17": "00000000000000000000000000000000",
|
||||||
|
"18": "00000000000000000000000000000000",
|
||||||
|
"19": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"20": "00000000000000000000000000000000",
|
||||||
|
"21": "00000000000000000000000000000000",
|
||||||
|
"22": "00000000000000000000000000000000",
|
||||||
|
"23": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"24": "00000000000000000000000000000000",
|
||||||
|
"25": "00000000000000000000000000000000",
|
||||||
|
"26": "00000000000000000000000000000000",
|
||||||
|
"27": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"28": "00000000000000000000000000000000",
|
||||||
|
"29": "00000000000000000000000000000000",
|
||||||
|
"30": "00000000000000000000000000000000",
|
||||||
|
"31": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"32": "00000000000000000000000000000000",
|
||||||
|
"33": "00000000000000000000000000000000",
|
||||||
|
"34": "00000000000000000000000000000000",
|
||||||
|
"35": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"36": "00000000000000000000000000000000",
|
||||||
|
"37": "00000000000000000000000000000000",
|
||||||
|
"38": "00000000000000000000000000000000",
|
||||||
|
"39": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"40": "00000000000000000000000000000000",
|
||||||
|
"41": "00000000000000000000000000000000",
|
||||||
|
"42": "00000000000000000000000000000000",
|
||||||
|
"43": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"44": "00000000000000000000000000000000",
|
||||||
|
"45": "00000000000000000000000000000000",
|
||||||
|
"46": "00000000000000000000000000000000",
|
||||||
|
"47": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"48": "00000000000000000000000000000000",
|
||||||
|
"49": "00000000000000000000000000000000",
|
||||||
|
"50": "00000000000000000000000000000000",
|
||||||
|
"51": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"52": "00000000000000000000000000000000",
|
||||||
|
"53": "00000000000000000000000000000000",
|
||||||
|
"54": "00000000000000000000000000000000",
|
||||||
|
"55": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"56": "00000000000000000000000000000000",
|
||||||
|
"57": "00000000000000000000000000000000",
|
||||||
|
"58": "00000000000000000000000000000000",
|
||||||
|
"59": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF",
|
||||||
|
"60": "00000000000000000000000000000000",
|
||||||
|
"61": "00000000000000000000000000000000",
|
||||||
|
"62": "00000000000000000000000000000000",
|
||||||
|
"63": "A1D4FAFD4CAFFF078069FFFFFFFFFFFF"
|
||||||
|
},
|
||||||
|
"SectorKeys": {
|
||||||
|
"0": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block0": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block1": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block2": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block3": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"KeyA": "2A2C13CC242A",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block4": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block5": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block6": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block7": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"KeyA": "FFFFFFFFFFFF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block8": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block9": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block10": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block11": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block12": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block13": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block14": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block15": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block16": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block17": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block18": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block19": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block20": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block21": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block22": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block23": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block24": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block25": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block26": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block27": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block28": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block29": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block30": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block31": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block32": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block33": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block34": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block35": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block36": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block37": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block38": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block39": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block40": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block41": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block42": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block43": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block44": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block45": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block46": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block47": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block48": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block49": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block50": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block51": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block52": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block53": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block54": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block55": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block56": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block57": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block58": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block59": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"KeyA": "A1D4FAFD4CAF",
|
||||||
|
"KeyB": "FFFFFFFFFFFF",
|
||||||
|
"AccessConditions": "FF078069",
|
||||||
|
"AccessConditionsText": {
|
||||||
|
"block60": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block61": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block62": "read AB; write AB; increment AB; decrement transfer restore AB",
|
||||||
|
"block63": "write A by A; read/write ACCESS by A; read/write B by A",
|
||||||
|
"UserData": "69"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ import { GET as getTags, POST as postTag } from "@/app/api/v1/tags/route";
|
|||||||
import { PUT as putByUid } from "@/app/api/v1/tags/by-uid/[uid]/route";
|
import { PUT as putByUid } from "@/app/api/v1/tags/by-uid/[uid]/route";
|
||||||
import { GET as getExport } from "@/app/api/v1/tags/[id]/export/route";
|
import { GET as getExport } from "@/app/api/v1/tags/[id]/export/route";
|
||||||
import { GET as listTokens, POST as createToken } from "@/app/api/v1/tokens/route";
|
import { GET as listTokens, POST as createToken } from "@/app/api/v1/tokens/route";
|
||||||
|
import { DELETE as deleteToken } from "@/app/api/v1/tokens/[id]/route";
|
||||||
|
import { GET as search } from "@/app/api/v1/search/route";
|
||||||
|
import { GET as getBackup, POST as postBackup } from "@/app/api/v1/backup/route";
|
||||||
|
import { POST as importTag } from "@/app/api/v1/tags/import/route";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
function req(url: string, init?: ConstructorParameters<typeof NextRequest>[1]) {
|
function req(url: string, init?: ConstructorParameters<typeof NextRequest>[1]) {
|
||||||
@@ -189,9 +193,115 @@ describe("API v1 integration", () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const revoked = await deleteToken(
|
||||||
|
req(`http://localhost/api/v1/tokens/${data.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: auth,
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id: data.id }) }
|
||||||
|
);
|
||||||
|
expect(revoked.status).toBe(200);
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||||
expect(user?.email).toBe("[email protected]");
|
expect(user?.email).toBe("[email protected]");
|
||||||
expect(db.select().from(sites).all().length).toBeGreaterThanOrEqual(0);
|
expect(db.select().from(sites).all().length).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("searches, imports MCT, and backs up", 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: "C", name: "Lab" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const site = await siteRes.json();
|
||||||
|
|
||||||
|
const mct = `+UID: AABBCCDD
|
||||||
|
+Sector: 0
|
||||||
|
AABBCCDD00112233445566778899AABB
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||||
|
`;
|
||||||
|
|
||||||
|
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: "Imported fob",
|
||||||
|
content: mct,
|
||||||
|
filename: "sample.mct",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(imported.status).toBe(201);
|
||||||
|
const tag = await imported.json();
|
||||||
|
expect(tag.uid).toBe("AABBCCDD");
|
||||||
|
|
||||||
|
const searchRes = await search(
|
||||||
|
req("http://localhost/api/v1/search?q=Imported", { headers: auth })
|
||||||
|
);
|
||||||
|
expect(searchRes.status).toBe(200);
|
||||||
|
const found = await searchRes.json();
|
||||||
|
expect(found.tags.some((t: { label: string }) => t.label === "Imported fob")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const backupRes = await getBackup(
|
||||||
|
req("http://localhost/api/v1/backup", { headers: auth })
|
||||||
|
);
|
||||||
|
expect(backupRes.status).toBe(200);
|
||||||
|
const backup = await backupRes.json();
|
||||||
|
expect(backup.sites.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(backup.tags.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const restore = await postBackup(
|
||||||
|
req("http://localhost/api/v1/backup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...backup, mode: "merge" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(restore.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports Proxmark mfc v2 JSON dumps", async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${bearer}` };
|
||||||
|
const siteRes = await postSite(
|
||||||
|
req("http://localhost/api/v1/sites", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "PM3", name: "Proxmark" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const site = await siteRes.json();
|
||||||
|
const dump = fs.readFileSync(
|
||||||
|
path.join(import.meta.dirname, "../fixtures/hf-mf-91A2F020-dump.json"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const imported = await importTag(
|
||||||
|
req("http://localhost/api/v1/tags/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteId: site.id,
|
||||||
|
label: "Proxmark v2",
|
||||||
|
content: dump,
|
||||||
|
filename: "hf-mf-91A2F020-dump.json",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(imported.status).toBe(201);
|
||||||
|
const tag = await imported.json();
|
||||||
|
expect(tag.uid).toBe("91A2F020");
|
||||||
|
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
||||||
|
expect(tag.keys.A[0]).toBe("A1D4FAFD4CAF");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportTag } from "@/lib/rfid/exporters";
|
||||||
|
import {
|
||||||
|
dumpDataSchema,
|
||||||
|
mifareClassicDumpSchema,
|
||||||
|
siteCreateSchema,
|
||||||
|
tagCreateSchema,
|
||||||
|
} from "@/lib/validation/rfid";
|
||||||
|
|
||||||
|
const classicDump = {
|
||||||
|
size: "1K" as const,
|
||||||
|
sectors: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
blocks: [
|
||||||
|
"04A1B2C304A1B2C304A1B2C304A1B2C3",
|
||||||
|
"00000000000000000000000000000000",
|
||||||
|
"00000000000000000000000000000000",
|
||||||
|
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleTag = {
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
label: "Dock fob",
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: "MIFARE_CLASSIC_1K",
|
||||||
|
uid: "04A1B2C3",
|
||||||
|
dumpData: classicDump,
|
||||||
|
keys: { A: ["FFFFFFFFFFFF"], B: [] },
|
||||||
|
notes: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("exporters", () => {
|
||||||
|
it("exports canonical JSON", () => {
|
||||||
|
const out = exportTag(sampleTag, "json");
|
||||||
|
expect(out.contentType).toBe("application/json");
|
||||||
|
const parsed = JSON.parse(out.body);
|
||||||
|
expect(parsed.uid).toBe("04A1B2C3");
|
||||||
|
expect(parsed.dumpData.size).toBe("1K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports proxmark JSON with blocks", () => {
|
||||||
|
const out = exportTag(sampleTag, "proxmark");
|
||||||
|
const parsed = JSON.parse(out.body);
|
||||||
|
expect(parsed.FileType).toBe("mfcard");
|
||||||
|
expect(parsed.blocks).toHaveLength(4);
|
||||||
|
expect(parsed.Card.UID).toBe("04A1B2C3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports MCT text", () => {
|
||||||
|
const out = exportTag(sampleTag, "mct");
|
||||||
|
expect(out.body).toContain("+UID: 04A1B2C3");
|
||||||
|
expect(out.body).toContain("+Sector: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports hex listing", () => {
|
||||||
|
const out = exportTag(sampleTag, "hex");
|
||||||
|
expect(out.body).toContain("# UID 04A1B2C3");
|
||||||
|
expect(out.body).toContain("S0B0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation schemas", () => {
|
||||||
|
it("accepts valid classic dump", () => {
|
||||||
|
expect(mifareClassicDumpSchema.safeParse(classicDump).success).toBe(true);
|
||||||
|
expect(dumpDataSchema.safeParse(classicDump).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid site codes", () => {
|
||||||
|
expect(
|
||||||
|
siteCreateSchema.safeParse({ name: "Lab", code: "bad code!" }).success
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid tag create payload", () => {
|
||||||
|
const parsed = tagCreateSchema.safeParse({
|
||||||
|
siteId: "00000000-0000-4000-8000-000000000099",
|
||||||
|
label: "Dock",
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: "MIFARE_CLASSIC_1K",
|
||||||
|
uid: "04:A1:B2:C3",
|
||||||
|
dumpData: classicDump,
|
||||||
|
keys: { A: ["FFFFFFFFFFFF"] },
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { emailFromOidcProfile } from "@/lib/auth/oidc";
|
||||||
|
|
||||||
|
describe("emailFromOidcProfile", () => {
|
||||||
|
it("reads email claim", () => {
|
||||||
|
expect(emailFromOidcProfile({ email: "[email protected]" })).toBe(
|
||||||
|
"[email protected]"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to preferred_username when it looks like an email", () => {
|
||||||
|
expect(
|
||||||
|
emailFromOidcProfile({ preferred_username: "[email protected]" })
|
||||||
|
).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-email preferred_username", () => {
|
||||||
|
expect(emailFromOidcProfile({ preferred_username: "nick" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing usable is present", () => {
|
||||||
|
expect(emailFromOidcProfile({ sub: "abc", name: "Nick" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseMct, parseImport } from "@/lib/rfid/parsers";
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseMct, parseImport, parseDump, parseKeyBin } from "@/lib/rfid/parsers";
|
||||||
import { exportTag } from "@/lib/rfid/exporters";
|
import { 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
|
||||||
@@ -60,3 +63,91 @@ 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