Add PN532 toolkit firmware, web UI, and embedded SPIFFS assets

Includes ESP-IDF NFC stack (deep capture, 4K Classic geometry, UL write API,
open SoftAP), React dashboard with live tag diagnostics, and docs. README
updated for APIs and lab Wi-Fi defaults.

Made-with: Cursor
This commit is contained in:
drjones
2026-03-29 09:32:55 -07:00
parent d1068d965b
commit 8968560565
73 changed files with 8802 additions and 28 deletions

129
web/src/App.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { motion } from "framer-motion";
import { NavLink, Route, Routes } from "react-router-dom";
import BrowserLogBar from "./BrowserLogBar";
import FlashBackdrop from "./FlashBackdrop";
import ScanCashFlourish from "./ScanCashFlourish";
import { NfcWsProvider } from "./NfcWsContext";
import { ToastHost } from "./toast";
import Dashboard from "./pages/Dashboard";
import ReadAnalyze from "./pages/ReadAnalyze";
import WriteClone from "./pages/WriteClone";
import Library from "./pages/Library";
import Keys from "./pages/Keys";
import RawConsole from "./pages/RawConsole";
import Settings from "./pages/Settings";
import Capture from "./pages/Capture";
import Brute from "./pages/Brute";
import Emulate from "./pages/Emulate";
import KeyLab from "./pages/KeyLab";
const nav = [
["/", "Dash"],
["/capture", "Read-all"],
["/read", "Read"],
["/write", "Write"],
["/brute", "Brute"],
["/emulate", "Emu"],
["/keylab", "KeyLab"],
["/library", "Lib"],
["/keys", "Keys"],
["/raw", "Raw"],
["/settings", "Set"],
] as const;
export default function App() {
return (
<ToastHost>
<NfcWsProvider>
<div className="hack-scanlines hack-grid relative min-h-screen pb-20">
<FlashBackdrop />
<ScanCashFlourish />
<BrowserLogBar />
<header className="relative z-40 border-b border-bubble-accent/20 bg-bubble-950/80 backdrop-blur-xl">
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-bubble-mint/60 to-transparent" />
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-bubble-rose/30 via-bubble-accent/40 to-bubble-mint/30 opacity-80" />
<div className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<motion.div
className="font-display text-lg font-normal tracking-[0.12em] sm:text-xl md:text-2xl"
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 120, damping: 18 }}
>
<motion.span
className="bg-gradient-to-r from-bubble-mint via-bubble-accent to-bubble-rose bg-clip-text text-transparent text-glow-matrix"
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
style={{
WebkitBackgroundClip: "text",
backgroundClip: "text",
backgroundImage:
"linear-gradient(90deg, #00ff9d, #00e5ff, #ff2a6d, #d4ff00, #00ff9d, #00ff9d)",
backgroundSize: "250% 100%",
}}
>
PN532
</motion.span>
<span className="ml-2 text-[9px] font-mono font-normal tracking-[0.35em] text-bubble-accent/60 sm:text-[10px]">
MAXIMAL
</span>
</motion.div>
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]">
<span className="text-bubble-accent/90"></span> RF_STACK{" "}
<span className="text-bubble-rose/80">LIVE</span>
<span className="mx-1.5 text-bubble-mint/25"></span>
<span className="text-bubble-mint/50">ws://stream</span>
</span>
</div>
<nav className="flex max-w-full flex-wrap justify-end gap-1 text-[10px] font-mono sm:gap-1.5 sm:text-[11px]">
{nav.map(([to, label]) => (
<NavLink key={to} to={to}>
{({ isActive }) => (
<motion.span
className={`inline-block rounded-md border px-1.5 py-1 sm:px-2 sm:py-1.5 ${
isActive
? "nav-hack-active"
: "border-transparent text-slate-500 hover:border-bubble-accent/35 hover:text-bubble-accent hover:shadow-[0_0_18px_rgba(0,229,255,0.25)]"
}`}
whileHover={{ scale: 1.06, y: -1 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 400, damping: 22 }}
>
<span className="text-bubble-mint/35"></span>
{label}
<span className="text-bubble-mint/35"></span>
</motion.span>
)}
</NavLink>
))}
</nav>
</div>
</header>
<main className="relative z-10 mx-auto max-w-6xl px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/capture" element={<Capture />} />
<Route path="/read" element={<ReadAnalyze />} />
<Route path="/write" element={<WriteClone />} />
<Route path="/brute" element={<Brute />} />
<Route path="/emulate" element={<Emulate />} />
<Route path="/keylab" element={<KeyLab />} />
<Route path="/library" element={<Library />} />
<Route path="/keys" element={<Keys />} />
<Route path="/raw" element={<RawConsole />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</motion.div>
</main>
</div>
</NfcWsProvider>
</ToastHost>
);
}

49
web/src/BrowserLogBar.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useNfcWs } from "./NfcWsContext";
export default function BrowserLogBar() {
const { log, exportBrowserLog, clearBrowserLog, wsOk } = useNfcWs();
return (
<div className="flash-log-bar relative z-40 mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-bubble-accent/15 px-4 py-2.5 font-mono text-[10px] text-bubble-mint/80 sm:text-xs">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-bubble-accent/10 to-transparent animate-shimmerLine" />
</div>
<div className="relative flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
<span className="shrink-0 animate-pulse text-bubble-volt"></span>
<span className="truncate">
<span className="text-bubble-accent/70">BUF</span>{" "}
<span className="text-glow-matrix font-bold text-bubble-mint">{log.length}</span>
<span className="text-bubble-mint/40">_evt</span>
</span>
<span className="hidden text-bubble-mint/20 sm:inline"></span>
<span>
<span className="text-bubble-accent/60">WS</span>{" "}
<span
className={
wsOk
? "text-glow-matrix font-bold tracking-wide text-bubble-mint"
: "animate-pulse text-glow-rose font-semibold text-bubble-rose"
}
>
{wsOk ? "SYNC" : "WAIT"}
</span>
</span>
</div>
<div className="relative flex shrink-0 gap-2">
<button
type="button"
onClick={exportBrowserLog}
className="btn-neon rounded-md border border-bubble-accent/50 bg-gradient-to-r from-bubble-accent/25 to-bubble-mint/15 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-bubble-accent sm:text-xs"
>
Exfil
</button>
<button
type="button"
onClick={clearBrowserLog}
className="rounded-md border border-bubble-rose/40 bg-bubble-rose/10 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide text-bubble-rose transition hover:border-bubble-rose hover:bg-bubble-rose/20 sm:text-xs"
>
Purge
</button>
</div>
</div>
);
}

19
web/src/FlashBackdrop.tsx Normal file
View File

@@ -0,0 +1,19 @@
/** Ambient neon soup — pointer-events none, stays under UI */
export default function FlashBackdrop() {
return (
<div className="pointer-events-none fixed inset-0 z-[2] overflow-hidden">
<div
className="absolute -left-[20%] top-[10%] h-[min(70vh,520px)] w-[min(70vh,520px)] rounded-full bg-fuchsia-600/25 blur-[100px] animate-pulseGlow"
style={{ animationDelay: "0s" }}
/>
<div
className="absolute -right-[15%] bottom-[5%] h-[min(55vh,440px)] w-[min(55vh,440px)] rounded-full bg-cyan-400/20 blur-[90px] animate-pulseGlow"
style={{ animationDelay: "1.2s" }}
/>
<div
className="absolute left-[35%] top-[40%] h-[min(45vh,360px)] w-[min(45vh,360px)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/15 blur-[80px] animate-floatSlow"
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_0%,rgba(2,4,8,0.75)_100%)]" />
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import { BINARY_CARD_FIXTURES, EMULATE_LAB, RAW_PN532_LAB, type BinaryCardFixture } from "./validationFixtures";
type Mode = "raw" | "emulate" | "binary";
type Props = {
mode: Mode;
onApplyHex: (hex: string) => void;
onPickBinary?: (f: BinaryCardFixture) => void;
className?: string;
};
export default function LabFixtureLoad({ mode, onApplyHex, onPickBinary, className = "" }: Props) {
const [resetKey, setResetKey] = useState(0);
const bump = () => setResetKey((k) => k + 1);
if (mode === "raw") {
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
<select
key={resetKey}
defaultValue=""
aria-label="Load PN532 lab command"
className="max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
onChange={(e) => {
const id = e.target.value;
if (!id) return;
const f = RAW_PN532_LAB.find((x) => x.id === id);
if (f) onApplyHex(f.hex);
bump();
}}
>
<option value="">Lab: PN532 command bytes</option>
{RAW_PN532_LAB.map((f) => (
<option key={f.id} value={f.id}>
{f.title}
</option>
))}
</select>
</div>
);
}
if (mode === "emulate") {
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
<select
key={resetKey}
defaultValue=""
aria-label="Load emulate lab frame"
className="max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
onChange={(e) => {
const id = e.target.value;
if (!id) return;
const f = EMULATE_LAB.find((x) => x.id === id);
if (f) onApplyHex(f.hex);
bump();
}}
>
<option value="">Lab: emulate / TgInit</option>
{EMULATE_LAB.map((f) => (
<option key={f.id} value={f.id}>
{f.title}
</option>
))}
</select>
</div>
);
}
return (
<div className={`space-y-2 ${className}`}>
<select
key={resetKey}
defaultValue=""
aria-label="Load synthetic card blob"
className="w-full max-w-full rounded-xl border border-bubble-mint/25 bg-black/40 px-3 py-2 font-mono text-xs text-bubble-mint/90"
onChange={(e) => {
const id = e.target.value;
if (!id) return;
const f = BINARY_CARD_FIXTURES.find((x) => x.id === id);
if (f) {
onApplyHex(f.hex);
onPickBinary?.(f);
}
bump();
}}
>
<option value="">Lab: synthetic card / NDEF blob</option>
{BINARY_CARD_FIXTURES.map((f) => (
<option key={f.id} value={f.id}>
{f.title} ({f.byteLength} B)
</option>
))}
</select>
</div>
);
}

189
web/src/NfcWsContext.tsx Normal file
View File

@@ -0,0 +1,189 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { Tag } from "./api";
import { useToolkitWs } from "./useWebSocket";
export type BrowserLogEntry = {
t: number;
channel: string;
payload: unknown;
};
export type CashVariant = "tag" | "vault";
type NfcCtx = {
wsOk: boolean;
lastTag: Tag | null;
tagPresent: boolean;
log: BrowserLogEntry[];
/** Increments on each “cha-ching” moment (new tag scan or capture line). */
cashWave: number;
cashVariant: CashVariant;
clearBrowserLog: () => void;
exportBrowserLog: () => void;
applyScanPoll: (present: boolean, tag?: Tag) => void;
};
const Ctx = createContext<NfcCtx | null>(null);
const MAX_LOG = 5000;
const LS_KEY = "pn532_browser_log_v1";
function loadPersisted(): BrowserLogEntry[] {
try {
const s = sessionStorage.getItem(LS_KEY);
if (!s) {
return [];
}
const j = JSON.parse(s) as BrowserLogEntry[];
return Array.isArray(j) ? j.slice(-MAX_LOG) : [];
} catch {
return [];
}
}
function persistLog(entries: BrowserLogEntry[]) {
try {
sessionStorage.setItem(LS_KEY, JSON.stringify(entries.slice(-MAX_LOG)));
} catch {
/* quota */
}
}
function handleScanPayload(
payload: unknown,
setLastTag: (t: Tag | null) => void,
setTagPresent: (v: boolean) => void,
) {
if (typeof payload === "object" && payload && "present" in (payload as object)) {
const p = payload as { present?: boolean };
if (p.present === false) {
setLastTag(null);
setTagPresent(false);
return;
}
}
setTagPresent(true);
if (typeof payload === "object" && payload && "uid" in (payload as object)) {
setLastTag(payload as Tag);
}
}
function isTagPayload(payload: unknown): payload is Record<string, unknown> & { uid: string } {
if (!payload || typeof payload !== "object") {
return false;
}
const p = payload as Record<string, unknown>;
if (p.present === false) {
return false;
}
return typeof p.uid === "string" && p.uid.length > 0;
}
function isCaptureRecorded(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
return (payload as { event?: string }).event === "recorded";
}
export function NfcWsProvider({ children }: { children: React.ReactNode }) {
const [lastTag, setLastTag] = useState<Tag | null>(null);
const [tagPresent, setTagPresent] = useState(false);
const [log, setLog] = useState<BrowserLogEntry[]>(() => loadPersisted());
const [cashWave, setCashWave] = useState(0);
const [cashVariant, setCashVariant] = useState<CashVariant>("tag");
const bumpLock = useRef(0);
const bump = useCallback((variant: CashVariant) => {
const now = Date.now();
if (variant === "tag" && now - bumpLock.current < 280) {
return;
}
bumpLock.current = now;
setCashVariant(variant);
setCashWave((w) => w + 1);
}, []);
const onMsg = useCallback(
(msg: string) => {
try {
const o = JSON.parse(msg) as { channel?: string; payload?: unknown };
const ch = o.channel || "unknown";
setLog((prev) => {
const next = [...prev, { t: Date.now(), channel: ch, payload: o.payload }].slice(-MAX_LOG);
persistLog(next);
return next;
});
if (ch === "scan") {
handleScanPayload(o.payload, setLastTag, setTagPresent);
if (isTagPayload(o.payload)) {
bump("tag");
}
} else if (ch === "capture" && isCaptureRecorded(o.payload)) {
bump("vault");
}
} catch {
/* ignore */
}
},
[bump],
);
const wsOk = useToolkitWs(onMsg);
const applyScanPoll = useCallback(
(present: boolean, tag?: Tag) => {
if (!present) {
setLastTag(null);
setTagPresent(false);
return;
}
if (tag) {
setLastTag(tag);
setTagPresent(true);
bump("tag");
}
},
[bump],
);
const clearBrowserLog = useCallback(() => {
setLog([]);
sessionStorage.removeItem(LS_KEY);
}, []);
const exportBrowserLog = useCallback(() => {
const blob = new Blob([JSON.stringify(log, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `pn532-browser-log-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}, [log]);
const v = useMemo(
() => ({
wsOk,
lastTag,
tagPresent,
log,
cashWave,
cashVariant,
clearBrowserLog,
exportBrowserLog,
applyScanPoll,
}),
[wsOk, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
);
return <Ctx.Provider value={v}>{children}</Ctx.Provider>;
}
export function useNfcWs() {
const x = useContext(Ctx);
if (!x) {
throw new Error("useNfcWs outside NfcWsProvider");
}
return x;
}

View File

@@ -0,0 +1,234 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { useNfcWs } from "./NfcWsContext";
const SPARKS = 16;
const COINS = 7;
function playChaChing() {
try {
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!Ctx) {
return;
}
const ctx = new Ctx();
const master = ctx.createGain();
master.gain.value = 0.11;
master.connect(ctx.destination);
const ding = (freq: number, t0: number, dur: number) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(1, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.01, t0 + dur);
o.connect(g);
g.connect(master);
o.start(t0);
o.stop(t0 + dur + 0.05);
};
const now = ctx.currentTime;
ding(523.25, now, 0.11);
ding(659.25, now + 0.055, 0.13);
ding(783.99, now + 0.1, 0.16);
ding(1046.5, now + 0.14, 0.2);
const noise = ctx.createBufferSource();
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.07, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (data.length * 0.32));
}
noise.buffer = buf;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.055, now + 0.04);
ng.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
noise.connect(ng);
ng.connect(master);
noise.start(now + 0.04);
noise.stop(now + 0.18);
void ctx.resume?.();
setTimeout(() => void ctx.close(), 700);
} catch {
/* autoplay / policy */
}
}
export default function ScanCashFlourish() {
const { cashWave, cashVariant, lastTag } = useNfcWs();
const [visible, setVisible] = useState(false);
const [burstKey, setBurstKey] = useState(0);
useEffect(() => {
if (cashWave === 0) {
return;
}
setBurstKey(cashWave);
setVisible(true);
playChaChing();
const t = window.setTimeout(() => setVisible(false), 1650);
return () => window.clearTimeout(t);
}, [cashWave]);
return (
<div className="pointer-events-none fixed left-2 top-[4.25rem] z-[55] sm:left-4 sm:top-[4.5rem] md:top-[5.25rem]">
<AnimatePresence mode="wait">
{visible ? (
<motion.div
key={burstKey}
className="relative flex flex-col items-start"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
transition={{ duration: 0.4 }}
>
<motion.div
className="absolute -left-8 -top-8 h-40 w-40 rounded-full bg-gradient-to-br from-amber-400/45 via-yellow-200/30 to-bubble-mint/35 blur-2xl"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: [0.2, 1.35, 1], opacity: [0, 1, 0.7] }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
/>
{Array.from({ length: SPARKS }).map((_, i) => {
const a = (i / SPARKS) * Math.PI * 2;
const dist = 56 + (i % 4) * 10;
return (
<motion.span
key={`s-${i}`}
className="absolute left-8 top-9 h-2 w-2 rounded-full bg-gradient-to-br from-yellow-100 to-amber-500 shadow-[0_0_12px_rgba(250,204,21,1)]"
initial={{ x: 0, y: 0, opacity: 0, scale: 0 }}
animate={{
x: Math.cos(a) * dist,
y: Math.sin(a) * dist,
opacity: [0, 1, 0],
scale: [0, 1.3, 0.3],
}}
transition={{ duration: 0.8, delay: i * 0.018, ease: "easeOut" }}
/>
);
})}
{Array.from({ length: COINS }).map((_, i) => (
<motion.span
key={`c-${i}`}
className="absolute left-8 top-8 text-xl sm:text-2xl"
initial={{ x: 0, y: 0, opacity: 0, rotate: -30, scale: 0 }}
animate={{
x: (i % 2 === 0 ? 1 : -1) * (36 + i * 12),
y: -32 - i * 11,
opacity: [0, 1, 0],
rotate: i * 35,
scale: [0, 1.15, 0.85],
}}
transition={{ duration: 0.9, delay: 0.04 + i * 0.035, ease: [0.22, 1, 0.36, 1] }}
>
🪙
</motion.span>
))}
<motion.div
className="relative flex h-[4.75rem] w-[4.75rem] items-center justify-center sm:h-[5.5rem] sm:w-[5.5rem]"
initial={{ scale: 0, rotate: -40 }}
animate={{
scale: [0, 1.3, 0.92, 1.06, 1],
rotate: [-40, 12, -6, 3, 0],
}}
transition={{ duration: 0.7, ease: [0.34, 1.56, 0.64, 1] }}
>
<motion.div
className="absolute inset-0 rounded-2xl border-2 border-yellow-200/90"
animate={{
boxShadow: [
"0 0 25px rgba(250,204,21,0.55), inset 0 0 22px rgba(254,240,138,0.2)",
"0 0 50px rgba(0,255,157,0.5), inset 0 0 28px rgba(0,229,255,0.15)",
"0 0 28px rgba(250,204,21,0.6), inset 0 0 18px rgba(254,240,138,0.25)",
],
}}
transition={{ duration: 1.1, repeat: 2 }}
/>
<svg
viewBox="0 0 100 100"
className="relative z-[1] h-[72%] w-[72%] drop-shadow-[0_0_14px_rgba(250,204,21,0.9)]"
aria-hidden
>
<defs>
<linearGradient id="cashGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#fef9c3" />
<stop offset="40%" stopColor="#facc15" />
<stop offset="100%" stopColor="#a16207" />
</linearGradient>
<filter id="cashGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.2" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<circle cx="50" cy="50" r="44" fill="url(#cashGold)" filter="url(#cashGlow)" />
<circle cx="50" cy="50" r="40" fill="none" stroke="#422006" strokeOpacity="0.28" strokeWidth="2" />
<text
x="50"
y="64"
textAnchor="middle"
fill="#713f12"
fontSize="54"
fontWeight="700"
fontFamily="Audiowide, Orbitron, system-ui, sans-serif"
>
$
</text>
</svg>
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"
initial={false}
>
<motion.div
className="absolute inset-y-2 w-2/5 bg-gradient-to-r from-transparent via-white/60 to-transparent skew-x-[-18deg]"
initial={{ left: "-40%" }}
animate={{ left: "140%" }}
transition={{ duration: 0.55, delay: 0.12, ease: "easeInOut" }}
/>
</motion.div>
</motion.div>
<motion.div
className="relative -mt-0.5 max-w-[12rem] pl-0.5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, type: "spring", stiffness: 320, damping: 22 }}
>
<motion.p
className="font-display text-[10px] tracking-[0.42em] text-yellow-100 sm:text-[11px]"
animate={{
textShadow: [
"0 0 10px rgba(250,204,21,0.95)",
"0 0 22px rgba(0,255,157,0.75)",
"0 0 12px rgba(250,204,21,0.9)",
],
}}
transition={{ duration: 0.75, repeat: 3 }}
>
{cashVariant === "vault" ? "VAULT · LOCKED" : "CHA-CHING · HIT"}
</motion.p>
{lastTag?.uid ? (
<motion.p
className="mt-1 truncate font-mono text-[10px] font-bold tracking-[0.15em] text-bubble-mint sm:text-xs"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.22 }}
>
{lastTag.uid}
</motion.p>
) : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}

62
web/src/api.ts Normal file
View File

@@ -0,0 +1,62 @@
/** Stored API origin; trailing slashes stripped so paths join cleanly. */
export function apiBaseUrl(): string {
return (localStorage.getItem("apiBase") || "").replace(/\/+$/, "");
}
export function apiUrl(path: string): string {
const b = apiBaseUrl();
const p = path.startsWith("/") ? path : `/${path}`;
return b ? `${b}${p}` : p;
}
export async function apiGet<T>(path: string): Promise<T> {
const r = await fetch(apiUrl(path));
if (!r.ok) {
throw new Error(await r.text());
}
return r.json() as Promise<T>;
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const r = await fetch(apiUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
throw new Error(await r.text());
}
return r.json() as Promise<T>;
}
export type SessionInfo = {
usedBytes: number;
maxBytes: number;
lines: number;
full: boolean;
deepCapture: boolean;
};
export type Status = {
app: string;
uptimeMs: number;
freeHeap: number;
wifiMode: number;
pn532?: { ic: number; fwHi: number; fwLo: number };
scanning: boolean;
session?: SessionInfo;
};
export type Tag = {
uid: string;
uidLen: number;
atqa: number;
sak: number;
typeHint: number;
/** ATQA as 4 hex digits (MSB first, same as firmware). */
atqaHex?: string;
sakHex?: string;
typeGuess?: string;
/** PN532 `GetGeneralStatus` payload bytes (chip-dependent length). */
pn532GeneralStatus?: number[];
};

127
web/src/index.css Normal file
View File

@@ -0,0 +1,127 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--hack-matrix: #00ff9d;
--hack-cyan: #00e5ff;
--hack-void: #020408;
--hack-rose: #ff2a6d;
}
.light {
color-scheme: light;
}
/* Deep void + animated grid + aurora blobs */
.hack-grid {
background-color: var(--hack-void);
background-image: linear-gradient(rgba(0, 255, 157, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 229, 255, 0.05) 1px, transparent 1px),
radial-gradient(ellipse 100% 60% at 50% -30%, rgba(0, 229, 255, 0.18), transparent 55%),
radial-gradient(ellipse 70% 50% at 110% 80%, rgba(255, 42, 109, 0.12), transparent 50%),
radial-gradient(ellipse 50% 40% at -10% 60%, rgba(0, 255, 157, 0.1), transparent 45%);
background-size: 20px 20px, 20px 20px, 100% 100%, 100% 100%, 100% 100%;
}
/* CRT + vignette */
.hack-scanlines::before {
content: "";
pointer-events: none;
position: fixed;
inset: 0;
z-index: 35;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.18) 2px,
rgba(0, 0, 0, 0.18) 4px
);
opacity: 0.45;
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.5);
}
.light.hack-root .hack-scanlines::before {
opacity: 0.06;
}
/* Neon chassis panels */
.glass {
@apply relative rounded-2xl border border-bubble-mint/25 bg-bubble-900/75 shadow-insetTerminal backdrop-blur-lg;
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.12), 0 0 40px -12px rgba(0, 255, 157, 0.25),
0 12px 40px -12px rgba(0, 0, 0, 0.75), inset 0 1px 0 0 rgba(0, 255, 157, 0.1);
transition: box-shadow 0.35s ease, border-color 0.35s ease;
}
.glass:hover {
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.22), 0 0 55px -10px rgba(0, 255, 157, 0.4),
0 16px 48px -12px rgba(0, 0, 0, 0.8), inset 0 1px 0 0 rgba(0, 229, 255, 0.12);
border-color: rgba(0, 229, 255, 0.35);
}
.light .glass {
@apply border-slate-300/80 bg-white/90 shadow-xl;
box-shadow: 0 4px 24px -4px rgba(0, 0, 0, 0.12);
}
.light .glass:hover {
box-shadow: 0 8px 32px -4px rgba(0, 0, 0, 0.15);
}
.text-glow-matrix {
text-shadow: 0 0 12px rgba(0, 255, 157, 0.8), 0 0 28px rgba(0, 255, 157, 0.45), 0 0 60px rgba(0, 229, 255, 0.25);
}
.text-glow-cyan {
text-shadow: 0 0 14px rgba(0, 229, 255, 0.75), 0 0 36px rgba(0, 229, 255, 0.35);
}
.text-glow-rose {
text-shadow: 0 0 16px rgba(255, 42, 109, 0.65);
}
/* Nav active — laser bracket */
.nav-hack-active {
@apply border border-bubble-mint/70 bg-gradient-to-br from-bubble-mint/20 to-bubble-accent/10 text-bubble-mint;
box-shadow: 0 0 28px -4px rgba(0, 255, 157, 0.55), 0 0 40px -8px rgba(0, 229, 255, 0.35),
inset 0 0 20px -8px rgba(0, 229, 255, 0.2);
animation: borderPulse 2s ease-in-out infinite;
}
/* Primary flashy buttons (use alongside existing classes) */
.btn-neon {
@apply relative overflow-hidden font-bold transition-all duration-300;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.btn-neon:hover {
transform: translateY(-1px) scale(1.02);
box-shadow: 0 0 35px rgba(0, 255, 157, 0.45), 0 0 50px rgba(0, 229, 255, 0.25);
}
.btn-neon::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 40%, rgba(255, 255, 255, 0.2) 50%, transparent 60%);
transform: translateX(-100%);
animation: shimmerLine 3s ease-in-out infinite;
}
/* Top status bar chrome */
.flash-log-bar {
background: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(5, 18, 16, 0.92), rgba(0, 0, 0, 0.85));
box-shadow: 0 4px 24px rgba(0, 255, 157, 0.08), inset 0 1px 0 rgba(0, 229, 255, 0.15);
}
.flash-log-bar::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 255, 157, 0.5), rgba(0, 229, 255, 0.6), transparent);
}

22
web/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const th = localStorage.getItem("theme");
if (th === "light") {
document.documentElement.classList.add("light");
document.documentElement.classList.remove("dark");
}
const root = document.getElementById("root");
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>,
);
}

85
web/src/nfcUtils.ts Normal file
View File

@@ -0,0 +1,85 @@
/** MIFARE Classic sector trailer access bits (C1 C2 C3) decode — informational only */
export type AccessBits = {
c1: number;
c2: number;
c3: number;
};
export function parseTrailerAccess(hex16: string): AccessBits | null {
const h = hex16.replace(/\s/g, "");
if (h.length !== 32) {
return null;
}
const b = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
b[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
const ab0 = b[6];
const ab1 = b[7];
const ab2 = b[8];
const c1 = ((ab0 >> 4) & 1) | ((ab1 >> 0) & 2) | ((ab2 >> 0) & 4);
const c2 = ((ab0 >> 5) & 1) | ((ab1 >> 1) & 2) | ((ab2 >> 1) & 4);
const c3 = ((ab0 >> 6) & 1) | ((ab1 >> 2) & 2) | ((ab2 >> 2) & 4);
return { c1, c2, c3 };
}
export function sakLabel(sak: number): string {
switch (sak) {
case 0x08:
return "Ultralight family";
case 0x09:
return "Mini / Classic";
case 0x18:
return "Classic 1K";
case 0x19:
return "Classic 4K";
default:
return `SAK 0x${sak.toString(16).toUpperCase()}`;
}
}
export function uidWithSeparators(uid: string): string {
const clean = uid.replace(/\s/g, "");
if (clean.length % 2 !== 0) {
return uid;
}
return clean.match(/.{2}/g)?.join(":") ?? clean;
}
export function typeHintLabel(hint: number): string {
switch (hint) {
case 1:
return "Classic-style (MIFARE)";
case 2:
return "Type 2 / Ultralight-style";
default:
return "Unknown (check SAK/ATQA)";
}
}
/** Short cheat-sheet lines for the live scan panel (not a full ISO parser). */
export function scanCheatLines(tag: { uidLen: number; atqa: number; sak: number }): string[] {
const lines: string[] = [];
if (tag.uidLen === 4) {
lines.push("4-byte UID — single cascade level (CL1).");
} else if (tag.uidLen === 7) {
lines.push("7-byte UID — double cascade (CL1 + CL2) typical for 7B tags.");
} else if (tag.uidLen === 10) {
lines.push("10-byte UID — triple cascade path.");
}
if (tag.sak === 0x08) {
lines.push("SAK 0x08 — often Ultralight / Type 2; use page read/write for user memory.");
}
if (tag.sak === 0x18 || tag.sak === 0x19) {
lines.push("MIFARE Classic — authenticate per sector trailer, then block read/write.");
}
if (tag.atqa === 0x0044 || tag.atqa === 0x4400) {
lines.push("ATQA 0x4400 pattern — very common Type A inventory response.");
}
return lines;
}
export function generalStatusHexLine(bytes: number[]): string {
return bytes.map((b) => (Number(b) & 0xff).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}

113
web/src/pages/Brute.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useState } from "react";
import { apiUrl } from "../api";
import { useToast } from "../toast";
export default function Brute() {
const toast = useToast();
const [reader, setReader] = useState<"classic1k" | "classic4k">("classic1k");
const [variations, setVariations] = useState(true);
const [extraKeys, setExtraKeys] = useState(
"FFFFFFFFFFFF\nA0A1A2A3A4A5\nD3F7D3F7D3F7",
);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string>("");
const run = async () => {
setBusy(true);
setResult("");
try {
const keysHex = extraKeys
.split(/\r?\n/)
.map((l) => l.replace(/\s/g, "").toUpperCase())
.filter((l) => l.length === 12);
const body = {
readerType: reader,
variations,
keysHex,
};
const r = await fetch(apiUrl("/api/mifare/dictionary-attack"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) {
throw new Error(text);
}
try {
const j = JSON.parse(text) as object;
setResult(JSON.stringify(j, null, 2));
} catch {
setResult(text);
}
toast("Dictionary pass finished");
} catch (e) {
toast(String(e), "err");
} finally {
setBusy(false);
}
};
return (
<div className="glass space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Dictionary attack</h1>
<p className="text-sm text-slate-400">
Select card family, optionally enable <strong>bounded variations</strong> (XOR / low-nibble tweaks per
key). Firmware tries a <strong>built-in community list</strong> (Proxmark3 / MCT-style defaults) plus
your lines below. This is <strong>not</strong> a full 2 exhaustive search only keys you and the
community already know. Hold a <strong>MIFARE Classic</strong> on the coil.{" "}
<a
className="text-bubble-mint underline"
href="https://github.com/RfidResearchGroup/proxmark3/blob/master/client/dictionaries/mfc_default_keys.dic"
target="_blank"
rel="noreferrer"
>
More keys online
</a>
.
</p>
<label className="block text-sm">
Reader / map
<select
value={reader}
onChange={(e) => setReader(e.target.value as "classic1k" | "classic4k")}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
>
<option value="classic1k">MIFARE Classic 1K (sectors 015)</option>
<option value="classic4k">MIFARE Classic 4K (sectors 039, proper 4/16-block geometry)</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={variations} onChange={(e) => setVariations(e.target.checked)} />
Variations (extra tries per key slower, wider net)
</label>
<label className="block text-sm">
Extra keys (one 12-hex key per line, merged after built-in list)
<textarea
value={extraKeys}
onChange={(e) => setExtraKeys(e.target.value)}
rows={6}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
/>
</label>
<button
type="button"
disabled={busy}
onClick={run}
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-500 px-6 py-3 font-bold text-white disabled:opacity-50"
>
{busy ? "Running…" : "Run dictionary attack"}
</button>
{result && (
<pre className="max-h-96 overflow-auto rounded-2xl border border-white/10 bg-black/40 p-4 text-xs text-bubble-mint">
{result}
</pre>
)}
</div>
);
}

168
web/src/pages/Capture.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { motion } from "framer-motion";
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPost, apiUrl, type Status } from "../api";
import { useNfcWs } from "../NfcWsContext";
import { useToast } from "../toast";
export default function Capture() {
const toast = useToast();
const { log } = useNfcWs();
const [st, setSt] = useState<Status | null>(null);
const refresh = useCallback(() => {
apiGet<Status>("/api/status")
.then(setSt)
.catch(() => {});
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, 2000);
return () => clearInterval(id);
}, [refresh]);
useEffect(() => {
refresh();
}, [log.length, refresh]);
const lastEv = (() => {
const c = [...log].reverse().find((e) => e.channel === "capture");
if (!c) {
return "";
}
return typeof c.payload === "object" ? JSON.stringify(c.payload) : String(c.payload);
})();
const sess = st?.session;
const pct = sess ? Math.min(100, (sess.usedBytes / Math.max(1, sess.maxBytes)) * 100) : 0;
const setDeep = async (enable: boolean) => {
try {
await apiPost("/api/session/deep", { enable });
toast(
enable ? "Passive read-all on (live scan on by default at boot)" : "Passive read-all off",
);
refresh();
} catch (e) {
toast(String(e), "err");
}
};
const clearBuf = async () => {
try {
await apiPost("/api/session/clear", {});
toast("Buffer cleared — scanning can resume");
refresh();
} catch (e) {
toast(String(e), "err");
}
};
const download = async () => {
try {
const r = await fetch(apiUrl("/api/session/export"));
if (!r.ok) {
throw new Error(await r.text());
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `pn532-deep-capture-${Date.now()}.ndjson`;
a.click();
URL.revokeObjectURL(url);
toast("Download started — check your Downloads folder");
} catch (e) {
toast(String(e), "err");
}
};
return (
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="glass relative overflow-hidden p-8"
>
<div className="pointer-events-none absolute -left-20 top-0 h-48 w-48 rounded-full bg-bubble-mint/20 blur-3xl" />
<h1 className="font-display text-3xl font-bold md:text-4xl">Passive read-all mode</h1>
<p className="mt-1 text-sm font-medium text-bubble-mint/90">
Same feature as deep capture fully controlled from this screen.
</p>
<p className="mt-3 max-w-2xl text-slate-300">
Turn it on below and keep <strong>live scan</strong> running (on by default at boot). Each{" "}
<strong>new tag</strong> in the field is read <strong>passively</strong>: no per-block clicks the
firmware pulls <strong>all data it can</strong> (PN532 status + Classic sector/block dump with
default keys, or Ultralight/NTAG page sweep). Results queue in <strong>device RAM</strong>; when
full, polling pauses until you <strong>download</strong> and <strong>clear</strong>.
</p>
</motion.div>
<div className="glass p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="font-display text-lg font-semibold">Read-all session buffer</h2>
<p className="text-sm text-slate-400">
{sess?.lines ?? 0} full dumps · {sess?.usedBytes ?? 0} / {sess?.maxBytes ?? "—"} bytes RAM
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDeep(!(sess?.deepCapture ?? false))}
className={`rounded-2xl px-4 py-2 font-semibold ${
sess?.deepCapture
? "bg-bubble-mint/20 text-bubble-mint ring-2 ring-bubble-mint/40"
: "border border-white/15 bg-white/5"
}`}
>
{sess?.deepCapture ? "Passive read-all ON" : "Enable passive read-all"}
</button>
<button
type="button"
onClick={download}
className="rounded-2xl bg-gradient-to-r from-bubble-accent to-indigo-400 px-4 py-2 font-bold text-white shadow-glow"
>
Download NDJSON
</button>
<button type="button" onClick={clearBuf} className="rounded-2xl border border-white/20 px-4 py-2">
Clear buffer
</button>
</div>
</div>
<div className="mt-6 h-4 overflow-hidden rounded-full bg-black/40">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-bubble-accent to-bubble-mint"
initial={false}
animate={{ width: `${pct}%` }}
transition={{ type: "spring", stiffness: 120, damping: 20 }}
/>
</div>
{sess?.full && (
<div className="mt-6 rounded-2xl border-2 border-bubble-rose/50 bg-bubble-rose/10 p-4 text-center">
<p className="font-display text-lg font-bold text-bubble-rose">Buffer full reader paused</p>
<p className="mt-1 text-sm text-slate-300">
Tap <strong>Download NDJSON</strong> to pull every card profile to your phone, then{" "}
<strong>Clear buffer</strong> to resume field scans.
</p>
</div>
)}
{sess?.deepCapture && st && !st.scanning && (
<p className="mt-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-100">
Firmware normally has <strong>Live scan</strong> on at boot. If you turned it off, enable it on the
Dashboard.
</p>
)}
{lastEv && (
<pre className="mt-4 max-h-40 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs text-slate-300">
Last capture event: {lastEv}
</pre>
)}
</div>
</div>
);
}

234
web/src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,234 @@
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiPost, apiGet, type Status, type Tag } from "../api";
import { useNfcWs } from "../NfcWsContext";
import { useToast } from "../toast";
import {
generalStatusHexLine,
sakLabel,
scanCheatLines,
typeHintLabel,
uidWithSeparators,
} from "../nfcUtils";
import HeroVisual from "../ui/HeroVisual";
export default function Dashboard() {
const toast = useToast();
const { lastTag, applyScanPoll } = useNfcWs();
const [st, setSt] = useState<Status | null>(null);
const [scan, setScan] = useState(true);
const poll = () => {
apiGet<Status>("/api/status")
.then(setSt)
.catch(() => toast("Status unreachable", "err"));
};
useEffect(() => {
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, [toast]);
useEffect(() => {
if (st && typeof st.scanning === "boolean") {
setScan(st.scanning);
}
}, [st]);
const toggleScan = async () => {
try {
await apiPost<{ enable: boolean }>("/api/nfc/scan", { enable: !scan });
setScan(!scan);
toast(scan ? "Continuous scan off" : "Continuous scan on");
} catch (e) {
toast(String(e), "err");
}
};
const pollOnce = async () => {
try {
const j = await apiPost<{ present: boolean; tag?: Tag }>("/api/nfc/poll", {});
if (j.present && j.tag) {
applyScanPoll(true, j.tag);
toast(`Tag ${j.tag.uid}`);
} else {
applyScanPoll(false);
toast("No tag");
}
} catch (e) {
toast(String(e), "err");
}
};
return (
<div className="space-y-8">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="glass relative overflow-hidden p-6 md:p-10"
>
<div className="pointer-events-none absolute inset-0 bg-[conic-gradient(from_180deg_at_50%_120%,rgba(0,229,255,0.08),transparent_40%,rgba(255,42,109,0.06),transparent_70%)]" />
<div className="relative grid gap-8 lg:grid-cols-[1fr_min(320px,40%)] lg:items-center">
<div>
<p className="font-mono text-[10px] font-bold tracking-[0.4em] text-bubble-accent/80">COMMAND_LAYER</p>
<h1 className="font-display mt-2 text-3xl font-normal tracking-wide text-glow-matrix md:text-5xl">
NFC <span className="text-bubble-accent">CONTROL</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-400">
Full-time scan out of the box. Fat RF retries. Browser log + device session export crank it from
your phone.
</p>
{st?.session && (st.session.deepCapture || st.session.usedBytes > 0) && (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="mt-5 flex flex-wrap items-center gap-3 rounded-xl border border-bubble-accent/40 bg-gradient-to-r from-bubble-accent/15 to-bubble-mint/10 px-4 py-3 text-sm shadow-glowCyan"
>
<span className="font-mono text-bubble-mint">
RAM {st.session.usedBytes}/{st.session.maxBytes}
{st.session.full ? " · LOCKED" : ""}
</span>
<Link
to="/capture"
className="btn-neon rounded-lg bg-bubble-mint/20 px-3 py-1 text-xs font-bold text-bubble-mint"
>
Read-all
</Link>
</motion.div>
)}
<div className="mt-8 flex flex-wrap gap-3">
<motion.button
type="button"
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.98 }}
onClick={pollOnce}
className="btn-neon rounded-xl bg-gradient-to-r from-bubble-accent via-cyan-400 to-bubble-mint px-6 py-3 text-sm font-black uppercase tracking-wider text-bubble-950 shadow-neonBtn"
>
Poll tag
</motion.button>
<motion.button
type="button"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
onClick={toggleScan}
className={`rounded-xl border-2 px-6 py-3 text-sm font-bold uppercase tracking-wide transition ${
scan
? "border-bubble-mint/70 bg-bubble-mint/15 text-glow-matrix text-bubble-mint shadow-glow"
: "border-white/20 bg-black/30 text-slate-400 hover:border-bubble-accent/50 hover:text-bubble-accent"
}`}
>
{scan ? "Stop scan" : "Start scan"}
</motion.button>
</div>
</div>
<div className="relative flex justify-center lg:justify-end">
<div className="relative w-full max-w-[280px] opacity-90 drop-shadow-[0_0_40px_rgba(0,229,255,0.35)]">
<HeroVisual />
</div>
</div>
</div>
</motion.div>
<div className="grid gap-5 md:grid-cols-2">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08 }}
className="glass p-6"
>
<h2 className="font-display text-lg font-normal text-bubble-accent text-glow-cyan">Device stack</h2>
{st ? (
<ul className="mt-4 space-y-3 font-mono text-sm text-slate-300">
<li className="flex justify-between border-b border-white/5 pb-2">
<span className="text-slate-500">uptime</span>
<span className="text-bubble-mint">{(st.uptimeMs / 1000).toFixed(1)}s</span>
</li>
<li className="flex justify-between border-b border-white/5 pb-2">
<span className="text-slate-500">heap</span>
<span className="text-white">{st.freeHeap}</span>
</li>
<li className="flex justify-between">
<span className="text-slate-500">PN532</span>
<span className="text-bubble-accent">
{st.pn532 ? `IC${st.pn532.ic} v${st.pn532.fwHi}.${st.pn532.fwLo}` : "n/a"}
</span>
</li>
</ul>
) : (
<p className="mt-4 animate-pulse font-mono text-sm text-bubble-accent/60">Pulling status</p>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.14 }}
className="glass p-6"
>
<h2 className="font-display text-lg font-normal text-bubble-rose/90 text-glow-rose">Live tag</h2>
{lastTag ? (
<div className="mt-4 space-y-4 rounded-xl border border-bubble-mint/25 bg-black/35 p-4 shadow-glow">
<div>
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-mint/50">UID</p>
<div className="font-mono text-xl font-bold tracking-[0.15em] text-glow-matrix text-bubble-mint md:text-2xl">
{uidWithSeparators(lastTag.uid)}
</div>
<p className="mt-1 font-mono text-[11px] text-slate-500">
raw {lastTag.uid} · {lastTag.uidLen} byte{lastTag.uidLen === 1 ? "" : "s"}
</p>
</div>
<div className="grid gap-3 border-t border-white/5 pt-3 font-mono text-[11px] text-slate-300 sm:grid-cols-2">
<div>
<span className="text-slate-500">ATQA</span>{" "}
<span className="text-bubble-accent">
0x{(lastTag.atqaHex ?? lastTag.atqa.toString(16).toUpperCase().padStart(4, "0")).slice(-4)}
</span>
<span className="ml-2 text-slate-500">({lastTag.atqa})</span>
</div>
<div>
<span className="text-slate-500">SAK</span>{" "}
<span className="text-bubble-accent">
0x{(lastTag.sakHex ?? lastTag.sak.toString(16).toUpperCase().padStart(2, "0")).slice(-2)}
</span>
<span className="ml-1 text-slate-400"> {sakLabel(lastTag.sak)}</span>
</div>
<div className="sm:col-span-2">
<span className="text-slate-500">Guess</span>{" "}
<span className="text-bubble-mint">{lastTag.typeGuess ?? typeHintLabel(lastTag.typeHint)}</span>
<span className="ml-2 text-slate-600">· hint {lastTag.typeHint}</span>
</div>
</div>
{lastTag.pn532GeneralStatus && lastTag.pn532GeneralStatus.length > 0 && (
<div className="rounded-lg border border-bubble-accent/20 bg-black/40 p-3">
<p className="text-[10px] font-mono uppercase tracking-widest text-bubble-accent/70">
PN532 general status
</p>
<p className="mt-2 break-all font-mono text-[10px] leading-relaxed text-slate-400">
{generalStatusHexLine(lastTag.pn532GeneralStatus)}
</p>
<p className="mt-2 text-[10px] text-slate-600">
Raw bytes from the chip right after this inventory (error flags, last command, tag count
see NXP PN532 user manual).
</p>
</div>
)}
<ul className="space-y-1 border-t border-white/5 pt-3 text-[11px] leading-relaxed text-slate-500">
{scanCheatLines(lastTag).map((line, i) => (
<li key={i} className="flex gap-2">
<span className="text-bubble-mint/40"></span>
<span>{line}</span>
</li>
))}
</ul>
</div>
) : (
<p className="mt-6 font-mono text-sm text-bubble-mint/40">Listening on the field</p>
)}
</motion.div>
</div>
</div>
);
}

74
web/src/pages/Emulate.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import LabFixtureLoad from "../LabFixtureLoad";
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
import { apiUrl } from "../api";
import { useToast } from "../toast";
export default function Emulate() {
const toast = useToast();
const [hex, setHex] = useState("8C");
const [out, setOut] = useState("");
const [busy, setBusy] = useState(false);
const send = async () => {
setBusy(true);
setOut("");
try {
const r = await fetch(apiUrl("/api/nfc/emulate-raw"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hex: hex.replace(/\s/g, "") }),
});
const t = await r.text();
if (!r.ok) {
throw new Error(t);
}
try {
setOut(JSON.stringify(JSON.parse(t), null, 2));
} catch {
setOut(t);
}
toast("PN532 emulation command sent");
} catch (e) {
toast(String(e), "err");
} finally {
setBusy(false);
}
};
return (
<div className="glass space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Card emulation (PN532 target mode)</h1>
<p className="text-sm text-slate-400">
Sends <code className="text-bubble-mint">TgInitAsTarget</code> (0x8C) and following bytes as one PN532
payload. Real card emulation depends on UID length, timing, and reader behavior this is an{" "}
<strong>expert / experimental</strong> path. Build the byte sequence from NXP UM0701 or community
examples. Wrong frames can leave the RF stack busy; power-cycle if the field acts stuck.
</p>
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
<LabFixtureLoad mode="emulate" onApplyHex={(h) => setHex(h)} />
<label className="block text-sm">
Command + parameters (hex, no spaces required)
<textarea
value={hex}
onChange={(e) => setHex(e.target.value)}
rows={4}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
/>
</label>
<button
type="button"
disabled={busy}
onClick={send}
className="rounded-2xl bg-bubble-accent px-6 py-2 font-semibold text-white disabled:opacity-50"
>
{busy ? "Sending…" : "Send emulate frame"}
</button>
{out && (
<pre className="max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/40 p-4 text-xs text-slate-200">
{out}
</pre>
)}
</div>
);
}

203
web/src/pages/KeyLab.tsx Normal file
View File

@@ -0,0 +1,203 @@
import { useMemo, useState } from "react";
import { useToast } from "../toast";
import Panel from "../ui/Panel";
function parseKey(hex: string): number[] | null {
const h = hex.replace(/\s/g, "");
if (h.length !== 12) {
return null;
}
const out: number[] = [];
for (let i = 0; i < 12; i += 2) {
const b = parseInt(h.slice(i, i + 2), 16);
if (Number.isNaN(b)) {
return null;
}
out.push(b);
}
return out;
}
function keyToHex(k: number[]) {
return k.map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join("");
}
function xorSpan(bases: number[][]): number[][] {
const n = bases.length;
const seen = new Set<string>();
const out: number[][] = [];
for (let mask = 0; mask < 1 << n; mask++) {
const v = new Array(6).fill(0) as number[];
for (let b = 0; b < n; b++) {
if (mask & (1 << b)) {
for (let i = 0; i < 6; i++) {
v[i] ^= bases[b][i];
}
}
}
const hx = keyToHex(v);
if (!seen.has(hx)) {
seen.add(hx);
out.push([...v]);
}
}
return out;
}
function shannonEntropyBitsPerByte(hex: string): number | null {
const clean = hex.replace(/\s/g, "");
if (clean.length % 2) {
return null;
}
const bytes: number[] = [];
for (let i = 0; i < clean.length; i += 2) {
const v = parseInt(clean.slice(i, i + 2), 16);
if (Number.isNaN(v)) {
return null;
}
bytes.push(v);
}
if (!bytes.length) {
return null;
}
const freq = new Map<number, number>();
for (const b of bytes) {
freq.set(b, (freq.get(b) || 0) + 1);
}
let H = 0;
const n = bytes.length;
for (const c of freq.values()) {
const p = c / n;
H -= p * Math.log2(p);
}
return H;
}
export default function KeyLab() {
const toast = useToast();
const [k1, setK1] = useState("FFFFFFFFFFFF");
const [k2, setK2] = useState("A0A1A2A3A4A5");
const [k3, setK3] = useState("000000000000");
const [use3, setUse3] = useState(false);
const [blobHex, setBlobHex] = useState("DEADBEEF");
const bases = useMemo(() => {
const a = parseKey(k1);
const b = parseKey(k2);
const c = parseKey(k3);
if (!a || !b) {
return null;
}
return use3 && c ? [a, b, c] : [a, b];
}, [k1, k2, k3, use3]);
const span = useMemo(() => (bases ? xorSpan(bases) : []), [bases]);
const entropy = useMemo(() => shannonEntropyBitsPerByte(blobHex), [blobHex]);
const copySpan = () => {
const lines = span.map((v) => keyToHex(v)).join("\n");
navigator.clipboard.writeText(lines);
toast("Copied XOR span keys");
};
const pushToKeysPage = () => {
const lines = span.map((v) => keyToHex(v)).join("\n");
sessionStorage.setItem("pn532_keylab_import", lines);
toast("Stored for Keys page — open Keys and tap “Import Key Lab”");
};
return (
<div className="space-y-6">
<div className="glass relative overflow-hidden p-8">
<div className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full bg-bubble-accent/15 blur-3xl" />
<h1 className="font-display text-3xl font-bold text-glow-matrix md:text-4xl">Key Lab</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-400">
XOR your base keys together in every combination more candidates to paste into <strong>Brute</strong> or{" "}
<strong>Keys</strong>. Entropy readout is just for fun on random hex blobs.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Panel title="XOR span generator" badge="mix">
<p className="mb-4 text-xs leading-relaxed text-slate-500">
All XOR combinations of your base keys (2 bases up to 4 keys; 3 bases up to 8). Each result is a
valid 6-byte Classic key candidate.
</p>
<label className="block text-xs text-slate-400">
Base A
<input
value={k1}
onChange={(e) => setK1(e.target.value.toUpperCase())}
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
/>
</label>
<label className="mt-3 block text-xs text-slate-400">
Base B
<input
value={k2}
onChange={(e) => setK2(e.target.value.toUpperCase())}
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
/>
</label>
<label className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={use3} onChange={(e) => setUse3(e.target.checked)} />
Use third base
</label>
{use3 ? (
<label className="mt-2 block text-xs text-slate-400">
Base C
<input
value={k3}
onChange={(e) => setK3(e.target.value.toUpperCase())}
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-sm"
/>
</label>
) : null}
{!bases ? (
<p className="mt-4 text-sm text-bubble-rose">Enter valid 12-hex keys.</p>
) : (
<>
<ul className="mt-4 max-h-48 space-y-1 overflow-auto rounded-xl border border-bubble-mint/15 bg-black/35 p-3 font-mono text-xs text-bubble-mint">
{span.map((v) => (
<li key={keyToHex(v)}>{keyToHex(v)}</li>
))}
</ul>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={copySpan}
className="rounded-xl bg-bubble-accent px-4 py-2 text-sm font-semibold text-bubble-950"
>
Copy all
</button>
<button
type="button"
onClick={pushToKeysPage}
className="rounded-xl border border-bubble-mint/40 px-4 py-2 text-sm text-bubble-mint"
>
Stage for Keys
</button>
</div>
</>
)}
</Panel>
<Panel title="Blob entropy" badge="analysis">
<p className="mb-4 text-xs text-slate-500">
Shannon entropy per byte of your hex blob (08). Random uniform bytes ~8; sparse UID-like lower.
</p>
<textarea
value={blobHex}
onChange={(e) => setBlobHex(e.target.value)}
rows={5}
className="w-full rounded-xl border border-white/10 bg-black/30 p-3 font-mono text-xs"
/>
<div className="mt-4 rounded-xl border border-bubble-accent/25 bg-bubble-accent/5 p-4 font-mono text-sm text-bubble-accent">
{entropy == null ? "Invalid hex (even length, 0-9A-F)" : `${entropy.toFixed(3)} bits / byte`}
</div>
</Panel>
</div>
</div>
);
}

137
web/src/pages/Keys.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { useToast } from "../toast";
import { LAB_DICTIONARY_KEYS, LAB_PURPOSE_NOTE } from "../validationFixtures";
const DEFAULT_KEYS = [
"FFFFFFFFFFFF",
"A0A1A2A3A4A5",
"D3F7D3F7D3F7",
"000000000000",
];
const KEYSTORE = "pn532_key_dict_v1";
export default function Keys() {
const toast = useToast();
const [dict, setDict] = useState<string[]>([]);
const [line, setLine] = useState("");
useEffect(() => {
const raw = localStorage.getItem(KEYSTORE);
setDict(raw ? (JSON.parse(raw) as string[]) : [...DEFAULT_KEYS]);
}, []);
const save = (next: string[]) => {
localStorage.setItem(KEYSTORE, JSON.stringify(next));
setDict(next);
};
const mergeLabKeys = () => {
const next = [...dict];
let added = 0;
for (const k of LAB_DICTIONARY_KEYS) {
if (!next.includes(k)) {
next.push(k);
added++;
}
}
if (!added) {
toast("Lab key corpus already merged", "info");
return;
}
save(next);
toast(`Added ${added} public lab key(s)`);
};
const importKeyLab = () => {
const raw = sessionStorage.getItem("pn532_keylab_import");
if (!raw?.trim()) {
toast("Key Lab has nothing staged", "info");
return;
}
const keys = raw
.split(/\r?\n/)
.map((l) => l.replace(/\s/g, "").toUpperCase())
.filter((l) => l.length === 12);
if (!keys.length) {
toast("No valid 12-hex keys in stash", "err");
return;
}
const next = [...dict];
let n = 0;
for (const k of keys) {
if (!next.includes(k)) {
next.push(k);
n++;
}
}
save(next);
sessionStorage.removeItem("pn532_keylab_import");
toast(n ? `Merged ${n} key(s) from Key Lab` : "Keys already in list");
};
const add = () => {
const k = line.replace(/\s/g, "").toUpperCase();
if (k.length !== 12) {
toast("12 hex chars required", "err");
return;
}
if (!dict.includes(k)) {
save([k, ...dict]);
toast("Key added");
} else {
toast("Already in dictionary");
}
setLine("");
};
return (
<div className="glass space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Key manager</h1>
<p className="text-sm text-slate-400">
Dictionary for manual trials (not a cloud rainbow table). Keys stay in your browser.
</p>
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={mergeLabKeys}
className="btn-neon rounded-xl border border-bubble-mint/40 bg-bubble-mint/10 px-4 py-2 font-mono text-xs font-bold text-bubble-mint"
>
Merge lab corpus
</button>
<button
type="button"
onClick={importKeyLab}
className="rounded-xl border border-bubble-accent/45 bg-bubble-accent/10 px-4 py-2 font-mono text-xs font-bold text-bubble-accent"
>
Import Key Lab stash
</button>
</div>
<div className="flex gap-2">
<input
value={line}
onChange={(e) => setLine(e.target.value)}
placeholder="New key"
className="flex-1 rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
/>
<button type="button" className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold" onClick={add}>
Add
</button>
</div>
<ul className="space-y-2">
{dict.map((k) => (
<li
key={k}
className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-2 font-mono text-sm"
>
{k}
<button type="button" className="text-rose-300" onClick={() => save(dict.filter((x) => x !== k))}>
×
</button>
</li>
))}
</ul>
</div>
);
}

133
web/src/pages/Library.tsx Normal file
View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import LabFixtureLoad from "../LabFixtureLoad";
import { LIBRARY_LAB_SAMPLES, LAB_PURPOSE_NOTE } from "../validationFixtures";
import type { BinaryCardFixture } from "../validationFixtures";
import { useToast } from "../toast";
type Saved = { id: string; name: string; hex: string; ts: number };
const KEY = "pn532_saved_tags_v1";
export default function Library() {
const toast = useToast();
const [items, setItems] = useState<Saved[]>([]);
const [name, setName] = useState("My tag");
const [hex, setHex] = useState("");
const [labPick, setLabPick] = useState<BinaryCardFixture | null>(null);
useEffect(() => {
try {
const raw = localStorage.getItem(KEY);
setItems(raw ? (JSON.parse(raw) as Saved[]) : []);
} catch {
setItems([]);
}
}, []);
const persist = (next: Saved[]) => {
localStorage.setItem(KEY, JSON.stringify(next));
setItems(next);
};
const add = () => {
if (!hex.trim()) {
toast("Paste hex first", "err");
return;
}
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const s: Saved = { id, name, hex: hex.replace(/\s/g, ""), ts: Date.now() };
persist([s, ...items]);
toast("Saved");
};
const remove = (id: string) => persist(items.filter((x) => x.id !== id));
const seedLabCatalog = () => {
const names = new Set(items.map((x) => x.name));
const ts = Date.now();
const fresh = LIBRARY_LAB_SAMPLES.filter((s) => !names.has(s.name)).map((s, i) => ({
id: `lab-seed-${ts}-${i}`,
name: s.name,
hex: s.hex,
ts,
}));
if (!fresh.length) {
toast("Lab catalog already in library", "info");
return;
}
persist([...fresh, ...items]);
toast(`Seeded ${fresh.length} lab record(s)`);
};
return (
<div className="glass space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Saved tag library</h1>
<p className="text-sm text-slate-400">Local browser storage export by copy/paste.</p>
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={seedLabCatalog}
className="rounded-2xl border border-bubble-mint/30 bg-bubble-mint/10 px-4 py-2 font-mono text-xs font-semibold text-bubble-mint"
>
Seed all lab samples
</button>
</div>
<LabFixtureLoad
mode="binary"
onApplyHex={(h) => setHex(h.replace(/\s/g, ""))}
onPickBinary={setLabPick}
/>
{labPick && (
<div className="rounded-xl border border-bubble-accent/25 bg-black/30 p-3 font-mono text-[10px] text-bubble-accent/90">
<div className="text-bubble-mint/70">Expected SHA-256 (canonical blob, pre-edit)</div>
<div className="break-all">{labPick.sha256Hex}</div>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Label"
className="rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
/>
<button type="button" onClick={add} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
Save current hex
</button>
</div>
<textarea
value={hex}
onChange={(e) => setHex(e.target.value)}
placeholder="Hex dump"
rows={5}
className="w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs"
/>
<ul className="space-y-3">
{items.map((s) => (
<li key={s.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-display font-semibold">{s.name}</div>
<div className="text-xs text-slate-500">{new Date(s.ts).toLocaleString()}</div>
</div>
<button type="button" onClick={() => remove(s.id)} className="text-rose-300">
Remove
</button>
</div>
<pre className="mt-2 max-h-32 overflow-auto text-xs text-bubble-mint/90">{s.hex}</pre>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(s.hex);
toast("Copied");
}}
className="mt-2 text-sm text-slate-300 underline"
>
Copy hex
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useState } from "react";
import { apiGet, apiPost } from "../api";
import LabFixtureLoad from "../LabFixtureLoad";
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
import { useToast } from "../toast";
export default function RawConsole() {
const toast = useToast();
const [frame, setFrame] = useState("4A0100");
const [log, setLog] = useState<string[]>([]);
const [gs, setGs] = useState("");
const push = (s: string) => setLog((x) => [new Date().toLocaleTimeString() + " " + s, ...x].slice(0, 80));
const send = async () => {
try {
const j = await apiPost<{ response?: string; error?: string }>("/api/raw/pn532", { frame });
push(`TX ${frame}`);
push(`RX ${j.response || j.error || "?"}`);
if (j.response) {
toast("Frame OK");
}
} catch (e) {
push(`ERR ${String(e)}`);
toast(String(e), "err");
}
};
const status = async () => {
try {
const j = await apiGet<{ raw?: number[] }>("/api/pn532/general-status");
setGs(JSON.stringify(j.raw ?? j, null, 2));
toast("Fetched PN532 status");
} catch (e) {
toast(String(e), "err");
}
};
return (
<div className="glass space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Advanced / raw PN532</h1>
<p className="text-sm text-slate-400">
Send command bytes (without PN532 frame wrapper). Example: <code>4A0100</code> lists one Type A
passive target at 106 kbps.
</p>
<p className="text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
<LabFixtureLoad mode="raw" onApplyHex={(h) => setFrame(h)} />
<div className="flex flex-wrap gap-2">
<input
value={frame}
onChange={(e) => setFrame(e.target.value)}
className="min-w-[16rem] flex-1 rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono text-sm"
/>
<button type="button" onClick={send} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
Send
</button>
<button type="button" onClick={status} className="rounded-2xl border border-white/15 px-4 py-2">
General status
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<h2 className="text-sm font-semibold text-slate-300">Log</h2>
<pre className="mt-2 max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs">
{log.join("\n")}
</pre>
</div>
<div>
<h2 className="text-sm font-semibold text-slate-300">PN532 status snapshot</h2>
<pre className="mt-2 max-h-80 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-3 text-xs">
{gs || "—"}
</pre>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import { apiPost } from "../api";
import { useToast } from "../toast";
import { parseTrailerAccess, sakLabel } from "../nfcUtils";
export default function ReadAnalyze() {
const toast = useToast();
const [key, setKey] = useState("FFFFFFFFFFFF");
const [keyB, setKeyB] = useState(false);
const [block, setBlock] = useState(0);
const [hex, setHex] = useState("");
const [trailer, setTrailer] = useState("");
const readBlock = async () => {
try {
const j = await apiPost<{ data?: string; error?: string }>("/api/mifare/read-block", {
block,
key,
keyB,
});
if (j.data) {
setHex(j.data);
toast("Read OK");
} else {
toast(j.error || "failed", "err");
}
} catch (e) {
toast(String(e), "err");
}
};
const readUl = async () => {
try {
const j = await apiPost<{ data?: string }>("/api/ul/read-page", { page: block });
if (j.data) {
setHex(j.data + " (UL page)");
toast("UL read OK");
}
} catch (e) {
toast(String(e), "err");
}
};
const bits = trailer ? parseTrailerAccess(trailer) : null;
return (
<div className="space-y-6">
<div className="glass p-6">
<h1 className="font-display text-2xl font-bold">Read / analyze</h1>
<p className="mt-2 text-sm text-slate-400">
Authenticate with a known key, dump a block, paste a sector trailer to decode access bits.
</p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<label className="block space-y-2 text-sm">
Key (12 hex)
<input
value={key}
onChange={(e) => setKey(e.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono text-bubble-mint outline-none ring-bubble-accent/40 focus:ring-2"
/>
</label>
<label className="flex items-end gap-3 text-sm">
<input type="checkbox" checked={keyB} onChange={(e) => setKeyB(e.target.checked)} /> Key B
</label>
<label className="block space-y-2 text-sm">
Block / page
<input
type="number"
value={block}
onChange={(e) => setBlock(Number(e.target.value))}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 outline-none ring-bubble-accent/40 focus:ring-2"
/>
</label>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={readBlock}
className="rounded-2xl bg-bubble-accent/90 px-4 py-2 font-semibold text-white"
>
MIFARE read block
</button>
<button type="button" onClick={readUl} className="rounded-2xl border border-white/15 px-4 py-2">
Ultralight read page
</button>
</div>
{hex && (
<pre className="mt-4 overflow-auto rounded-2xl border border-white/10 bg-black/30 p-4 font-mono text-sm">
{hex}
</pre>
)}
</div>
<div className="glass p-6">
<h2 className="font-display text-lg font-semibold">Sector trailer playground</h2>
<label className="mt-4 block text-sm">
16-byte trailer (32 hex) bytes 68 are access bytes
<textarea
value={trailer}
onChange={(e) => setTrailer(e.target.value)}
rows={3}
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-xs outline-none focus:ring-2 focus:ring-bubble-accent/40"
/>
</label>
{bits && (
<div className="mt-4 rounded-2xl border border-bubble-mint/30 bg-bubble-mint/5 p-4 text-sm">
Parsed C1C3 nibble pattern: {bits.c1} {bits.c2} {bits.c3} (see NXP MIFARE docs for truth
tables)
</div>
)}
<p className="mt-4 text-xs text-slate-500">
SAK hints: common values for labeling only {sakLabel(0x18)} etc.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { useToast } from "../toast";
export default function Settings() {
const toast = useToast();
const [apiBase, setApiBase] = useState("");
const [light, setLight] = useState(false);
useEffect(() => {
setApiBase(localStorage.getItem("apiBase") || "");
setLight(document.documentElement.classList.contains("light"));
}, []);
const save = () => {
localStorage.setItem("apiBase", apiBase);
toast("Saved API base — reload for WS");
};
const toggleTheme = () => {
const next = !light;
setLight(next);
document.documentElement.classList.toggle("light", next);
document.documentElement.classList.toggle("dark", !next);
localStorage.setItem("theme", next ? "light" : "dark");
toast(next ? "Light" : "Dark");
};
return (
<div className="glass max-w-xl space-y-6 p-6">
<h1 className="font-display text-2xl font-bold">Settings</h1>
<label className="block text-sm">
API base (empty = same host)
<input
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
placeholder="http://192.168.4.1"
/>
</label>
<button type="button" onClick={save} className="rounded-2xl bg-bubble-accent px-4 py-2 font-semibold">
Save
</button>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<span className="text-sm">Theme</span>
<button type="button" onClick={toggleTheme} className="rounded-full border border-white/15 px-4 py-1 text-sm">
{light ? "Switch to dark" : "Switch to light"}
</button>
</div>
<p className="text-xs text-slate-500">
Firmware opens a <strong>password-free</strong> SoftAP named <strong>PN532-Toolkit</strong> (lab
default). mDNS: <code>pn532tool.local</code>
</p>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { apiPost } from "../api";
import LabFixtureLoad from "../LabFixtureLoad";
import { LAB_PURPOSE_NOTE } from "../validationFixtures";
import type { BinaryCardFixture } from "../validationFixtures";
import { useToast } from "../toast";
import { useNfcWs } from "../NfcWsContext";
import { uidWithSeparators } from "../nfcUtils";
export default function WriteClone() {
const toast = useToast();
const { lastTag } = useNfcWs();
const [key, setKey] = useState("FFFFFFFFFFFF");
const [keyB, setKeyB] = useState(false);
const [block, setBlock] = useState(4);
const [data, setData] = useState("00000000000000000000000000000000");
const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null);
const [ulPage, setUlPage] = useState(4);
const [ulData, setUlData] = useState("00000000");
const write = async () => {
if (!confirm("Write will modify tag memory. Continue?")) {
return;
}
try {
await apiPost("/api/mifare/write-block", { block, key, keyB, data });
toast("Write OK");
} catch (e) {
toast(String(e), "err");
}
};
const writeUl = async () => {
if (!confirm("Ultralight page write — can brick OTP/lock bytes if misused. Continue?")) {
return;
}
try {
await apiPost("/api/ul/write-page", { page: ulPage, data: ulData.replace(/\s/g, "") });
toast("UL page write OK");
} catch (e) {
toast(String(e), "err");
}
};
return (
<div className="glass space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-bold">Write / clone helpers</h1>
<p className="mt-2 text-sm text-slate-400">
Block editor for MIFARE Classic. Read blocks on the Read tab, adjust hex here, then write with a
key that unlocks the sector.
</p>
{lastTag && (
<p className="mt-2 rounded-lg border border-bubble-mint/20 bg-bubble-mint/5 px-3 py-2 font-mono text-xs text-bubble-mint">
Field: <span className="text-white">{uidWithSeparators(lastTag.uid)}</span> ·{" "}
{lastTag.typeGuess ?? `hint ${lastTag.typeHint}`}
</p>
)}
<p className="mt-2 text-[11px] leading-relaxed text-slate-500">{LAB_PURPOSE_NOTE}</p>
<LabFixtureLoad
mode="binary"
className="mt-3"
onApplyHex={(h) => {
const clean = h.replace(/\s/g, "");
if (clean.length <= 32) {
setData(clean);
} else {
setData(clean.slice(0, 32));
toast("First 16 bytes of lab blob loaded into block editor", "info");
}
}}
onPickBinary={setLabBlob}
/>
{labBlob && (
<p className="mt-2 font-mono text-[10px] text-bubble-accent/90">
Canonical SHA-256 ({labBlob.byteLength} B): {labBlob.sha256Hex} changes after any edit
</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="text-sm">
Key (12 hex)
<input
value={key}
onChange={(e) => setKey(e.target.value)}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
/>
</label>
<label className="flex items-end gap-2 text-sm">
<input type="checkbox" checked={keyB} onChange={(e) => setKeyB(e.target.checked)} /> Key B
</label>
<label className="text-sm">
Block
<input
type="number"
value={block}
onChange={(e) => setBlock(Number(e.target.value))}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
/>
</label>
</div>
<label className="block text-sm">
16 bytes (32 hex)
<textarea
value={data}
onChange={(e) => setData(e.target.value)}
rows={4}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 p-3 font-mono text-sm"
/>
</label>
<button
type="button"
onClick={write}
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-400 px-6 py-3 font-bold text-white shadow-glow"
>
Write block
</button>
<div className="border-t border-white/10 pt-8">
<h2 className="font-display text-lg font-bold text-bubble-accent">Ultralight / NTAG page write</h2>
<p className="mt-2 text-sm text-slate-400">
4 bytes per page (8 hex). Keep tag on the coil. Avoid lock / config pages unless you mean it.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="text-sm">
Page
<input
type="number"
value={ulPage}
onChange={(e) => setUlPage(Number(e.target.value))}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2"
/>
</label>
<label className="text-sm">
Data (8 hex)
<input
value={ulData}
onChange={(e) => setUlData(e.target.value)}
className="mt-1 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-2 font-mono"
/>
</label>
</div>
<button
type="button"
onClick={writeUl}
className="mt-4 rounded-2xl border border-bubble-accent/50 bg-bubble-accent/20 px-6 py-3 font-bold text-bubble-accent"
>
Write UL page
</button>
</div>
</div>
);
}

52
web/src/toast.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
type Toast = { id: number; msg: string; kind: "info" | "err" };
const Ctx = createContext<(msg: string, kind?: Toast["kind"]) => void>(() => {});
export function ToastHost({ children }: { children: React.ReactNode }) {
const [list, setList] = useState<Toast[]>([]);
const push = useCallback((msg: string, kind: Toast["kind"] = "info") => {
const id = Date.now();
setList((x) => [...x, { id, msg, kind }]);
setTimeout(() => setList((x) => x.filter((t) => t.id !== id)), 4200);
}, []);
const v = useMemo(() => push, [push]);
return (
<Ctx.Provider value={v}>
{children}
<div className="pointer-events-none fixed bottom-5 right-5 z-[60] flex max-w-sm flex-col gap-3">
<AnimatePresence>
{list.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, x: 40, rotate: -2, scale: 0.92 }}
animate={{ opacity: 1, x: 0, rotate: 0, scale: 1 }}
exit={{ opacity: 0, x: 20, scale: 0.9 }}
transition={{ type: "spring", stiffness: 380, damping: 26 }}
className={`pointer-events-auto relative overflow-hidden border-2 px-5 py-3.5 font-mono text-xs backdrop-blur-md ${
t.kind === "err"
? "border-bubble-rose bg-bubble-950/95 text-bubble-rose shadow-glowRose"
: "border-bubble-mint bg-bubble-900/95 text-bubble-mint shadow-glow"
} `}
>
<div
className={`pointer-events-none absolute inset-0 opacity-30 ${
t.kind === "err"
? "bg-gradient-to-r from-bubble-rose/20 to-transparent"
: "bg-gradient-to-r from-bubble-accent/20 to-bubble-mint/10"
}`}
/>
<span className="relative mr-2 font-black opacity-80">{t.kind === "err" ? "!!" : "»"}</span>
<span className="relative">{t.msg}</span>
</motion.div>
))}
</AnimatePresence>
</div>
</Ctx.Provider>
);
}
export function useToast() {
return useContext(Ctx);
}

83
web/src/ui/HeroVisual.tsx Normal file
View File

@@ -0,0 +1,83 @@
/** Decorative RF / matrix hero — pure SVG, no external assets */
export default function HeroVisual({ className = "" }: { className?: string }) {
return (
<div className={`relative ${className}`.trim()}>
<svg
viewBox="0 0 400 220"
className="h-auto w-full max-w-md text-bubble-mint/90"
aria-hidden
>
<defs>
<linearGradient id="g1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="100%" stopColor="#00e5ff" stopOpacity="0.35" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Field rings */}
{[0, 1, 2, 3].map((i) => (
<ellipse
key={i}
cx="200"
cy="110"
rx={40 + i * 38}
ry={28 + i * 26}
fill="none"
stroke="currentColor"
strokeWidth="0.6"
strokeOpacity={0.22 - i * 0.04}
className="origin-center animate-[spin_32s_linear_infinite]"
style={{ transformOrigin: "200px 110px", animationDelay: `${i * 2}s` }}
/>
))}
{/* Sweep */}
<g filter="url(#glow)">
<path
d="M200 110 L360 110 A160 80 0 0 1 200 190 Z"
fill="url(#g1)"
fillOpacity="0.12"
className="origin-center animate-[spin_6s_linear_infinite]"
style={{ transformOrigin: "200px 110px" }}
/>
</g>
{/* Coil */}
<circle cx="200" cy="110" r="36" fill="none" stroke="currentColor" strokeWidth="1.2" opacity="0.5" />
<circle cx="200" cy="110" r="22" fill="none" stroke="#00e5ff" strokeWidth="0.8" opacity="0.45" />
<circle cx="200" cy="110" r="8" fill="currentColor" opacity="0.35" />
{/* Matrix ticks */}
{Array.from({ length: 12 }).map((_, i) => {
const a = (i / 12) * Math.PI * 2;
const x = 200 + Math.cos(a) * 118;
const y = 110 + Math.sin(a) * 78;
return (
<rect
key={i}
x={x - 1}
y={y - 1}
width="2"
height="2"
fill="currentColor"
opacity={0.15 + (i % 3) * 0.08}
className="animate-pulse"
style={{ animationDelay: `${i * 0.15}s` }}
/>
);
})}
<text
x="200"
y="205"
textAnchor="middle"
className="fill-bubble-mint/40 font-mono text-[9px] tracking-[0.35em]"
>
13.56MHZ
</text>
</svg>
</div>
);
}

44
web/src/ui/Panel.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { motion } from "framer-motion";
export default function Panel({
title,
badge,
children,
className = "",
}: {
title: string;
badge?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className={`glass panel-edge relative overflow-hidden p-6 ${className}`.trim()}
>
<div className="pointer-events-none absolute -right-20 -top-20 h-40 w-40 rounded-full bg-bubble-accent/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-16 -left-16 h-36 w-36 rounded-full bg-bubble-rose/10 blur-3xl" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-bubble-accent/[0.06] via-transparent to-bubble-mint/[0.07]" />
<div className="relative">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="font-display text-lg font-normal tracking-wide text-bubble-mint text-glow-matrix">
{title}
</h2>
{badge ? (
<motion.span
animate={{ boxShadow: ["0 0 12px rgba(0,229,255,0.3)", "0 0 22px rgba(0,255,157,0.45)", "0 0 12px rgba(0,229,255,0.3)"] }}
transition={{ duration: 2.2, repeat: Infinity }}
className="rounded-full border border-bubble-accent/50 bg-bubble-accent/15 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-widest text-bubble-accent"
>
{badge}
</motion.span>
) : null}
</div>
{children}
</div>
</motion.div>
);
}

58
web/src/useWebSocket.ts Normal file
View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from "react";
import { apiBaseUrl } from "./api";
const wsUrl = () => {
const base = apiBaseUrl();
if (base) {
try {
const u = new URL(base.includes("://") ? base : `http://${base}`);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}/ws`;
} catch {
/* invalid stored base — fall back to current page */
}
}
const loc = window.location;
const proto = loc.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${loc.host}/ws`;
};
export function useToolkitWs(onMessage: (data: string) => void) {
const [ok, setOk] = useState(false);
const cb = useRef(onMessage);
cb.current = onMessage;
useEffect(() => {
let stopped = false;
let attempt = 0;
let timer: ReturnType<typeof setTimeout>;
let ws: WebSocket | null = null;
const connect = () => {
if (stopped) {
return;
}
ws = new WebSocket(wsUrl());
ws.onopen = () => {
attempt = 0;
setOk(true);
ws?.send("ping");
};
ws.onclose = () => {
setOk(false);
attempt += 1;
timer = setTimeout(connect, Math.min(8000, 500 + attempt * 400));
};
ws.onerror = () => ws?.close();
ws.onmessage = (ev) => cb.current(String(ev.data));
};
connect();
return () => {
stopped = true;
clearTimeout(timer);
ws?.close();
};
}, []);
return ok;
}

View File

@@ -0,0 +1,137 @@
/**
* Synthetic / public-domain patterns for lab validation only.
* No real credentials, payment PANs, or live tag dumps.
* Use these to verify hex decode/encode, hashing, and UI flows before mutating bytes for your own tests.
*/
export const LAB_PURPOSE_NOTE =
"Lab-only synthetic data and well-known defaults. Edit bytes freely to validate parsers, checksums, and emulate/raw paths — never treat these as real tags.";
/** PN532 command bytes only (no transport wrapper). */
export type RawPn532Lab = { id: string; title: string; hex: string; detail: string };
export const RAW_PN532_LAB: RawPn532Lab[] = [
{
id: "get-fw",
title: "GetFirmwareVersion (0x02)",
hex: "02",
detail: "Expect PN532 firmware major/minor in response payload.",
},
{
id: "get-status",
title: "GetGeneralStatus (0x04)",
hex: "04",
detail: "Chip + RF status snapshot; useful sanity check after a stuck session.",
},
{
id: "in-list-a",
title: "InListPassiveTarget — 106 kbps, 1 target",
hex: "4A0100",
detail: "Poll one ISO14443-A target at 106 kbps (maxTg=1, BrTy=0).",
},
];
/** Emulate path: full payload after host framing (what /api/nfc/emulate-raw sends). */
export type EmulateLab = { id: string; title: string; hex: string; detail: string };
export const EMULATE_LAB: EmulateLab[] = [
{
id: "tg-stub",
title: "TgInitAsTarget — opcode only (shell)",
hex: "8C",
detail: "Minimal placeholder; real target mode needs a full parameter block per NXP UM0701. Extend with NFCID, FeliCa params, etc.",
},
{
id: "tg-type-a-sketch",
title: "TgInitAsTarget — Type A sketch (synthetic NFCID3T)",
hex: "8C000012DEADBEEF00000000000000000000000000000000000000000000000000",
detail:
"Synthetic 0x8C prefix + padded fake ID — not guaranteed to satisfy any reader; use as a byte-editing template only.",
},
];
/** Contiguous hex blobs: mutate one nibble and confirm SHA-256 changes in your tooling. */
export type BinaryCardFixture = {
id: string;
title: string;
hex: string;
byteLength: number;
/** SHA-256 of raw bytes from `hex` (pre-edit canonical blob). */
sha256Hex: string;
detail: string;
};
/** MIFARE Classic sector 0 (4×16 B): synthetic UID block + empty data + default trailer. */
const MIFARE_SECTOR0_LAB =
"DEADBEEF220804000000000000000000" +
"00000000000000000000000000000000" +
"00000000000000000000000000000000" +
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF";
/** NTAG Type 2 — 7 pages × 4 B (UID / internal / lock placeholders). */
const NTAG_PAGES_0_6_LAB = "04112233445566172233445566172233445566172233445566172233";
/** NDEF TLV on Type 2: URI https://example.com/ (well-known RTD). */
const NDEF_TLV_URI_LAB = "0310D1010C55046578616D706C652E636F6DFE";
export const BINARY_CARD_FIXTURES: BinaryCardFixture[] = [
{
id: "mifare-block0",
title: "MIFARE Classic — block 0 (16 B, synthetic UID+BCC)",
hex: "DEADBEEF220804000000000000000000",
byteLength: 16,
sha256Hex: "6f13f064d70ec33b7ace5842011d66aff3852baa4ab7c0a85aa66d113c7189fe",
detail:
"Fake 4-byte UID (DE:AD:BE:EF wire order as stored), BCC = XOR(UID), rest filler. Pair with key FFFFFFFFFFFF on a writable tag only.",
},
{
id: "mifare-sector0",
title: "MIFARE Classic — sector 0 dump (64 B)",
hex: MIFARE_SECTOR0_LAB,
byteLength: 64,
sha256Hex: "11095df4db11e4271f40234533db45de35c4ca69f04e231e3fa47f38181e0168",
detail:
"Four blocks: manufacturer/UID, empty, empty, default trailer (Key A/B FFFFFFFFFFFF, access FF078069). Edit block 12 payload and re-hash to validate your pipeline.",
},
{
id: "ntag-pages",
title: "NTAG-style — pages 06 (28 B)",
hex: NTAG_PAGES_0_6_LAB,
byteLength: 28,
sha256Hex: "8ffd95e646b2deb218dabd72e35c7a1938725e07332d8ed7df4f2a2656e590a0",
detail: "Synthetic UID/internal/lock placeholders — not copied from a physical tag.",
},
{
id: "ndef-uri-tlv",
title: "NDEF TLV — URI https://example.com/",
hex: NDEF_TLV_URI_LAB,
byteLength: 19,
sha256Hex: "81197b6cd6e40a3f62f56f0c0f3f449c63a956919d04c5b5317895ad4328fa14",
detail: "Type 2 TLV (0x03) + short NDEF Well-Known URI record; append to user memory after static pages in real layouts.",
},
];
/** Extra well-known MIFARE Classic keys (public lists / defaults). Lab corpus only. */
export const LAB_DICTIONARY_KEYS: string[] = [
"FFFFFFFFFFFF",
"A0A1A2A3A4A5",
"D3F7D3F7D3F7",
"000000000000",
"B0B1B2B3B4B5",
"AABBCCDDEEFF",
"4D3A99C351DD",
"1A982C7E459A",
"714C5C886E97",
"587EE5F9350F",
"A0478CC39091",
"26940B21FFF5",
"E4410EF8ED2D",
];
export type LibraryLabSample = { name: string; hex: string; note: string };
export const LIBRARY_LAB_SAMPLES: LibraryLabSample[] = BINARY_CARD_FIXTURES.map((f) => ({
name: `LAB // ${f.title}`,
hex: f.hex,
note: f.detail,
}));