Initial commit: Veritas NFC Tool (Smartcard & NFC Reader GUI Utility)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4814
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "nfc_tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pcsc = "2.8"
|
||||
eframe = "0.28"
|
||||
egui = "0.28"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
rfd = "0.14"
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use pcsc::{Card, Context, Protocols, Scope, ShareMode, MAX_BUFFER_SIZE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardInfo {
|
||||
pub uid: String,
|
||||
pub uid_bytes: Vec<u8>,
|
||||
pub ats: Option<String>,
|
||||
pub card_type: String,
|
||||
pub sak: u8,
|
||||
pub atqa: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LedBuzzerConfig {
|
||||
pub red: bool,
|
||||
pub green: bool,
|
||||
pub blink_red: bool,
|
||||
pub blink_green: bool,
|
||||
pub blink_count: u8,
|
||||
pub t1_duration_ms: u8,
|
||||
pub t2_duration_ms: u8,
|
||||
pub buzzer_mode: u8,
|
||||
}
|
||||
|
||||
pub struct Acr122u;
|
||||
|
||||
pub const IOCTL_CCID_ESCAPE: u32 = 0x00313520;
|
||||
|
||||
impl Acr122u {
|
||||
pub fn create_context() -> Result<Context> {
|
||||
let ctx = Context::establish(Scope::User)?;
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
pub fn list_readers(ctx: &Context) -> Result<Vec<String>> {
|
||||
let mut readers_buf = [0u8; 2048];
|
||||
let readers = ctx.list_readers(&mut readers_buf)?;
|
||||
|
||||
let mut names = Vec::new();
|
||||
for name in readers {
|
||||
if let Ok(s) = name.to_str() {
|
||||
names.push(s.to_string());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub fn find_acr122u(ctx: &Context) -> Result<String> {
|
||||
let readers = Self::list_readers(ctx)?;
|
||||
if readers.is_empty() {
|
||||
return Err(anyhow!("Kein Smartcard-Lesegerät gefunden!"));
|
||||
}
|
||||
|
||||
for r in &readers {
|
||||
let lower = r.to_lowercase();
|
||||
if lower.contains("acr122") || lower.contains("acs") || lower.contains("picc") {
|
||||
return Ok(r.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(readers[0].clone())
|
||||
}
|
||||
|
||||
/// Connect to card for reading operations (Shared mode)
|
||||
pub fn connect_card_with_ctx(ctx: &Context, reader: &str) -> Result<Card> {
|
||||
let c_reader = std::ffi::CString::new(reader)?;
|
||||
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Shared, Protocols::RAW) {
|
||||
return Ok(card);
|
||||
}
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Shared, Protocols::ANY) {
|
||||
return Ok(card);
|
||||
}
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Exclusive, Protocols::RAW) {
|
||||
return Ok(card);
|
||||
}
|
||||
|
||||
Err(anyhow!("Karte konnte nicht verbunden werden (Kein passendes Protokoll)"))
|
||||
}
|
||||
|
||||
/// Connect to card for writing operations (Exclusive mode prevents Windows RF reset)
|
||||
pub fn connect_card_for_write(ctx: &Context, reader: &str) -> Result<Card> {
|
||||
let c_reader = std::ffi::CString::new(reader)?;
|
||||
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Exclusive, Protocols::RAW) {
|
||||
return Ok(card);
|
||||
}
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Exclusive, Protocols::ANY) {
|
||||
return Ok(card);
|
||||
}
|
||||
if let Ok(card) = ctx.connect(&c_reader, ShareMode::Shared, Protocols::RAW) {
|
||||
return Ok(card);
|
||||
}
|
||||
let card = ctx.connect(&c_reader, ShareMode::Shared, Protocols::ANY)?;
|
||||
Ok(card)
|
||||
}
|
||||
|
||||
pub fn connect_direct_with_ctx(ctx: &Context, reader: &str) -> Result<Card> {
|
||||
let c_reader = std::ffi::CString::new(reader)?;
|
||||
let card = ctx.connect(&c_reader, ShareMode::Direct, Protocols::UNDEFINED)?;
|
||||
Ok(card)
|
||||
}
|
||||
|
||||
pub fn transmit(card: &Card, command: &[u8]) -> Result<(Vec<u8>, u8, u8)> {
|
||||
let mut receive_buf = [0u8; MAX_BUFFER_SIZE];
|
||||
let resp = card.transmit(command, &mut receive_buf)?;
|
||||
|
||||
if resp.len() < 2 {
|
||||
return Err(anyhow!("Antwort zu kurz: {:?}", resp));
|
||||
}
|
||||
|
||||
let sw1 = resp[resp.len() - 2];
|
||||
let sw2 = resp[resp.len() - 1];
|
||||
let data = resp[..resp.len() - 2].to_vec();
|
||||
|
||||
Ok((data, sw1, sw2))
|
||||
}
|
||||
|
||||
pub fn control_escape(card: &Card, command: &[u8]) -> Result<(Vec<u8>, u8, u8)> {
|
||||
let mut receive_buf = [0u8; MAX_BUFFER_SIZE];
|
||||
let resp = card.control(IOCTL_CCID_ESCAPE, command, &mut receive_buf)?;
|
||||
|
||||
if resp.is_empty() {
|
||||
return Err(anyhow!("Keine Antwort auf Escape Command"));
|
||||
}
|
||||
|
||||
if resp.len() >= 2 {
|
||||
let sw1 = resp[resp.len() - 2];
|
||||
let sw2 = resp[resp.len() - 1];
|
||||
let data = resp[..resp.len() - 2].to_vec();
|
||||
Ok((data, sw1, sw2))
|
||||
} else {
|
||||
Ok((resp.to_vec(), 0x90, 0x00))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_led_buzzer(ctx: &Context, reader: &str, config: &LedBuzzerConfig) -> Result<()> {
|
||||
let mut p2: u8 = 0;
|
||||
if config.red { p2 |= 0x01; }
|
||||
if config.green { p2 |= 0x02; }
|
||||
p2 |= 0x04 | 0x08;
|
||||
|
||||
if config.blink_red { p2 |= 0x10 | 0x40; }
|
||||
if config.blink_green { p2 |= 0x20 | 0x80; }
|
||||
|
||||
let apdu = [
|
||||
0xFF, 0x00, 0x40, p2, 0x04,
|
||||
config.t1_duration_ms / 100,
|
||||
config.t2_duration_ms / 100,
|
||||
config.blink_count,
|
||||
config.buzzer_mode,
|
||||
];
|
||||
|
||||
if let Ok(card) = Self::connect_card_with_ctx(ctx, reader) {
|
||||
let _ = Self::transmit(&card, &apdu);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(card) = Self::connect_direct_with_ctx(ctx, reader) {
|
||||
let _ = Self::control_escape(&card, &apdu);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn beep(ctx: &Context, reader: &str, duration_ms: u8, count: u8) -> Result<()> {
|
||||
let config = LedBuzzerConfig {
|
||||
red: false,
|
||||
green: true,
|
||||
blink_red: false,
|
||||
blink_green: true,
|
||||
blink_count: count,
|
||||
t1_duration_ms: duration_ms,
|
||||
t2_duration_ms: duration_ms,
|
||||
buzzer_mode: 1,
|
||||
};
|
||||
Self::set_led_buzzer(ctx, reader, &config)
|
||||
}
|
||||
|
||||
pub fn get_card_uid(card: &Card) -> Result<Vec<u8>> {
|
||||
let apdu = [0xFF, 0xCA, 0x00, 0x00, 0x00];
|
||||
match Self::transmit(card, &apdu) {
|
||||
Ok((data, sw1, sw2)) => {
|
||||
if (sw1 == 0x90 || sw1 == 0x62 || sw1 == 0x63) && !data.is_empty() {
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(anyhow!("UID Abfrage fehlgeschlagen (SW: {:02X}{:02X})", sw1, sw2))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow!("Transmit Fehler beim UID Lesen: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_card_ats(card: &Card) -> Result<Vec<u8>> {
|
||||
let apdu = [0xFF, 0xCA, 0x01, 0x00, 0x00];
|
||||
let (data, sw1, sw2) = Self::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 {
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(anyhow!("ATS nicht verfügbar"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_card_info(ctx: &Context, reader: &str) -> Result<CardInfo> {
|
||||
let card = Self::connect_card_with_ctx(ctx, reader)?;
|
||||
let uid_bytes = Self::get_card_uid(&card)?;
|
||||
let uid_hex = hex::encode_upper(&uid_bytes);
|
||||
|
||||
let ats_bytes = Self::get_card_ats(&card).ok();
|
||||
let ats_hex = ats_bytes.as_ref().map(|b| hex::encode_upper(b));
|
||||
|
||||
let card_type = match uid_bytes.len() {
|
||||
4 => "MIFARE Classic 1K / 4K oder NTAG (4-Byte UID)",
|
||||
7 => "NTAG213 / NTAG215 / NTAG216 / Ultralight (7-Byte UID)",
|
||||
10 => "NFC Tag Type 2 / ISO14443 (10-Byte UID)",
|
||||
_ => "ISO 14443 NFC Karte",
|
||||
}.to_string();
|
||||
|
||||
Ok(CardInfo {
|
||||
uid: uid_hex,
|
||||
uid_bytes,
|
||||
ats: ats_hex,
|
||||
card_type,
|
||||
sak: 0x00,
|
||||
atqa: "0000".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
use anyhow::Result;
|
||||
use tao::{
|
||||
event::{Event, WindowEvent},
|
||||
event_loop::{ControlFlow, EventLoop},
|
||||
window::WindowBuilder,
|
||||
};
|
||||
use wry::WebViewBuilder;
|
||||
|
||||
pub fn launch_gui(url: &str) -> Result<()> {
|
||||
let event_loop = EventLoop::new();
|
||||
let window = WindowBuilder::new()
|
||||
.with_title("ACR122U NFC Studio Pro")
|
||||
.with_inner_size(tao::dpi::LogicalSize::new(1280.0, 850.0))
|
||||
.with_min_inner_size(tao::dpi::LogicalSize::new(900.0, 600.0))
|
||||
.build(&event_loop)?;
|
||||
|
||||
let _webview = WebViewBuilder::new(&window)
|
||||
.with_url(url)
|
||||
.build()?;
|
||||
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Wait;
|
||||
|
||||
if let Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
});
|
||||
}
|
||||
+692
@@ -0,0 +1,692 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod acr122u;
|
||||
mod mifare;
|
||||
mod ndef;
|
||||
mod ntag;
|
||||
|
||||
use acr122u::{Acr122u, CardInfo, LedBuzzerConfig};
|
||||
use eframe::egui;
|
||||
use mifare::{MifareDump, MifareHandler};
|
||||
use ndef::NdefCodec;
|
||||
use ntag::NtagHandler;
|
||||
use pcsc::Context;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub enum CardEvent {
|
||||
ReaderStatus { connected: bool, name: String },
|
||||
CardDetected(CardInfo),
|
||||
CardRemoved,
|
||||
}
|
||||
|
||||
pub struct NfcApp {
|
||||
rx: Receiver<CardEvent>,
|
||||
reader_name: String,
|
||||
is_reader_connected: bool,
|
||||
current_card: Option<CardInfo>,
|
||||
event_logs: Vec<String>,
|
||||
active_tab: Tab,
|
||||
|
||||
// LED / Beep state
|
||||
led_red: bool,
|
||||
led_green: bool,
|
||||
led_blink: bool,
|
||||
led_beep: bool,
|
||||
|
||||
// MIFARE state
|
||||
mifare_dump: Option<MifareDump>,
|
||||
mifare_status: String,
|
||||
|
||||
// NDEF & Text State
|
||||
ndef_subtab: NdefSubTab,
|
||||
ndef_url_input: String,
|
||||
ndef_text_input: String,
|
||||
ndef_text_lang: String,
|
||||
wifi_ssid: String,
|
||||
wifi_pass: String,
|
||||
vcard_name: String,
|
||||
vcard_phone: String,
|
||||
vcard_email: String,
|
||||
vcard_company: String,
|
||||
geo_url: String,
|
||||
aar_package: String,
|
||||
ndef_status: String,
|
||||
|
||||
// NTAG Password State
|
||||
pwd_input: String,
|
||||
pack_input: String,
|
||||
pwd_status: String,
|
||||
|
||||
// APDU State
|
||||
apdu_input: String,
|
||||
apdu_response_log: Vec<String>,
|
||||
|
||||
pcsc_ctx: Arc<Mutex<Context>>,
|
||||
is_busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Tab {
|
||||
Scanner,
|
||||
Ndef,
|
||||
Mifare,
|
||||
Password,
|
||||
Hardware,
|
||||
ExpertApdu,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum NdefSubTab {
|
||||
Text,
|
||||
Url,
|
||||
Wifi,
|
||||
VCard,
|
||||
Geo,
|
||||
AppLauncher,
|
||||
}
|
||||
|
||||
impl NfcApp {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
setup_edel_theme(&cc.egui_ctx);
|
||||
|
||||
let (tx, rx) = channel();
|
||||
let ctx = Acr122u::create_context().expect("PC/SC Context konnte nicht erstellt werden!");
|
||||
let pcsc_ctx = Arc::new(Mutex::new(ctx));
|
||||
let is_busy = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let poll_ctx = pcsc_ctx.clone();
|
||||
let poll_busy = is_busy.clone();
|
||||
thread::spawn(move || {
|
||||
poll_loop(tx, poll_ctx, poll_busy);
|
||||
});
|
||||
|
||||
Self {
|
||||
rx,
|
||||
reader_name: "Kein Lesegerät angeschlossen".to_string(),
|
||||
is_reader_connected: false,
|
||||
current_card: None,
|
||||
event_logs: vec!["System bereit. Bitte lege einen NFC-Tag auf das Gerät...".to_string()],
|
||||
active_tab: Tab::Scanner,
|
||||
|
||||
led_red: false,
|
||||
led_green: true,
|
||||
led_blink: false,
|
||||
led_beep: true,
|
||||
|
||||
mifare_dump: None,
|
||||
mifare_status: "Klicke auf 'Karte Auslesen', um alle Speichersektoren zu scannen.".to_string(),
|
||||
|
||||
ndef_subtab: NdefSubTab::Text,
|
||||
ndef_url_input: "https://example.com".to_string(),
|
||||
ndef_text_input: "Hallo! Das ist meine persönliche Notiz auf dem NFC-Tag.".to_string(),
|
||||
ndef_text_lang: "de".to_string(),
|
||||
wifi_ssid: "MeinWLAN".to_string(),
|
||||
wifi_pass: "MeinPasswort123".to_string(),
|
||||
vcard_name: "Max Mustermann".to_string(),
|
||||
vcard_phone: "+49 170 1234567".to_string(),
|
||||
vcard_email: "max@beispiel.de".to_string(),
|
||||
vcard_company: "Meine Firma GmbH".to_string(),
|
||||
geo_url: "https://maps.google.com/?q=Berlin".to_string(),
|
||||
aar_package: "com.spotify.music".to_string(),
|
||||
ndef_status: "Bereit zum Beschreiben".to_string(),
|
||||
|
||||
pwd_input: "1234".to_string(),
|
||||
pack_input: "0000".to_string(),
|
||||
pwd_status: "Gib ein 4-stelliges Passwort ein, um den Tag zu schützen.".to_string(),
|
||||
|
||||
apdu_input: "FF 00 48 00 00".to_string(),
|
||||
apdu_response_log: Vec::new(),
|
||||
|
||||
pcsc_ctx,
|
||||
is_busy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_edel_theme(ctx: &egui::Context) {
|
||||
let mut style = (*ctx.style()).clone();
|
||||
let mut visuals = egui::Visuals::dark();
|
||||
|
||||
visuals.panel_fill = egui::Color32::from_rgb(10, 13, 20);
|
||||
visuals.window_fill = egui::Color32::from_rgb(19, 25, 39);
|
||||
visuals.extreme_bg_color = egui::Color32::from_rgb(6, 8, 14);
|
||||
|
||||
visuals.widgets.noninteractive.rounding = egui::Rounding::same(10.0);
|
||||
visuals.widgets.noninteractive.bg_fill = egui::Color32::from_rgb(26, 34, 50);
|
||||
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(45, 58, 82));
|
||||
|
||||
visuals.widgets.inactive.rounding = egui::Rounding::same(10.0);
|
||||
visuals.widgets.inactive.bg_fill = egui::Color32::from_rgb(26, 34, 50);
|
||||
|
||||
visuals.widgets.hovered.rounding = egui::Rounding::same(10.0);
|
||||
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(45, 58, 82);
|
||||
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(0, 242, 254));
|
||||
|
||||
visuals.widgets.active.rounding = egui::Rounding::same(10.0);
|
||||
visuals.widgets.active.bg_fill = egui::Color32::from_rgb(0, 242, 254);
|
||||
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0_f32, egui::Color32::BLACK);
|
||||
|
||||
visuals.selection.bg_fill = egui::Color32::from_rgba_premultiplied(0, 242, 254, 70);
|
||||
|
||||
style.visuals = visuals;
|
||||
style.spacing.item_spacing = egui::vec2(12.0, 12.0);
|
||||
style.spacing.button_padding = egui::vec2(16.0, 10.0);
|
||||
ctx.set_style(style);
|
||||
}
|
||||
|
||||
fn poll_loop(tx: Sender<CardEvent>, ctx_store: Arc<Mutex<Context>>, is_busy: Arc<AtomicBool>) {
|
||||
let mut last_uid = String::new();
|
||||
|
||||
loop {
|
||||
thread::sleep(Duration::from_millis(600));
|
||||
|
||||
if is_busy.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ctx = ctx_store.lock().unwrap();
|
||||
|
||||
let reader = match Acr122u::find_acr122u(&ctx) {
|
||||
Ok(r) => {
|
||||
let _ = tx.send(CardEvent::ReaderStatus {
|
||||
connected: true,
|
||||
name: r.clone(),
|
||||
});
|
||||
r
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(CardEvent::ReaderStatus {
|
||||
connected: false,
|
||||
name: "Kein Lesegerät".to_string(),
|
||||
});
|
||||
last_uid.clear();
|
||||
let _ = tx.send(CardEvent::CardRemoved);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match Acr122u::read_card_info(&ctx, &reader) {
|
||||
Ok(info) => {
|
||||
if info.uid != last_uid {
|
||||
last_uid = info.uid.clone();
|
||||
let _ = Acr122u::beep(&ctx, &reader, 50, 1);
|
||||
let _ = tx.send(CardEvent::CardDetected(info));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if !last_uid.is_empty() {
|
||||
last_uid.clear();
|
||||
let _ = tx.send(CardEvent::CardRemoved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for NfcApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
while let Ok(evt) = self.rx.try_recv() {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
|
||||
match evt {
|
||||
CardEvent::ReaderStatus { connected, name } => {
|
||||
self.is_reader_connected = connected;
|
||||
self.reader_name = name;
|
||||
}
|
||||
CardEvent::CardDetected(info) => {
|
||||
let friendly_type = match info.uid_bytes.len() {
|
||||
7 => "Standard NFC-Sticker / Tag (NTAG)",
|
||||
4 => "MIFARE Chipkarte / Schlüsselanhänger",
|
||||
_ => "Kontaktloser NFC-Chip",
|
||||
};
|
||||
self.event_logs.insert(0, format!("[{}] 💳 NFC-Tag erkannt: ID {} ({})", timestamp, info.uid, friendly_type));
|
||||
self.current_card = Some(info);
|
||||
}
|
||||
CardEvent::CardRemoved => {
|
||||
if self.current_card.is_some() {
|
||||
self.event_logs.insert(0, format!("[{}] 🚫 NFC-Tag entfernt", timestamp));
|
||||
self.current_card = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.request_repaint_after(Duration::from_millis(200));
|
||||
|
||||
egui::TopBottomPanel::top("top_header").show(ctx, |ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("💎 ACR122U NFC Studio Pro").font(egui::FontId::proportional(22.0)).strong().color(egui::Color32::from_rgb(0, 242, 254)));
|
||||
ui.label(egui::RichText::new("Premium Edition").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if self.is_reader_connected {
|
||||
ui.colored_label(egui::Color32::from_rgb(0, 230, 153), format!("🟢 {}", self.reader_name));
|
||||
} else {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 75, 87), "🔴 Gerät nicht verbunden");
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.active_tab, Tab::Scanner, "📡 Live-Erkennung");
|
||||
ui.selectable_value(&mut self.active_tab, Tab::Ndef, "✏️ NFC-Tag Beschreiben");
|
||||
ui.selectable_value(&mut self.active_tab, Tab::Mifare, "🔍 Speicher & Klonen");
|
||||
ui.selectable_value(&mut self.active_tab, Tab::Password, "🔒 Passwort-Schutz");
|
||||
ui.selectable_value(&mut self.active_tab, Tab::Hardware, "🔔 LEDs & Töne");
|
||||
ui.selectable_value(&mut self.active_tab, Tab::ExpertApdu, "🛠️ Experten-Befehle");
|
||||
});
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
match self.active_tab {
|
||||
Tab::Scanner => self.render_scanner_tab(ui),
|
||||
Tab::Ndef => self.render_ndef_tab(ui),
|
||||
Tab::Mifare => self.render_mifare_tab(ui),
|
||||
Tab::Password => self.render_password_tab(ui),
|
||||
Tab::Hardware => self.render_hardware_tab(ui),
|
||||
Tab::ExpertApdu => self.render_apdu_tab(ui),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl NfcApp {
|
||||
fn render_scanner_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.columns(2, |cols| {
|
||||
cols[0].group(|ui| {
|
||||
ui.heading("💳 Aufgelegter NFC-Tag");
|
||||
ui.label(egui::RichText::new("Hier siehst du sofort alle Infos zur erkannten Karte.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::Frame::none()
|
||||
.fill(egui::Color32::from_rgb(19, 25, 39))
|
||||
.stroke(egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(0, 242, 254)))
|
||||
.rounding(16.0)
|
||||
.inner_margin(20.0)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("📇 NFC-CHIP").font(egui::FontId::proportional(14.0)).strong().color(egui::Color32::from_rgb(246, 211, 101)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new("Kontaktlos").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(0, 242, 254)));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.label(egui::RichText::new("EINDEUTIGE CHIP-ID (UID):").font(egui::FontId::proportional(11.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
|
||||
if let Some(card) = &self.current_card {
|
||||
ui.label(egui::RichText::new(&card.uid).font(egui::FontId::monospace(26.0)).strong().color(egui::Color32::from_rgb(246, 211, 101)));
|
||||
ui.add_space(12.0);
|
||||
let friendly_type = match card.uid_bytes.len() {
|
||||
7 => "Standard NFC-Sticker / Tag (NTAG213/215/216)",
|
||||
4 => "MIFARE Classic Chipkarte / Schlüsselanhänger",
|
||||
_ => "Kontaktloser NFC-Chip",
|
||||
};
|
||||
ui.label(format!("Chip-Typ: {}", friendly_type));
|
||||
} else {
|
||||
ui.label(egui::RichText::new("-- -- -- --").font(egui::FontId::monospace(26.0)).color(egui::Color32::GRAY));
|
||||
ui.add_space(12.0);
|
||||
ui.colored_label(egui::Color32::GRAY, "Bitte halte einen NFC-Tag auf das Gerät...");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cols[1].group(|ui| {
|
||||
ui.heading("📜 Echtzeit Aktivitäten-Log");
|
||||
ui.label(egui::RichText::new("Verlauf der Erfassungen und Aktionen.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(350.0).show(ui, |ui| {
|
||||
for log in &self.event_logs {
|
||||
ui.label(egui::RichText::new(log).font(egui::FontId::monospace(13.0)));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_ndef_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("✏️ NFC-Tag Beschreiben");
|
||||
ui.label(egui::RichText::new("Wähle aus, was beim Dranhalten eines Smartphones passieren soll:").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::Text, "📝 Text & Notiz");
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::Url, "🌐 Internet-Link (URL)");
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::Wifi, "📶 WLAN Quick-Connect");
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::VCard, "🎴 Visitenkarte");
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::Geo, "🗺️ Standort / Maps");
|
||||
ui.selectable_value(&mut self.ndef_subtab, NdefSubTab::AppLauncher, "📱 App Öffnen");
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
let mut tlv_payload: Option<Vec<u8>> = None;
|
||||
|
||||
match self.ndef_subtab {
|
||||
NdefSubTab::Text => {
|
||||
ui.group(|ui| {
|
||||
ui.label("Deine Nachricht / Notiz:");
|
||||
ui.add(egui::TextEdit::multiline(&mut self.ndef_text_input).desired_rows(4).desired_width(f32::INFINITY));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("💾 Text als Datei speichern (.txt)").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new().add_filter("Textdatei", &["txt"]).save_file() {
|
||||
let _ = fs::write(path, &self.ndef_text_input);
|
||||
self.ndef_status = "Text erfolgreich auf dem PC gespeichert!".to_string();
|
||||
}
|
||||
}
|
||||
if ui.button("📂 Text aus Datei laden (.txt)").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new().add_filter("Textdatei", &["txt"]).pick_file() {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
self.ndef_text_input = content;
|
||||
self.ndef_status = "Text aus Datei geladen!".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tlv_payload = Some(NdefCodec::encode_text(&self.ndef_text_input, &self.ndef_text_lang));
|
||||
});
|
||||
}
|
||||
NdefSubTab::Url => {
|
||||
ui.group(|ui| {
|
||||
ui.label("Internet-Adresse (öffnet sich beim Scannen automatisch):");
|
||||
ui.text_edit_singleline(&mut self.ndef_url_input);
|
||||
tlv_payload = Some(NdefCodec::encode_url(&self.ndef_url_input));
|
||||
});
|
||||
}
|
||||
NdefSubTab::Wifi => {
|
||||
ui.group(|ui| {
|
||||
ui.label("WLAN Netz-Name (SSID):");
|
||||
ui.text_edit_singleline(&mut self.wifi_ssid);
|
||||
ui.label("WLAN Passwort:");
|
||||
ui.text_edit_singleline(&mut self.wifi_pass);
|
||||
ui.label(egui::RichText::new("💡 Smartphones verbinden sich beim Dranhalten direkt mit dem WLAN.").font(egui::FontId::proportional(11.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
tlv_payload = Some(NdefCodec::encode_wifi(&self.wifi_ssid, &self.wifi_pass));
|
||||
});
|
||||
}
|
||||
NdefSubTab::VCard => {
|
||||
ui.group(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(&mut self.vcard_name);
|
||||
ui.label("Telefonnummer:");
|
||||
ui.text_edit_singleline(&mut self.vcard_phone);
|
||||
ui.label("E-Mail:");
|
||||
ui.text_edit_singleline(&mut self.vcard_email);
|
||||
ui.label("Firma / Organisation:");
|
||||
ui.text_edit_singleline(&mut self.vcard_company);
|
||||
ui.label(egui::RichText::new("💡 Beim Scannen wird der Kontakt direkt in das Handy-Telefonbuch gespeichert.").font(egui::FontId::proportional(11.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
tlv_payload = Some(NdefCodec::encode_vcard(&self.vcard_name, &self.vcard_phone, &self.vcard_email, &self.vcard_company));
|
||||
});
|
||||
}
|
||||
NdefSubTab::Geo => {
|
||||
ui.group(|ui| {
|
||||
ui.label("Google Maps Standorts-Link:");
|
||||
ui.text_edit_singleline(&mut self.geo_url);
|
||||
tlv_payload = Some(NdefCodec::encode_url(&self.geo_url));
|
||||
});
|
||||
}
|
||||
NdefSubTab::AppLauncher => {
|
||||
ui.group(|ui| {
|
||||
ui.label("Android App-Name (z. B. com.spotify.music):");
|
||||
ui.text_edit_singleline(&mut self.aar_package);
|
||||
tlv_payload = Some(NdefCodec::encode_aar(&self.aar_package));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(15.0);
|
||||
|
||||
if ui.button("⚡ Auf NFC-Tag Schreiben").clicked() {
|
||||
if let Some(tlv) = tlv_payload {
|
||||
self.is_busy.store(true, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
if let Ok(mut card) = Acr122u::connect_card_with_ctx(&pcsc, &reader) {
|
||||
let _ = NtagHandler::ensure_cc_header(&pcsc, &reader, &mut card);
|
||||
let mut page = 4;
|
||||
let mut err = false;
|
||||
|
||||
for chunk in tlv.chunks(4) {
|
||||
let mut p_data = [0u8; 4];
|
||||
p_data[..chunk.len()].copy_from_slice(chunk);
|
||||
if NtagHandler::write_page(&pcsc, &reader, &mut card, page, &p_data).is_err() {
|
||||
err = true;
|
||||
self.ndef_status = format!("Schreibfehler auf Seite {}", page);
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if !err {
|
||||
let _ = Acr122u::beep(&pcsc, &reader, 100, 2);
|
||||
self.ndef_status = "✅ Inhalt erfolgreich auf den NFC-Tag geschrieben!".to_string();
|
||||
}
|
||||
} else {
|
||||
self.ndef_status = "❌ Karte konnte nicht verbunden werden!".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
self.is_busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.colored_label(egui::Color32::from_rgb(0, 242, 254), &self.ndef_status);
|
||||
}
|
||||
|
||||
fn render_mifare_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("🔍 Speicher Auslesen & Klonen");
|
||||
ui.label(egui::RichText::new("Lies den kompletten Speicherinhalt von MIFARE-Karten aus und speichere ihn ab.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("🔄 Karte Auslesen").clicked() {
|
||||
self.is_busy.store(true, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
match MifareHandler::dump_card(&pcsc, &reader) {
|
||||
Ok(dump) => {
|
||||
self.mifare_dump = Some(dump);
|
||||
self.mifare_status = "Auslesen erfolgreich!".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
self.mifare_status = format!("Fehler beim Auslesen: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.is_busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if let Some(dump) = &self.mifare_dump {
|
||||
if ui.button("💾 Als Backup speichern (.json)").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new().add_filter("JSON Backup", &["json"]).save_file() {
|
||||
if let Ok(json) = serde_json::to_string_pretty(dump) {
|
||||
let _ = fs::write(path, json);
|
||||
self.mifare_status = "Backup-Datei gespeichert!".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("📂 Backup aus Datei laden").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new().add_filter("JSON Backup", &["json"]).pick_file() {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(loaded_dump) = serde_json::from_str::<MifareDump>(&content) {
|
||||
self.mifare_dump = Some(loaded_dump);
|
||||
self.mifare_status = "Backup geladen!".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.colored_label(egui::Color32::from_rgb(0, 242, 254), &self.mifare_status);
|
||||
ui.add_space(10.0);
|
||||
|
||||
if let Some(dump) = &self.mifare_dump {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for sec in &dump.sectors {
|
||||
ui.group(|ui| {
|
||||
ui.label(egui::RichText::new(format!("Speicherblock {} (Key: {})", sec.sector_number, sec.key_a.as_deref().unwrap_or("Schlüsselgeschützt"))).strong());
|
||||
for b in &sec.blocks {
|
||||
let text_color = if b.is_trailer { egui::Color32::from_rgb(214, 188, 250) } else { egui::Color32::WHITE };
|
||||
ui.label(egui::RichText::new(format!("Block {:02}: {} | {}", b.block_number, b.hex_data, b.ascii_data)).font(egui::FontId::monospace(12.0)).color(text_color));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_password_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("🔒 Passwort-Schutz & Tag Versiegeln");
|
||||
ui.label(egui::RichText::new("Schütze deinen NFC-Tag vor unbefugtem Überschreiben.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.group(|ui| {
|
||||
ui.label("4-stelliges Passwort (z. B. 1234):");
|
||||
ui.text_edit_singleline(&mut self.pwd_input);
|
||||
|
||||
ui.add_space(10.0);
|
||||
if ui.button("🔐 Passwort auf Tag aktivieren").clicked() {
|
||||
self.is_busy.store(true, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
if let Ok(mut card) = Acr122u::connect_card_with_ctx(&pcsc, &reader) {
|
||||
let pwd_bytes = self.pwd_input.as_bytes();
|
||||
if pwd_bytes.len() == 4 {
|
||||
let mut p_arr = [0u8; 4];
|
||||
p_arr.copy_from_slice(pwd_bytes);
|
||||
if NtagHandler::write_page(&pcsc, &reader, &mut card, 134, &p_arr).is_ok() {
|
||||
self.pwd_status = "✅ Passwort erfolgreich auf dem Tag aktiviert!".to_string();
|
||||
let _ = Acr122u::beep(&pcsc, &reader, 100, 2);
|
||||
} else {
|
||||
self.pwd_status = "❌ Fehler beim Aktivieren des Passworts!".to_string();
|
||||
}
|
||||
} else {
|
||||
self.pwd_status = "Das Passwort muss genau 4 Zeichen lang sein!".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.is_busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(15.0);
|
||||
ui.colored_label(egui::Color32::from_rgb(0, 242, 254), &self.pwd_status);
|
||||
}
|
||||
|
||||
fn render_hardware_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("🔔 LEDs & Signal-Töne des Lesegeräts");
|
||||
ui.label(egui::RichText::new("Steuere die Hardware-Signale deines ACR122U Geräts.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.checkbox(&mut self.led_red, "Rote LED aktivieren");
|
||||
ui.checkbox(&mut self.led_green, "Grüne LED aktivieren");
|
||||
ui.checkbox(&mut self.led_blink, "Blinken aktivieren");
|
||||
ui.checkbox(&mut self.led_beep, "Ton beim Schalten abspielen");
|
||||
|
||||
ui.add_space(12.0);
|
||||
if ui.button("⚡ LED & Ton-Einstellungen Anwenden").clicked() {
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
let config = LedBuzzerConfig {
|
||||
red: self.led_red,
|
||||
green: self.led_green,
|
||||
blink_red: self.led_blink,
|
||||
blink_green: self.led_blink,
|
||||
blink_count: if self.led_blink { 4 } else { 0 },
|
||||
t1_duration_ms: 100,
|
||||
t2_duration_ms: 100,
|
||||
buzzer_mode: if self.led_beep { 1 } else { 0 },
|
||||
};
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
let _ = Acr122u::set_led_buzzer(&pcsc, &reader, &config);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(20.0);
|
||||
ui.label("Signalton testen:");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("🔊 1x Signalton").clicked() {
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
let _ = Acr122u::beep(&pcsc, &reader, 100, 1);
|
||||
}
|
||||
}
|
||||
if ui.button("🔊 2x Bestätigungston").clicked() {
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
let _ = Acr122u::beep(&pcsc, &reader, 100, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_apdu_tab(&mut self, ui: &mut egui::Ui) {
|
||||
ui.heading("🛠️ Experten-Befehle (Rohdaten / APDU)");
|
||||
ui.label(egui::RichText::new("Manuelle Befehlseingabe für fortgeschrittene Nutzer.").font(egui::FontId::proportional(12.0)).color(egui::Color32::from_rgb(148, 163, 184)));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.label("Befehl in Hexadezimal (z. B. FF 00 48 00 00):");
|
||||
ui.text_edit_singleline(&mut self.apdu_input);
|
||||
|
||||
if ui.button("Befehl Senden").clicked() {
|
||||
let pcsc = self.pcsc_ctx.lock().unwrap();
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&pcsc) {
|
||||
if let Ok(card) = Acr122u::connect_card_with_ctx(&pcsc, &reader).or_else(|_| Acr122u::connect_direct_with_ctx(&pcsc, &reader)) {
|
||||
if let Ok(bytes) = hex::decode(self.apdu_input.replace(" ", "")) {
|
||||
match Acr122u::transmit(&card, &bytes) {
|
||||
Ok((data, sw1, sw2)) => {
|
||||
let entry = format!("> {}\n< DATEN: {}\n< STATUS: {:02X}{:02X}", self.apdu_input, hex::encode_upper(&data), sw1, sw2);
|
||||
self.apdu_response_log.insert(0, entry);
|
||||
}
|
||||
Err(e) => {
|
||||
self.apdu_response_log.insert(0, format!("❌ FEHLER: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(15.0);
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for log in &self.apdu_response_log {
|
||||
ui.label(egui::RichText::new(log).font(egui::FontId::monospace(13.0)));
|
||||
ui.separator();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("ACR122U NFC Studio Pro (Transparent Reconnect)")
|
||||
.with_inner_size([1150.0, 780.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"nfc_tool",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(NfcApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
use crate::acr122u::Acr122u;
|
||||
use anyhow::{anyhow, Result};
|
||||
use pcsc::{Card, Context};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_KEYS: &[&[u8; 6]] = &[
|
||||
&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], // Default Factory Key
|
||||
&[0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5], // MAD Key
|
||||
&[0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7], // NFC Forum Key
|
||||
&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // Blank Key
|
||||
&[0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5],
|
||||
&[0x4D, 0x3A, 0x99, 0xC3, 0x51, 0xDD],
|
||||
&[0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F],
|
||||
&[0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC],
|
||||
&[0xA0, 0xB1, 0xC2, 0xD3, 0xE4, 0xF5],
|
||||
&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF],
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MifareBlock {
|
||||
pub block_number: u8,
|
||||
pub hex_data: String,
|
||||
pub ascii_data: String,
|
||||
pub is_trailer: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MifareSector {
|
||||
pub sector_number: u8,
|
||||
pub key_a: Option<String>,
|
||||
pub key_b: Option<String>,
|
||||
pub blocks: Vec<MifareBlock>,
|
||||
pub is_authenticated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MifareDump {
|
||||
pub uid: String,
|
||||
pub sectors_count: u8,
|
||||
pub sectors: Vec<MifareSector>,
|
||||
}
|
||||
|
||||
pub struct MifareHandler;
|
||||
|
||||
impl MifareHandler {
|
||||
pub fn load_key(card: &Card, key_slot: u8, key: &[u8; 6]) -> Result<()> {
|
||||
let mut apdu = vec![0xFF, 0x82, 0x00, key_slot, 0x06];
|
||||
apdu.extend_from_slice(key);
|
||||
let (_, sw1, sw2) = Acr122u::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Fehler beim Laden des Keys (SW: {:02X}{:02X})", sw1, sw2))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticate_block(card: &Card, block: u8, key_type: u8, key_slot: u8) -> Result<()> {
|
||||
let apdu = [0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, block, key_type, key_slot];
|
||||
let (_, sw1, sw2) = Acr122u::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Authentifizierung fehlgeschlagen für Block {} (SW: {:02X}{:02X})", block, sw1, sw2))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_with_key(card: &Card, block: u8, key_type: u8, key: &[u8; 6]) -> Result<()> {
|
||||
Self::load_key(card, 0, key)?;
|
||||
Self::authenticate_block(card, block, key_type, 0)
|
||||
}
|
||||
|
||||
pub fn read_block(card: &Card, block: u8) -> Result<Vec<u8>> {
|
||||
let apdu = [0xFF, 0xB0, 0x00, block, 0x10];
|
||||
let (data, sw1, sw2) = Acr122u::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 && data.len() == 16 {
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(anyhow!("Lesen von Block {} fehlgeschlagen (SW: {:02X}{:02X})", block, sw1, sw2))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_block(card: &Card, block: u8, data: &[u8; 16]) -> Result<()> {
|
||||
let mut apdu = vec![0xFF, 0xD6, 0x00, block, 0x10];
|
||||
apdu.extend_from_slice(data);
|
||||
let (_, sw1, sw2) = Acr122u::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Schreiben in Block {} fehlgeschlagen (SW: {:02X}{:02X})", block, sw1, sw2))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_sector(card: &Card, sector: u8, custom_keys: Option<&[[u8; 6]]>) -> Result<MifareSector> {
|
||||
let first_block = sector * 4;
|
||||
let mut keys_to_test = DEFAULT_KEYS.to_vec();
|
||||
|
||||
let mut extra_keys = Vec::new();
|
||||
if let Some(user_keys) = custom_keys {
|
||||
for k in user_keys {
|
||||
extra_keys.push(k);
|
||||
}
|
||||
keys_to_test.extend(extra_keys);
|
||||
}
|
||||
|
||||
let mut found_key_a: Option<[u8; 6]> = None;
|
||||
let mut found_key_b: Option<[u8; 6]> = None;
|
||||
|
||||
for &k in &keys_to_test {
|
||||
if Self::auth_with_key(card, first_block, 0x60, k).is_ok() {
|
||||
found_key_a = Some(*k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for &k in &keys_to_test {
|
||||
if Self::auth_with_key(card, first_block, 0x61, k).is_ok() {
|
||||
found_key_b = Some(*k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let is_auth = found_key_a.is_some() || found_key_b.is_some();
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
if is_auth {
|
||||
let (key_type, active_key) = if let Some(ka) = found_key_a {
|
||||
(0x60, ka)
|
||||
} else {
|
||||
(0x61, found_key_b.unwrap())
|
||||
};
|
||||
|
||||
for b in 0..4 {
|
||||
let block_num = first_block + b;
|
||||
let _ = Self::auth_with_key(card, block_num, key_type, &active_key);
|
||||
let block_data = match Self::read_block(card, block_num) {
|
||||
Ok(d) => d,
|
||||
Err(_) => vec![0u8; 16],
|
||||
};
|
||||
|
||||
let hex_str = hex::encode_upper(&block_data);
|
||||
let ascii_str: String = block_data.iter().map(|&c| if c >= 32 && c <= 126 { c as char } else { '.' }).collect();
|
||||
|
||||
blocks.push(MifareBlock {
|
||||
block_number: block_num,
|
||||
hex_data: hex_str,
|
||||
ascii_data: ascii_str,
|
||||
is_trailer: b == 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MifareSector {
|
||||
sector_number: sector,
|
||||
key_a: found_key_a.map(|k| hex::encode_upper(k)),
|
||||
key_b: found_key_b.map(|k| hex::encode_upper(k)),
|
||||
blocks,
|
||||
is_authenticated: is_auth,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dump_card(ctx: &Context, reader: &str) -> Result<MifareDump> {
|
||||
let card = Acr122u::connect_card_with_ctx(ctx, reader)?;
|
||||
let uid_bytes = Acr122u::get_card_uid(&card)?;
|
||||
let uid_hex = hex::encode_upper(&uid_bytes);
|
||||
|
||||
let mut sectors = Vec::new();
|
||||
for sec in 0..16 {
|
||||
let sector_info = Self::scan_sector(&card, sec, None)?;
|
||||
sectors.push(sector_info);
|
||||
}
|
||||
|
||||
Ok(MifareDump {
|
||||
uid: uid_hex,
|
||||
sectors_count: 16,
|
||||
sectors,
|
||||
})
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum NdefPayload {
|
||||
Url(String),
|
||||
Text { text: String, lang: String },
|
||||
Wifi { ssid: String, password: String, auth: String },
|
||||
Contact { name: String, phone: String, email: String, company: String },
|
||||
Geo { lat: f64, lon: f64, label: String },
|
||||
AppLauncher { package_name: String },
|
||||
Custom { mime: String, payload: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NdefRecord {
|
||||
pub tnf: u8,
|
||||
pub record_type: String,
|
||||
pub payload_hex: String,
|
||||
pub payload_parsed: NdefPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NdefMessage {
|
||||
pub records: Vec<NdefRecord>,
|
||||
}
|
||||
|
||||
pub struct NdefCodec;
|
||||
|
||||
impl NdefCodec {
|
||||
/// Encode URL into NDEF TLV bytes
|
||||
pub fn encode_url(url: &str) -> Vec<u8> {
|
||||
let (prefix_code, clean_url) = if url.starts_with("https://www.") {
|
||||
(0x02u8, &url[12..])
|
||||
} else if url.starts_with("http://www.") {
|
||||
(0x01u8, &url[11..])
|
||||
} else if url.starts_with("https://") {
|
||||
(0x04u8, &url[8..])
|
||||
} else if url.starts_with("http://") {
|
||||
(0x03u8, &url[7..])
|
||||
} else if url.starts_with("tel:") {
|
||||
(0x05u8, &url[4..])
|
||||
} else if url.starts_with("mailto:") {
|
||||
(0x06u8, &url[7..])
|
||||
} else if url.starts_with("geo:") {
|
||||
(0x00u8, url)
|
||||
} else {
|
||||
(0x00u8, url)
|
||||
};
|
||||
|
||||
let mut payload = vec![prefix_code];
|
||||
payload.extend_from_slice(clean_url.as_bytes());
|
||||
|
||||
let type_bytes = b"U";
|
||||
let payload_len = payload.len() as u8;
|
||||
|
||||
let mut record = vec![0xD1, 0x01, payload_len, type_bytes[0]];
|
||||
record.extend(payload);
|
||||
|
||||
let mut tlv = vec![0x03, record.len() as u8];
|
||||
tlv.extend(record);
|
||||
tlv.push(0xFE);
|
||||
|
||||
tlv
|
||||
}
|
||||
|
||||
/// Encode Text into NDEF TLV bytes
|
||||
pub fn encode_text(text: &str, lang: &str) -> Vec<u8> {
|
||||
let lang_bytes = lang.as_bytes();
|
||||
let status_byte = (lang_bytes.len() & 0x3F) as u8;
|
||||
|
||||
let mut payload = vec![status_byte];
|
||||
payload.extend_from_slice(lang_bytes);
|
||||
payload.extend_from_slice(text.as_bytes());
|
||||
|
||||
let type_bytes = b"T";
|
||||
let payload_len = payload.len() as u8;
|
||||
|
||||
let mut record = vec![0xD1, 0x01, payload_len, type_bytes[0]];
|
||||
record.extend(payload);
|
||||
|
||||
let mut tlv = vec![0x03, record.len() as u8];
|
||||
tlv.extend(record);
|
||||
tlv.push(0xFE);
|
||||
|
||||
tlv
|
||||
}
|
||||
|
||||
/// Encode Wi-Fi Configuration into NDEF MIME payload (application/vnd.wfa.wsc)
|
||||
pub fn encode_wifi(ssid: &str, password: &str) -> Vec<u8> {
|
||||
let mime_type = b"application/vnd.wfa.wsc";
|
||||
|
||||
// Build WSC (Wi-Fi Simple Configuration) TLV structure
|
||||
let mut wsc = Vec::new();
|
||||
|
||||
// Network Index (0x1026)
|
||||
wsc.extend_from_slice(&[0x10, 0x26, 0x00, 0x01, 0x01]);
|
||||
|
||||
// SSID (0x1045)
|
||||
let ssid_bytes = ssid.as_bytes();
|
||||
wsc.extend_from_slice(&[0x10, 0x45]);
|
||||
wsc.extend_from_slice(&(ssid_bytes.len() as u16).to_be_bytes());
|
||||
wsc.extend_from_slice(ssid_bytes);
|
||||
|
||||
// Authentication Type (0x1003): 0x0020 = WPA2-Personal
|
||||
wsc.extend_from_slice(&[0x10, 0x03, 0x00, 0x02, 0x00, 0x20]);
|
||||
|
||||
// Encryption Type (0x100F): 0x0008 = AES / CCMP
|
||||
wsc.extend_from_slice(&[0x10, 0x0F, 0x00, 0x02, 0x00, 0x08]);
|
||||
|
||||
// Network Key / Password (0x1027)
|
||||
let pass_bytes = password.as_bytes();
|
||||
wsc.extend_from_slice(&[0x10, 0x27]);
|
||||
wsc.extend_from_slice(&(pass_bytes.len() as u16).to_be_bytes());
|
||||
wsc.extend_from_slice(pass_bytes);
|
||||
|
||||
// MAC Address (0x1020) broadcast
|
||||
wsc.extend_from_slice(&[0x10, 0x20, 0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
|
||||
// Wrap in NDEF MIME Record (TNF = 0x02 MIME Media Type)
|
||||
// Header: MB=1, ME=1, CF=0, SR=1, IL=0, TNF=0x02 => 0xD2
|
||||
let mut record = vec![0xD2, mime_type.len() as u8, wsc.len() as u8];
|
||||
record.extend_from_slice(mime_type);
|
||||
record.extend(wsc);
|
||||
|
||||
let mut tlv = vec![0x03, record.len() as u8];
|
||||
tlv.extend(record);
|
||||
tlv.push(0xFE);
|
||||
|
||||
tlv
|
||||
}
|
||||
|
||||
/// Encode vCard (Contact Card) into NDEF MIME payload (text/vcard)
|
||||
pub fn encode_vcard(name: &str, phone: &str, email: &str, company: &str) -> Vec<u8> {
|
||||
let mime_type = b"text/vcard";
|
||||
let vcard_str = format!(
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:{}\r\nTEL;TYPE=CELL:{}\r\nEMAIL:{}\r\nORG:{}\r\nEND:VCARD",
|
||||
name, phone, email, company
|
||||
);
|
||||
let payload_bytes = vcard_str.as_bytes();
|
||||
|
||||
let mut record = vec![0xD2, mime_type.len() as u8, payload_bytes.len() as u8];
|
||||
record.extend_from_slice(mime_type);
|
||||
record.extend_from_slice(payload_bytes);
|
||||
|
||||
let mut tlv = vec![0x03, record.len() as u8];
|
||||
tlv.extend(record);
|
||||
tlv.push(0xFE);
|
||||
|
||||
tlv
|
||||
}
|
||||
|
||||
/// Encode Android Application Record (AAR) to launch specific Android App
|
||||
pub fn encode_aar(package_name: &str) -> Vec<u8> {
|
||||
let type_bytes = b"android.com:pkg";
|
||||
let payload_bytes = package_name.as_bytes();
|
||||
|
||||
// TNF = 0x04 (NFC Forum External Type)
|
||||
let mut record = vec![0xD4, type_bytes.len() as u8, payload_bytes.len() as u8];
|
||||
record.extend_from_slice(type_bytes);
|
||||
record.extend_from_slice(payload_bytes);
|
||||
|
||||
let mut tlv = vec![0x03, record.len() as u8];
|
||||
tlv.extend(record);
|
||||
tlv.push(0xFE);
|
||||
|
||||
tlv
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use crate::acr122u::Acr122u;
|
||||
use anyhow::{anyhow, Result};
|
||||
use pcsc::{Card, Context, MAX_BUFFER_SIZE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NtagPage {
|
||||
pub page_number: u8,
|
||||
pub hex_data: String,
|
||||
pub ascii_data: String,
|
||||
}
|
||||
|
||||
pub struct NtagHandler;
|
||||
|
||||
impl NtagHandler {
|
||||
pub fn read_pages(card: &Card, start_page: u8) -> Result<Vec<u8>> {
|
||||
let apdu = [0xFF, 0xB0, 0x00, start_page, 0x10];
|
||||
let (data, sw1, sw2) = Acr122u::transmit(card, &apdu)?;
|
||||
if sw1 == 0x90 && sw2 == 0x00 && data.len() >= 4 {
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(anyhow!("Lesen ab Seite {} fehlgeschlagen (SW: {:02X}{:02X})", start_page, sw1, sw2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Write 4 bytes to page with EEPROM write cycle delay & transparent reset auto-reconnect
|
||||
pub fn write_page(ctx: &Context, reader: &str, card: &mut Card, page: u8, data: &[u8; 4]) -> Result<()> {
|
||||
let mut apdu = vec![0xFF, 0xD6, 0x00, page, 0x04];
|
||||
apdu.extend_from_slice(data);
|
||||
|
||||
for _attempt in 0..5 {
|
||||
let mut receive_buf = [0u8; MAX_BUFFER_SIZE];
|
||||
match card.transmit(&apdu, &mut receive_buf) {
|
||||
Ok(resp) if resp.len() >= 2 => {
|
||||
let sw1 = resp[resp.len() - 2];
|
||||
let sw2 = resp[resp.len() - 1];
|
||||
if sw1 == 0x90 && sw2 == 0x00 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Transparent auto-reconnect if card was reset by driver
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
if let Ok(new_card) = Acr122u::connect_card_with_ctx(ctx, reader) {
|
||||
*card = new_card;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Schreiben auf Seite {} fehlgeschlagen", page))
|
||||
}
|
||||
|
||||
pub fn ensure_cc_header(ctx: &Context, reader: &str, card: &mut Card) -> Result<()> {
|
||||
if let Ok(pages) = Self::read_pages(card, 0) {
|
||||
if pages.len() >= 16 {
|
||||
let p3 = &pages[12..16];
|
||||
if p3[0] != 0xE1 {
|
||||
let default_cc = [0xE1, 0x10, 0x3E, 0x00];
|
||||
let _ = Self::write_page(ctx, reader, card, 3, &default_cc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
use crate::acr122u::{Acr122u, CardInfo, LedBuzzerConfig};
|
||||
use crate::mifare::MifareHandler;
|
||||
use crate::ndef::NdefCodec;
|
||||
use crate::ntag::NtagHandler;
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::{Html, IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use pcsc::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, Mutex as AsyncMutex};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub tx: broadcast::Sender<String>,
|
||||
pub last_card: Arc<Mutex<Option<CardInfo>>>,
|
||||
pub reader_lock: Arc<AsyncMutex<()>>,
|
||||
pub pcsc_ctx: Arc<Context>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LedRequest {
|
||||
pub red: bool,
|
||||
pub green: bool,
|
||||
pub blink: bool,
|
||||
pub beep: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WriteBlockRequest {
|
||||
pub block: u8,
|
||||
pub hex_data: String,
|
||||
pub key_a: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WriteNdefUrlRequest {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WriteNdefTextRequest {
|
||||
pub text: String,
|
||||
pub lang: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApduRequest {
|
||||
pub apdu_hex: String,
|
||||
}
|
||||
|
||||
pub async fn start_server(port: u16) -> Result<()> {
|
||||
let ctx = Acr122u::create_context()?;
|
||||
let pcsc_ctx = Arc::new(ctx);
|
||||
|
||||
let (tx, _) = broadcast::channel::<String>(100);
|
||||
let state = AppState {
|
||||
tx: tx.clone(),
|
||||
last_card: Arc::new(Mutex::new(None)),
|
||||
reader_lock: Arc::new(AsyncMutex::new(())),
|
||||
pcsc_ctx: pcsc_ctx.clone(),
|
||||
};
|
||||
|
||||
// Background card polling thread
|
||||
let tx_poll = tx.clone();
|
||||
let card_state_store = state.last_card.clone();
|
||||
let lock_poll = state.reader_lock.clone();
|
||||
let poll_ctx = pcsc_ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
poll_cards_loop(tx_poll, card_state_store, lock_poll, poll_ctx).await;
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/api/info", get(get_info_handler))
|
||||
.route("/api/led", post(set_led_handler))
|
||||
.route("/api/mifare/dump", post(mifare_dump_handler))
|
||||
.route("/api/mifare/write", post(mifare_write_handler))
|
||||
.route("/api/ntag/dump", post(ntag_dump_handler))
|
||||
.route("/api/ndef/write-url", post(write_ndef_url_handler))
|
||||
.route("/api/ndef/write-text", post(write_ndef_text_handler))
|
||||
.route("/api/apdu", post(apdu_transceive_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
info!("🚀 NFC Web Server gestartet unter http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_cards_loop(
|
||||
tx: broadcast::Sender<String>,
|
||||
card_store: Arc<Mutex<Option<CardInfo>>>,
|
||||
reader_lock: Arc<AsyncMutex<()>>,
|
||||
ctx: Arc<Context>,
|
||||
) {
|
||||
let mut last_uid = String::new();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
|
||||
let _guard = reader_lock.lock().await;
|
||||
|
||||
let reader = match Acr122u::find_acr122u(&ctx) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let msg = serde_json::json!({
|
||||
"event": "reader_status",
|
||||
"connected": false,
|
||||
"reader": "Kein Lesegerät"
|
||||
}).to_string();
|
||||
let _ = tx.send(msg);
|
||||
last_uid.clear();
|
||||
let mut guard = card_store.lock().unwrap();
|
||||
*guard = None;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match Acr122u::read_card_info(&ctx, &reader) {
|
||||
Ok(info) => {
|
||||
if info.uid != last_uid {
|
||||
last_uid = info.uid.clone();
|
||||
{
|
||||
let mut guard = card_store.lock().unwrap();
|
||||
*guard = Some(info.clone());
|
||||
}
|
||||
|
||||
let _ = Acr122u::beep(&ctx, &reader, 50, 1);
|
||||
|
||||
let msg = serde_json::json!({
|
||||
"event": "card_detected",
|
||||
"connected": true,
|
||||
"reader": reader,
|
||||
"uid": info.uid,
|
||||
"card_type": info.card_type,
|
||||
"ats": info.ats,
|
||||
}).to_string();
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if !last_uid.is_empty() {
|
||||
last_uid.clear();
|
||||
{
|
||||
let mut guard = card_store.lock().unwrap();
|
||||
*guard = None;
|
||||
}
|
||||
let msg = serde_json::json!({
|
||||
"event": "card_removed",
|
||||
"connected": true,
|
||||
"reader": reader
|
||||
}).to_string();
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn index_handler() -> Html<&'static str> {
|
||||
Html(include_str!("../static/index.html"))
|
||||
}
|
||||
|
||||
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let (mut sender, _) = socket.split();
|
||||
let mut rx = state.tx.subscribe();
|
||||
|
||||
if let Ok(reader) = Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
let fw = Acr122u::get_firmware(&state.pcsc_ctx, &reader).unwrap_or_else(|_| "Unknown".to_string());
|
||||
let init_msg = serde_json::json!({
|
||||
"event": "init",
|
||||
"connected": true,
|
||||
"reader": reader,
|
||||
"firmware": fw
|
||||
}).to_string();
|
||||
let _ = sender.send(Message::Text(init_msg)).await;
|
||||
}
|
||||
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_info_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(reader) => {
|
||||
let fw = Acr122u::get_firmware(&state.pcsc_ctx, &reader).unwrap_or_else(|e| e.to_string());
|
||||
let card_info = Acr122u::read_card_info(&state.pcsc_ctx, &reader).ok();
|
||||
Json(serde_json::json!({
|
||||
"connected": true,
|
||||
"reader": reader,
|
||||
"firmware": fw,
|
||||
"card": card_info
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"connected": false,
|
||||
"error": e.to_string()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_led_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LedRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let config = LedBuzzerConfig {
|
||||
red: req.red,
|
||||
green: req.green,
|
||||
blink_red: req.blink,
|
||||
blink_green: req.blink,
|
||||
blink_count: if req.blink { 4 } else { 0 },
|
||||
t1_duration_ms: 100,
|
||||
t2_duration_ms: 100,
|
||||
buzzer_mode: if req.beep { 1 } else { 0 },
|
||||
};
|
||||
|
||||
match Acr122u::set_led_buzzer(&state.pcsc_ctx, &reader, &config) {
|
||||
Ok(_) => Json(serde_json::json!({"success": true})),
|
||||
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mifare_dump_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
match MifareHandler::dump_card(&state.pcsc_ctx, &reader) {
|
||||
Ok(dump) => Json(serde_json::json!({"success": true, "dump": dump})),
|
||||
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mifare_write_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WriteBlockRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let bytes = match hex::decode(&req.hex_data) {
|
||||
Ok(b) if b.len() == 16 => b,
|
||||
_ => return Json(serde_json::json!({"success": false, "error": "Daten müssen genau 16 Hex-Bytes (32 Zeichen) lang sein!"})),
|
||||
};
|
||||
|
||||
let card = match Acr122u::connect_card_with_ctx(&state.pcsc_ctx, &reader) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let key_bytes = req.key_a.as_ref()
|
||||
.and_then(|k| hex::decode(k).ok())
|
||||
.and_then(|b| if b.len() == 6 { Some([b[0], b[1], b[2], b[3], b[4], b[5]]) } else { None })
|
||||
.unwrap_or([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
|
||||
if let Err(e) = MifareHandler::auth_with_key(&card, req.block, 0x60, &key_bytes) {
|
||||
return Json(serde_json::json!({"success": false, "error": format!("Authentifizierung fehlgeschlagen: {}", e)}));
|
||||
}
|
||||
|
||||
let mut data_arr = [0u8; 16];
|
||||
data_arr.copy_from_slice(&bytes);
|
||||
|
||||
match MifareHandler::write_block(&card, req.block, &data_arr) {
|
||||
Ok(_) => {
|
||||
let _ = Acr122u::beep(&state.pcsc_ctx, &reader, 100, 2);
|
||||
Json(serde_json::json!({"success": true}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ntag_dump_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
match NtagHandler::dump_tag(&state.pcsc_ctx, &reader, 135) {
|
||||
Ok(dump) => Json(serde_json::json!({"success": true, "dump": dump})),
|
||||
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_ndef_url_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WriteNdefUrlRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let tlv = NdefCodec::encode_url(&req.url);
|
||||
|
||||
let card = match Acr122u::connect_card_with_ctx(&state.pcsc_ctx, &reader) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
// Ensure Capability Container (CC) on Page 3 is set
|
||||
let _ = NtagHandler::ensure_cc_header(&card);
|
||||
|
||||
let mut page = 4;
|
||||
let mut chunks = tlv.chunks(4);
|
||||
|
||||
while let Some(chunk) = chunks.next() {
|
||||
let mut page_data = [0u8; 4];
|
||||
for (i, &b) in chunk.iter().enumerate() {
|
||||
page_data[i] = b;
|
||||
}
|
||||
|
||||
if let Err(e) = NtagHandler::write_page(&card, page, &page_data) {
|
||||
return Json(serde_json::json!({"success": false, "error": format!("Schreibfehler auf Seite {}: {}", page, e)}));
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
let _ = Acr122u::beep(&state.pcsc_ctx, &reader, 100, 2);
|
||||
Json(serde_json::json!({"success": true, "written_bytes": tlv.len()}))
|
||||
}
|
||||
|
||||
async fn write_ndef_text_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WriteNdefTextRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let tlv = NdefCodec::encode_text(&req.text, &req.lang);
|
||||
|
||||
let card = match Acr122u::connect_card_with_ctx(&state.pcsc_ctx, &reader) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let _ = NtagHandler::ensure_cc_header(&card);
|
||||
|
||||
let mut page = 4;
|
||||
let mut chunks = tlv.chunks(4);
|
||||
|
||||
while let Some(chunk) = chunks.next() {
|
||||
let mut page_data = [0u8; 4];
|
||||
for (i, &b) in chunk.iter().enumerate() {
|
||||
page_data[i] = b;
|
||||
}
|
||||
|
||||
if let Err(e) = NtagHandler::write_page(&card, page, &page_data) {
|
||||
return Json(serde_json::json!({"success": false, "error": format!("Schreibfehler auf Seite {}: {}", page, e)}));
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
let _ = Acr122u::beep(&state.pcsc_ctx, &reader, 100, 2);
|
||||
Json(serde_json::json!({"success": true, "written_bytes": tlv.len()}))
|
||||
}
|
||||
|
||||
async fn apdu_transceive_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ApduRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let _guard = state.reader_lock.lock().await;
|
||||
let reader = match Acr122u::find_acr122u(&state.pcsc_ctx) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
};
|
||||
|
||||
let apdu_bytes = match hex::decode(req.apdu_hex.replace(" ", "")) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Json(serde_json::json!({"success": false, "error": "Ungültiger Hex-String!"})),
|
||||
};
|
||||
|
||||
let card = match Acr122u::connect_card_with_ctx(&state.pcsc_ctx, &reader) {
|
||||
Ok(c) => c,
|
||||
Err(_) => match Acr122u::connect_direct_with_ctx(&state.pcsc_ctx, &reader) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
},
|
||||
};
|
||||
|
||||
match Acr122u::transmit(&card, &apdu_bytes) {
|
||||
Ok((data, sw1, sw2)) => {
|
||||
let status = match (sw1, sw2) {
|
||||
(0x90, 0x00) => "90 00 (Erfolg / Command OK)".to_string(),
|
||||
(0x63, 0x00) => "63 00 (Operation fehlgeschlagen)".to_string(),
|
||||
(0x6A, 0x81) => "6A 81 (Funktion nicht unterstützt)".to_string(),
|
||||
(0x6A, 0x82) => "6A 82 (Datei / Block nicht gefunden)".to_string(),
|
||||
(0x69, 0x82) => "69 82 (Zugriff verweigert / Auth erforderlich)".to_string(),
|
||||
_ => format!("{:02X} {:02X} (Status Code)", sw1, sw2),
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"response": {
|
||||
"data_hex": hex::encode_upper(&data),
|
||||
"sw1": format!("{:02X}", sw1),
|
||||
"sw2": format!("{:02X}", sw2),
|
||||
"status": status
|
||||
}
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ACR122U NFC Studio Pro</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;600&family=Inter:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-card: rgba(18, 22, 31, 0.75);
|
||||
--bg-card-hover: rgba(28, 35, 49, 0.85);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent-cyan: #00f2fe;
|
||||
--accent-blue: #4facfe;
|
||||
--accent-gold: #f6d365;
|
||||
--accent-emerald: #00b894;
|
||||
--accent-pink: #ff0844;
|
||||
--text-main: #f0f4f8;
|
||||
--text-muted: #8a99ad;
|
||||
--font-main: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(0, 242, 254, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 100%, rgba(79, 172, 254, 0.08) 0px, transparent 50%);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(10, 12, 16, 0.8);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
color: #000;
|
||||
box-shadow: 0 0 15px rgba(0, 242, 254, 0.4);
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, #fff, var(--text-muted));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4757;
|
||||
box-shadow: 0 0 8px #ff4757;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background-color: var(--accent-emerald);
|
||||
box-shadow: 0 0 10px var(--accent-emerald);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 32px 0 32px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: 10px 10px 0 0;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
color: var(--text-main);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
color: var(--accent-cyan);
|
||||
border-bottom: 2px solid var(--accent-cyan);
|
||||
background: rgba(0, 242, 254, 0.05);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Grid Layouts */
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* NFC Visual Card */
|
||||
.nfc-hologram-card {
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nfc-hologram-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(0, 242, 254, 0.1) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
width: 44px;
|
||||
height: 34px;
|
||||
background: linear-gradient(135deg, #f6d365, #fda085);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.card-uid-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 0 10px rgba(0, 242, 254, 0.5);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-main);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.3);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.5);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-main);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* MIFARE Grid */
|
||||
.sector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sector-card {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 700;
|
||||
color: var(--accent-gold);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.block-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.block-line.trailer {
|
||||
color: #d6bcfa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* APDU Console */
|
||||
.apdu-console {
|
||||
background: #05070a;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-family: var(--font-mono);
|
||||
min-height: 180px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.apdu-log {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.apdu-in { color: var(--accent-cyan); }
|
||||
.apdu-out { color: var(--accent-emerald); }
|
||||
.apdu-err { color: var(--accent-pink); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon">NFC</div>
|
||||
<div>
|
||||
<div class="logo-title">ACR122U Studio Pro</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">PC/SC NFC Reader & Writer Software</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-badge">
|
||||
<div id="statusDot" class="status-dot"></div>
|
||||
<span id="statusText">Verbindung wird aufgebaut...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<button class="nav-btn active" onclick="switchTab('scanTab', this)">📡 Live Scan</button>
|
||||
<button class="nav-btn" onclick="switchTab('hardwareTab', this)">💡 Reader Control (LED/Beep)</button>
|
||||
<button class="nav-btn" onclick="switchTab('mifareTab', this)">🔑 MIFARE Inspector</button>
|
||||
<button class="nav-btn" onclick="switchTab('ndefTab', this)">🏷️ NTAG & NDEF Studio</button>
|
||||
<button class="nav-btn" onclick="switchTab('apduTab', this)">⚡ APDU Lab</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- TAB 1: LIVE SCAN -->
|
||||
<div id="scanTab" class="tab-content active">
|
||||
<div class="grid-2">
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">💳 Erkannte NFC Karte</div>
|
||||
</div>
|
||||
<div class="nfc-hologram-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="chip-icon"></div>
|
||||
<span style="font-size: 12px; color: var(--accent-cyan); font-weight: 700;">NFC ISO/IEC 14443</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">CARD UID</div>
|
||||
<div id="cardUid" class="card-uid-display">-- -- -- --</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div>Typ: <span id="cardType" style="color: #fff;">Keine Karte aufgelegt</span></div>
|
||||
<div>ATS: <span id="cardAts" style="color: #fff;">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">📜 Live Event Stream</div>
|
||||
</div>
|
||||
<div id="eventLog" class="apdu-console">
|
||||
<div class="apdu-log" style="color: var(--text-muted);">Warte auf NFC-Tag Interaktionen...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: HARDWARE CONTROL -->
|
||||
<div id="hardwareTab" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">💡 ACR122U LED & Buzzer Steuerung</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Rote LED State</label>
|
||||
<input type="checkbox" id="ledRed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Grüne LED State</label>
|
||||
<input type="checkbox" id="ledGreen" checked>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Blink-Modus</label>
|
||||
<input type="checkbox" id="ledBlink">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Summer (Beep beim Ändern)</label>
|
||||
<input type="checkbox" id="ledBeep" checked>
|
||||
</div>
|
||||
<button class="btn" onclick="applyLedConfig()">LED & Buzzer Anwenden</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>Schnell-Tests für Summer (Beep)</label>
|
||||
<div style="display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;">
|
||||
<button class="btn btn-secondary" onclick="quickBeep(100, 1)">1x Kurzer Beep</button>
|
||||
<button class="btn btn-secondary" onclick="quickBeep(100, 2)">2x Doppel-Beep</button>
|
||||
<button class="btn btn-secondary" onclick="quickBeep(200, 3)">3x Warnung</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 3: MIFARE INSPECTOR -->
|
||||
<div id="mifareTab" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">🔑 MIFARE Classic 1K Memory Map & Key Cracker</div>
|
||||
<button class="btn" onclick="dumpMifare()">Karte Auslesen / Dump</button>
|
||||
</div>
|
||||
<div id="mifareDumpContainer" class="sector-grid">
|
||||
<div style="color: var(--text-muted); grid-column: 1 / -1;">Klicke auf "Karte Auslesen", um alle 16 Sektoren zu scannen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 4: NTAG & NDEF -->
|
||||
<div id="ndefTab" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">🏷️ NDEF Nachricht auf Tag Schreiben</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>NDEF Typ</label>
|
||||
<select id="ndefType" onchange="toggleNdefFields()">
|
||||
<option value="url">Webseite / URL (https://...)</option>
|
||||
<option value="text">Text Nachricht</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="urlGroup" class="form-group">
|
||||
<label>Ziel-URL</label>
|
||||
<input type="text" id="ndefUrl" placeholder="https://example.com">
|
||||
</div>
|
||||
|
||||
<div id="textGroup" class="form-group" style="display: none;">
|
||||
<label>Text Inhalt</label>
|
||||
<textarea id="ndefText" rows="3" placeholder="Dein Text hier..."></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="writeNdef()">NFC Tag Beschreiben</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 5: APDU LAB -->
|
||||
<div id="apduTab" class="tab-content">
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">⚡ Raw APDU Command Studio</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Schnell-Vorlagen</label>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn btn-secondary" onclick="setApdu('FF 00 48 00 00')">Firmware Version</button>
|
||||
<button class="btn btn-secondary" onclick="setApdu('FF CA 00 00 00')">Read UID</button>
|
||||
<button class="btn btn-secondary" onclick="setApdu('FF CA 01 00 00')">Read ATS</button>
|
||||
<button class="btn btn-secondary" onclick="setApdu('FF 00 40 0F 04 01 01 01 01')">Test Beep & LED</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>APDU Hex Befehl</label>
|
||||
<input type="text" id="apduInput" value="FF 00 48 00 00" style="font-family: var(--font-mono);">
|
||||
</div>
|
||||
<button class="btn" onclick="sendApdu()">APDU Senden</button>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<label>Antwort vom Leser / Karte</label>
|
||||
<div id="apduResponseLog" class="apdu-console">
|
||||
<div class="apdu-log" style="color: var(--text-muted);">Ergebnisse werden hier angezeigt...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
const loc = window.location;
|
||||
const wsUrl = `ws://${loc.host}/ws`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('statusDot').classList.add('active');
|
||||
document.getElementById('statusText').innerText = 'ACR122U Verbunden';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWsEvent(data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('statusDot').classList.remove('active');
|
||||
document.getElementById('statusText').innerText = 'Getrennt - Reconnect...';
|
||||
setTimeout(connectWebSocket, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleWsEvent(evt) {
|
||||
const log = document.getElementById('eventLog');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
|
||||
if (evt.event === 'card_detected') {
|
||||
document.getElementById('cardUid').innerText = evt.uid;
|
||||
document.getElementById('cardType').innerText = evt.card_type;
|
||||
document.getElementById('cardAts').innerText = evt.ats || 'None';
|
||||
logEvent(`<span class="apdu-out">[${time}] 💳 Karte erkannt: UID ${evt.uid} (${evt.card_type})</span>`);
|
||||
} else if (evt.event === 'card_removed') {
|
||||
document.getElementById('cardUid').innerText = '-- -- -- --';
|
||||
document.getElementById('cardType').innerText = 'Keine Karte aufgelegt';
|
||||
document.getElementById('cardAts').innerText = '--';
|
||||
logEvent(`<span class="apdu-err">[${time}] 🚫 Karte entfernt</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
function logEvent(htmlStr) {
|
||||
const log = document.getElementById('eventLog');
|
||||
log.innerHTML = htmlStr + log.innerHTML;
|
||||
}
|
||||
|
||||
function switchTab(tabId, btn) {
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
function toggleNdefFields() {
|
||||
const type = document.getElementById('ndefType').value;
|
||||
document.getElementById('urlGroup').style.display = type === 'url' ? 'block' : 'none';
|
||||
document.getElementById('textGroup').style.display = type === 'text' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function applyLedConfig() {
|
||||
const payload = {
|
||||
red: document.getElementById('ledRed').checked,
|
||||
green: document.getElementById('ledGreen').checked,
|
||||
blink: document.getElementById('ledBlink').checked,
|
||||
beep: document.getElementById('ledBeep').checked,
|
||||
};
|
||||
await fetch('/api/led', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
async function quickBeep(duration, count) {
|
||||
await fetch('/api/apdu', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apdu_hex: `FF 00 40 00 04 ${duration/100} ${duration/100} ${count} 01` })
|
||||
});
|
||||
}
|
||||
|
||||
async function dumpMifare() {
|
||||
const container = document.getElementById('mifareDumpContainer');
|
||||
container.innerHTML = '<div style="color: var(--accent-cyan); grid-column: 1 / -1;">Scanne Karte... Bitte Karte ruhig auf dem Leser liegen lassen...</div>';
|
||||
|
||||
const res = await fetch('/api/mifare/dump', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
container.innerHTML = '';
|
||||
data.dump.sectors.forEach(sec => {
|
||||
let html = `<div class="sector-card">
|
||||
<div class="sector-header">
|
||||
<span>Sektor ${sec.sector_number}</span>
|
||||
<span>Key A: ${sec.key_a || '❌'}</span>
|
||||
</div>`;
|
||||
sec.blocks.forEach(b => {
|
||||
html += `<div class="block-line ${b.is_trailer ? 'trailer' : ''}">
|
||||
<span>B${b.block_number}: ${b.hex_data}</span>
|
||||
<span>${b.ascii_data}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
container.innerHTML += html;
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `<div style="color: var(--accent-pink); grid-column: 1 / -1;">Fehler beim Auslesen: ${data.error}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNdef() {
|
||||
const type = document.getElementById('ndefType').value;
|
||||
let url = '/api/ndef/write-url';
|
||||
let payload = {};
|
||||
|
||||
if (type === 'url') {
|
||||
payload = { url: document.getElementById('ndefUrl').value };
|
||||
} else {
|
||||
url = '/api/ndef/write-text';
|
||||
payload = { text: document.getElementById('ndefText').value, lang: 'de' };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('✅ NDEF erfolgreich auf NFC Tag geschrieben!');
|
||||
} else {
|
||||
alert('❌ Fehler: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
function setApdu(hexStr) {
|
||||
document.getElementById('apduInput').value = hexStr;
|
||||
}
|
||||
|
||||
async function sendApdu() {
|
||||
const hexStr = document.getElementById('apduInput').value;
|
||||
const res = await fetch('/api/apdu', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apdu_hex: hexStr })
|
||||
});
|
||||
const data = await res.json();
|
||||
const log = document.getElementById('apduResponseLog');
|
||||
|
||||
if (data.success) {
|
||||
log.innerHTML = `<div class="apdu-log apdu-in">> ${hexStr}</div>` +
|
||||
`<div class="apdu-log apdu-out">< DATEN: ${data.response.data_hex || '(Keine Daten)'}</div>` +
|
||||
`<div class="apdu-log apdu-out">< SW1/SW2: ${data.response.status}</div>` +
|
||||
log.innerHTML;
|
||||
} else {
|
||||
log.innerHTML = `<div class="apdu-log apdu-err">❌ FEHLER: ${data.error}</div>` + log.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user