From 449cdb7af5557f2c4598b14c5d60e8b77e47b284 Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 29 Oct 2026 08:57:36 +0700 Subject: [PATCH] NFC tag writer: local web page for ACR1252U (NDEF URL/text on Type 2 tags) --- .gitignore | 1 + Cargo.lock | 521 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 12 ++ README.md | 14 ++ src/main.rs | 78 +++++++ src/ndef.rs | 358 +++++++++++++++++++++++++++++++ src/reader.rs | 338 ++++++++++++++++++++++++++++++ static/index.html | 174 ++++++++++++++++ 8 files changed, 1496 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/main.rs create mode 100644 src/ndef.rs create mode 100644 src/reader.rs create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..05cd747 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,521 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nfc-writer" +version = "0.1.0" +dependencies = [ + "axum", + "pcsc", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cde1977 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nfc-writer" +version = "0.1.0" +edition = "2024" +description = "Local web page that programs NFC tags (NDEF URL/text) through a PC/SC reader such as the ACS ACR1252U" + +[dependencies] +axum = "0.8" +pcsc = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..c21bee2 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# nfc-writer + +Small local web page that programs NFC tags through a PC/SC reader (tested with the ACS ACR1252U-M1). +Type a URL or some text, put a tag on the reader, press **Program**. + +``` +cargo run --release # http://127.0.0.1:7252 (port: NFC_PORT) +``` + +- Browsers can't talk to a USB smart-card reader, so a small Rust server (axum + `pcsc`, Windows `winscard`) does the reader access; the page polls `/api/status` and posts to `/api/program`. +- Supported tags: NFC Forum Type 2: NTAG213/215/216, MIFARE Ultralight / EV1 / C. MIFARE Classic and DESFire are detected and refused. +- Input starting with `http(s)://`, `www.`, `tel:`, `mailto:`, `sms:`, `geo:` or any `scheme://` is written as an NDEF URI record (`www.` gets `https://` in front); anything else as an NDEF text record (`en`). +- The write empties the NDEF TLV first and writes its header last, so an interrupted write leaves an empty tag rather than a corrupt one. After writing, the data is read back and compared. +- Bound to 127.0.0.1 only. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c74222b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,78 @@ +mod ndef; +mod reader; + +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use serde::Deserialize; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +const INDEX: &str = include_str!("../static/index.html"); + +/// One reader, one operation at a time: the mutex serialises status polls and writes. +type Shared = Arc>; + +#[tokio::main] +async fn main() { + let port: u16 = std::env::var("NFC_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(7252); + let app = Router::new() + .route("/", get(|| async { Html(INDEX) })) + .route("/api/status", get(status)) + .route("/api/program", post(program)) + .with_state(Shared::default()); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .unwrap_or_else(|e| panic!("bind {addr}: {e}")); + println!("NFC writer running on http://{addr}"); + axum::serve(listener, app).await.expect("server error"); +} + +async fn with_reader( + s: Shared, + f: impl FnOnce(&mut reader::Cache) -> T + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(move || { + let mut cache = s.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut cache) + }) + .await + .map_err(|e| format!("internal error: {e}")) +} + +async fn status(State(s): State) -> Json { + Json( + with_reader(s, reader::status) + .await + .unwrap_or_else(|message| reader::Status::Error { message }), + ) +} + +#[derive(Deserialize)] +struct ProgramRequest { + text: String, +} + +async fn program(State(s): State, Json(req): Json) -> Response { + match with_reader(s, move |c| reader::program(c, &req.text)).await { + Ok(Ok(done)) => Json(done).into_response(), + Ok(Err(error)) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({ "error": error })), + ) + .into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": error })), + ) + .into_response(), + } +} diff --git a/src/ndef.rs b/src/ndef.rs new file mode 100644 index 0000000..1f9fef3 --- /dev/null +++ b/src/ndef.rs @@ -0,0 +1,358 @@ +//! NDEF encoding/decoding for NFC Forum Type 2 tags (NTAG21x, MIFARE Ultralight). + +use serde::Serialize; + +/// URI identifier codes (NFC Forum URI RTD), index = code. +const URI_PREFIXES: [&str; 36] = [ + "", + "http://www.", + "https://www.", + "http://", + "https://", + "tel:", + "mailto:", + "ftp://anonymous:anonymous@", + "ftp://ftp.", + "ftps://", + "sftp://", + "smb://", + "nfs://", + "ftp://", + "dav://", + "news:", + "telnet://", + "imap:", + "rtsp://", + "urn:", + "pop:", + "sip:", + "sips:", + "tftp:", + "btspp://", + "btl2cap://", + "btgoep://", + "tcpobex://", + "irdaobex://", + "file://", + "urn:epc:id:", + "urn:epc:tag:", + "urn:epc:pat:", + "urn:epc:raw:", + "urn:epc:", + "urn:nfc:", +]; + +/// Schemes without `//` that phones handle and that we treat as URLs. +const BARE_SCHEMES: [&str; 8] = [ + "tel:", "mailto:", "sms:", "smsto:", "geo:", "sip:", "sips:", "urn:", +]; + +#[derive(Debug, Clone, PartialEq)] +pub enum Record { + Uri(String), + Text(String), +} + +impl Record { + /// URL-looking input becomes a URI record, everything else a text record. + pub fn auto(input: &str) -> Record { + let s = input.trim(); + let lower = s.to_ascii_lowercase(); + if s.contains(char::is_whitespace) { + return Record::Text(s.to_string()); + } + if lower.starts_with("www.") { + return Record::Uri(format!("https://{s}")); + } + if has_scheme_slashes(&lower) || BARE_SCHEMES.iter().any(|p| lower.starts_with(p)) { + return Record::Uri(s.to_string()); + } + Record::Text(s.to_string()) + } + + pub fn view(&self) -> RecordView { + match self { + Record::Uri(u) => RecordView { + kind: "URL".into(), + value: u.clone(), + }, + Record::Text(t) => RecordView { + kind: "Text".into(), + value: t.clone(), + }, + } + } +} + +fn has_scheme_slashes(s: &str) -> bool { + match s.find("://") { + Some(i) if i > 0 => { + let scheme = &s[..i]; + scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + } + _ => false, + } +} + +/// A decoded record, ready for display. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RecordView { + pub kind: String, + pub value: String, +} + +fn uri_abbreviate(uri: &str) -> (u8, &str) { + URI_PREFIXES + .iter() + .enumerate() + .skip(1) + .filter(|(_, p)| uri.starts_with(*p)) + .max_by_key(|(_, p)| p.len()) + .map(|(i, p)| (i as u8, &uri[p.len()..])) + .unwrap_or((0, uri)) +} + +/// Encode a single-record NDEF message. +pub fn encode(record: &Record) -> Vec { + let (typ, payload) = match record { + Record::Uri(u) => { + let (code, rest) = uri_abbreviate(u); + let mut p = vec![code]; + p.extend_from_slice(rest.as_bytes()); + (b'U', p) + } + Record::Text(t) => { + let mut p = vec![2u8]; // UTF-8, language code length 2 + p.extend_from_slice(b"en"); + p.extend_from_slice(t.as_bytes()); + (b'T', p) + } + }; + let short = payload.len() < 256; + // MB | ME | (SR) | TNF=well-known + let mut out = vec![0x80 | 0x40 | if short { 0x10 } else { 0 } | 0x01, 1]; + if short { + out.push(payload.len() as u8); + } else { + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + } + out.push(typ); + out.extend_from_slice(&payload); + out +} + +/// Wrap an NDEF message in an NDEF TLV followed by a terminator TLV. +pub fn tlv(message: &[u8]) -> Vec { + let mut out = vec![0x03]; + if message.len() < 0xFF { + out.push(message.len() as u8); + } else { + out.push(0xFF); + out.extend_from_slice(&(message.len() as u16).to_be_bytes()); + } + out.extend_from_slice(message); + out.push(0xFE); + out +} + +/// Find the NDEF message in a Type 2 data area. Returns the message and the +/// number of bytes used up to the end of the NDEF TLV. +pub fn find_message(area: &[u8]) -> Result, String> { + let mut i = 0; + while i < area.len() { + match area[i] { + 0x00 => i += 1, // NULL TLV + 0xFE => return Ok(None), // terminator before any NDEF TLV + t => { + let (len, header) = match area.get(i + 1) { + Some(0xFF) => { + let b = area.get(i + 2..i + 4).ok_or("truncated TLV length")?; + (u16::from_be_bytes([b[0], b[1]]) as usize, 4) + } + Some(&l) => (l as usize, 2), + None => return Err("truncated TLV".into()), + }; + let start = i + header; + let end = start + len; + if t == 0x03 { + let msg = area + .get(start..end) + .ok_or("NDEF TLV runs past end of tag")?; + return Ok(Some((msg, end))); + } + i = end; // lock/memory control or proprietary TLV + } + } + } + Ok(None) +} + +/// Decode the records of an NDEF message. +pub fn decode(msg: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut i = 0; + let mut take = |n: usize| -> Result<&[u8], String> { + let s = msg.get(i..i + n).ok_or("NDEF record truncated")?; + i += n; + Ok(s) + }; + if msg.is_empty() { + return Ok(out); + } + loop { + let h = take(1)?[0]; + let tnf = h & 0x07; + let type_len = take(1)?[0] as usize; + let payload_len = if h & 0x10 != 0 { + take(1)?[0] as usize + } else { + let b = take(4)?; + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize + }; + let id_len = if h & 0x08 != 0 { + take(1)?[0] as usize + } else { + 0 + }; + let typ = take(type_len)?.to_vec(); + take(id_len)?; + let payload = take(payload_len)?; + out.push(view(tnf, &typ, payload)); + if h & 0x40 != 0 { + break; + } + } + Ok(out) +} + +fn view(tnf: u8, typ: &[u8], p: &[u8]) -> RecordView { + let (kind, value) = match (tnf, typ) { + (0, _) => ("Empty".to_string(), String::new()), + (1, b"U") if !p.is_empty() => { + let prefix = URI_PREFIXES.get(p[0] as usize).copied().unwrap_or(""); + ( + "URL".into(), + format!("{prefix}{}", String::from_utf8_lossy(&p[1..])), + ) + } + (1, b"T") if !p.is_empty() => { + let lang_len = (p[0] & 0x3F) as usize; + let body = p.get(1 + lang_len..).unwrap_or_default(); + let text = if p[0] & 0x80 != 0 { + let units: Vec = body + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_be_bytes(*c)) + .collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(body).into_owned() + }; + ("Text".into(), text) + } + (1, b"Sp") => ("Smart poster".into(), format!("{} bytes", p.len())), + (2, mime) => { + let mime = String::from_utf8_lossy(mime).into_owned(); + let value = if mime.starts_with("text/") { + String::from_utf8_lossy(p).into_owned() + } else { + format!("{} bytes", p.len()) + }; + (format!("MIME {mime}"), value) + } + (4, ext) => ( + format!("External {}", String::from_utf8_lossy(ext)), + format!("{} bytes", p.len()), + ), + _ => (format!("TNF {tnf}"), format!("{} bytes", p.len())), + }; + RecordView { kind, value } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_detects_urls() { + assert_eq!( + Record::auto("https://buildfor.life"), + Record::Uri("https://buildfor.life".into()) + ); + assert_eq!( + Record::auto("www.b4l.co.th"), + Record::Uri("https://www.b4l.co.th".into()) + ); + assert_eq!( + Record::auto("tel:+6653123456"), + Record::Uri("tel:+6653123456".into()) + ); + assert_eq!( + Record::auto("spotify://track/1"), + Record::Uri("spotify://track/1".into()) + ); + assert_eq!( + Record::auto("Note: hello"), + Record::Text("Note: hello".into()) + ); + assert_eq!( + Record::auto("Hello world"), + Record::Text("Hello world".into()) + ); + assert_eq!(Record::auto("Shelf-A3"), Record::Text("Shelf-A3".into())); + } + + #[test] + fn encodes_uri_with_prefix_code() { + let msg = encode(&Record::Uri("https://buildfor.life".into())); + let mut want = vec![0xD1, 0x01, 14, b'U', 0x04]; + want.extend_from_slice(b"buildfor.life"); + assert_eq!(msg, want); + let t = tlv(&msg); + assert_eq!(&t[..2], &[0x03, msg.len() as u8]); + assert_eq!(*t.last().unwrap(), 0xFE); + } + + #[test] + fn longest_prefix_wins() { + assert_eq!(uri_abbreviate("https://www.x.org"), (0x02, "x.org")); + assert_eq!(uri_abbreviate("urn:nfc:sn"), (0x23, "sn")); + assert_eq!(uri_abbreviate("geo:1,2"), (0x00, "geo:1,2")); + } + + #[test] + fn round_trips_short_and_long_records() { + for r in [ + Record::Uri("https://www.example.com/a?b=c".into()), + Record::Uri("geo:18.79,98.98".into()), + Record::Text("สวัสดี Chiang Mai".into()), + Record::Text("x".repeat(400)), + ] { + let area = tlv(&encode(&r)); + let (msg, used) = find_message(&area).unwrap().unwrap(); + assert_eq!(used, area.len() - 1); + assert_eq!(decode(msg).unwrap(), vec![r.view()]); + } + } + + #[test] + fn skips_lock_control_and_null_tlvs() { + let mut area = vec![0x00, 0x01, 0x03, 0xA0, 0x0C, 0x34]; + area.extend(tlv(&encode(&Record::Text("hi".into())))); + let (msg, _) = find_message(&area).unwrap().unwrap(); + assert_eq!(decode(msg).unwrap()[0].value, "hi"); + } + + #[test] + fn empty_tag_has_empty_message() { + let area = [0x03, 0x00, 0xFE, 0x00, 0x00]; + let (msg, used) = find_message(&area).unwrap().unwrap(); + assert!(decode(msg).unwrap().is_empty()); + assert_eq!(used, 2); + assert_eq!(find_message(&[0xFE, 0, 0]).unwrap(), None); + } +} diff --git a/src/reader.rs b/src/reader.rs new file mode 100644 index 0000000..7bb202b --- /dev/null +++ b/src/reader.rs @@ -0,0 +1,338 @@ +//! PC/SC access to the reader (ACR1252U PICC slot) and Type 2 tag memory. + +use crate::ndef::{self, Record, RecordView}; +use pcsc::{ + Attribute, Card, Context, Error as PcscError, MAX_BUFFER_SIZE, Protocols, Scope, ShareMode, +}; +use serde::Serialize; + +const GET_UID: [u8; 5] = [0xFF, 0xCA, 0x00, 0x00, 0x00]; + +/// Last inspected tag, so the status poll does not re-read the whole tag every second. +#[derive(Default)] +pub struct Cache { + tag: Option, +} + +#[derive(Clone, Serialize)] +pub struct TagInfo { + pub uid: String, + pub kind: String, + pub formatted: bool, + pub capacity: Option, + pub used: Option, + pub writable: bool, + pub records: Vec, + /// Why the tag cannot be programmed, or a read error. + pub note: Option, +} + +#[derive(Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum Status { + NoReader { message: String }, + NoCard { reader: String }, + Card { reader: String, tag: Box }, + Error { message: String }, +} + +#[derive(Serialize)] +pub struct Programmed { + pub uid: String, + pub record: RecordView, + pub bytes: usize, + pub capacity: usize, +} + +enum Kind { + Type2, + Unsupported(String), +} + +struct Cc { + formatted: bool, + capacity: usize, + writable: bool, +} + +fn open() -> Result<(String, Option), Status> { + let no_reader = || Status::NoReader { + message: "No NFC reader found. Is the ACR1252U plugged in?".into(), + }; + let ctx = Context::establish(Scope::User).map_err(|e| match e { + PcscError::NoService | PcscError::ServiceStopped | PcscError::NoReadersAvailable => { + no_reader() + } + e => Status::Error { + message: format!("PC/SC: {e}"), + }, + })?; + let readers = ctx.list_readers_owned().map_err(|e| match e { + PcscError::NoReadersAvailable | PcscError::NoService | PcscError::ServiceStopped => { + no_reader() + } + e => Status::Error { + message: format!("PC/SC: {e}"), + }, + })?; + // The ACR1252U shows up as two readers: "... PICC 0" (contactless) and "... SAM 0". + let reader = readers + .iter() + .find(|r| r.to_string_lossy().contains("PICC")) + .or_else(|| readers.first()) + .ok_or_else(no_reader)?; + let name = reader.to_string_lossy().into_owned(); + match ctx.connect(reader, ShareMode::Shared, Protocols::ANY) { + Ok(card) => Ok((name, Some(card))), + Err( + PcscError::NoSmartcard + | PcscError::RemovedCard + | PcscError::UnpoweredCard + | PcscError::UnresponsiveCard + | PcscError::ResetCard, + ) => Ok((name, None)), + Err(PcscError::ReaderUnavailable | PcscError::UnknownReader) => Err(no_reader()), + Err(e) => Err(Status::Error { + message: format!("Connecting to the tag failed: {e}"), + }), + } +} + +fn apdu(card: &Card, cmd: &[u8]) -> Result, String> { + let mut buf = [0u8; MAX_BUFFER_SIZE]; + let r = card.transmit(cmd, &mut buf).map_err(|e| { + format!("Tag communication failed ({e}). Keep the tag still on the reader.") + })?; + if r.len() < 2 { + return Err("Reader returned a short response.".into()); + } + let (data, sw) = r.split_at(r.len() - 2); + if sw != [0x90, 0x00] { + return Err(format!( + "Tag rejected the command (status {:02X}{:02X}).", + sw[0], sw[1] + )); + } + Ok(data.to_vec()) +} + +fn hex(b: &[u8]) -> String { + b.iter() + .map(|x| format!("{x:02X}")) + .collect::>() + .join(":") +} + +/// Card type from the PC/SC Part 3 storage-card ATR (3B 8F 80 01 80 4F 0C A0 00 00 03 06 SS NN NN ...). +fn kind(atr: &[u8]) -> Kind { + let storage = atr.len() >= 15 + && atr[..7] == [0x3B, 0x8F, 0x80, 0x01, 0x80, 0x4F, 0x0C] + && atr[7..12] == [0xA0, 0x00, 0x00, 0x03, 0x06]; + if !storage { + return Kind::Unsupported("ISO 14443-4 card (e.g. DESFire, bank card or phone)".into()); + } + match (atr[13], atr[14]) { + (0x00, 0x03) | (0x00, 0x3A) => Kind::Type2, + (0x00, 0x01) => Kind::Unsupported("MIFARE Classic 1K".into()), + (0x00, 0x02) => Kind::Unsupported("MIFARE Classic 4K".into()), + (0x00, 0x26) => Kind::Unsupported("MIFARE Mini".into()), + (a, b) => Kind::Unsupported(format!("storage card type {a:02X}{b:02X}")), + } +} + +fn type2_name(cc: &Cc) -> String { + let chip = match cc.capacity { + 48 => " (MIFARE Ultralight)", + 128 => " (Ultralight EV1)", + 144 => " (NTAG213)", + 496 => " (NTAG215)", + 872 => " (NTAG216)", + _ => "", + }; + format!("NFC Type 2{chip}") +} + +fn read_cc(card: &Card) -> Result { + let d = apdu(card, &[0xFF, 0xB0, 0x00, 0x03, 0x10])?; // pages 3..6 + let cc = d + .get(..4) + .ok_or("Could not read the capability container.")?; + Ok(Cc { + formatted: cc[0] == 0xE1, + capacity: cc[2] as usize * 8, + // lower nibble of byte 3 = write access (0 = granted) + writable: cc[3] & 0x0F == 0, + }) +} + +/// Read `len` bytes starting at page 4, 16 bytes (4 pages) per command. +fn read_data(card: &Card, len: usize) -> Result, String> { + let mut out = Vec::with_capacity(len + 16); + let mut page = 4u8; + while out.len() < len { + out.extend(apdu(card, &[0xFF, 0xB0, 0x00, page, 0x10])?); + page = page.checked_add(4).ok_or("Tag memory too large.")?; + } + out.truncate(len); + Ok(out) +} + +fn write_page(card: &Card, page: usize, data: &[u8]) -> Result<(), String> { + let page = u8::try_from(page).map_err(|_| "Page number out of range.")?; + let mut cmd = vec![0xFF, 0xD6, 0x00, page, 0x04]; + cmd.extend_from_slice(data); + apdu(card, &cmd).map(|_| ()) +} + +fn inspect(card: &Card, uid: String) -> TagInfo { + let mut info = TagInfo { + uid, + kind: String::new(), + formatted: false, + capacity: None, + used: None, + writable: false, + records: vec![], + note: None, + }; + let atr = card + .get_attribute_owned(Attribute::AtrString) + .unwrap_or_default(); + if let Kind::Unsupported(name) = kind(&atr) { + info.kind = name; + info.note = + Some("Only NTAG21x / MIFARE Ultralight (NFC Type 2) tags can be programmed.".into()); + return info; + } + let result = (|| -> Result<(), String> { + let cc = read_cc(card)?; + info.kind = type2_name(&cc); + info.formatted = cc.formatted; + info.writable = cc.writable; + if !cc.formatted { + info.note = Some("Tag is not NDEF formatted.".into()); + return Ok(()); + } + info.capacity = Some(cc.capacity); + if !cc.writable { + info.note = Some("Tag is locked (read-only).".into()); + } + let area = read_data(card, cc.capacity)?; + match ndef::find_message(&area)? { + Some((msg, used)) => { + info.used = Some(used); + info.records = ndef::decode(msg)?; + } + None => info.used = Some(0), + } + Ok(()) + })(); + if let Err(e) = result { + if info.kind.is_empty() { + info.kind = "NFC Type 2".into(); + } + info.note = Some(e); + } + info +} + +pub fn status(cache: &mut Cache) -> Status { + let (reader, card) = match open() { + Ok(x) => x, + Err(s) => { + cache.tag = None; + return s; + } + }; + let Some(card) = card else { + cache.tag = None; + return Status::NoCard { reader }; + }; + let uid = match apdu(&card, &GET_UID) { + Ok(u) => hex(&u), + Err(_) => return Status::NoCard { reader }, + }; + if let Some(tag) = cache.tag.as_ref().filter(|t| t.uid == uid) { + return Status::Card { + reader, + tag: Box::new(tag.clone()), + }; + } + let tag = inspect(&card, uid); + // Don't cache a failed read (tag moved away mid-read): retry on the next poll. + let failed = tag + .note + .as_deref() + .is_some_and(|n| n.contains("communication") || n.contains("rejected")); + cache.tag = (!failed).then(|| tag.clone()); + Status::Card { + reader, + tag: Box::new(tag), + } +} + +fn status_message(s: Status) -> String { + match s { + Status::NoReader { message } | Status::Error { message } => message, + Status::NoCard { .. } => "No tag on the reader.".into(), + Status::Card { .. } => "Unexpected reader state.".into(), + } +} + +pub fn program(cache: &mut Cache, input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("Enter a URL or some text first.".into()); + } + let record = Record::auto(input); + let tlv = ndef::tlv(&ndef::encode(&record)); + + let (_, card) = open().map_err(status_message)?; + let card = + card.ok_or("No tag on the reader. Place a tag on the reader and press Program again.")?; + let uid = hex(&apdu(&card, &GET_UID)?); + let atr = card + .get_attribute_owned(Attribute::AtrString) + .unwrap_or_default(); + if let Kind::Unsupported(name) = kind(&atr) { + return Err(format!( + "{name} is not supported. Use an NTAG21x / MIFARE Ultralight tag." + )); + } + let cc = read_cc(&card)?; + if !cc.formatted { + return Err("Tag is not NDEF formatted (no capability container).".into()); + } + if !cc.writable { + return Err("Tag is locked (read-only).".into()); + } + if tlv.len() > cc.capacity { + return Err(format!( + "Too long: needs {} bytes, this tag holds {}.", + tlv.len(), + cc.capacity + )); + } + + let mut data = tlv; + data.resize(data.len().div_ceil(4) * 4, 0); + // Empty the NDEF TLV first and write its header last, so an interrupted + // write leaves an empty tag instead of a corrupt message. + write_page(&card, 4, &[0x03, 0x00, 0xFE, 0x00])?; + for (i, chunk) in data.chunks(4).enumerate().skip(1) { + write_page(&card, 4 + i, chunk)?; + } + write_page(&card, 4, &data[..4])?; + + let back = read_data(&card, data.len())?; + if back != data { + return Err("Verification failed: the tag content does not match what was written.".into()); + } + cache.tag = None; // re-read on the next status poll + Ok(Programmed { + uid, + record: record.view(), + bytes: data.len(), + capacity: cc.capacity, + }) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..5def8f6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,174 @@ + + + + + +NFC tag writer + + + +
+

NFC tag writer

+
Connecting…
+ +
+ + +
+

URLs (https://, www., tel:, mailto:, geo: …) are written as a link, anything else as plain text.

+
+
+ + +