Initial commit: Veritas NFC Tool (Smartcard & NFC Reader GUI Utility)

This commit is contained in:
2026-07-28 20:46:51 +02:00
commit 1ed16d8e4a
11 changed files with 7351 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+4814
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()})),
}
}
+709
View File
@@ -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>