1use crate::llm::actions::{
8 protocol_trait::{ActionResult, Protocol, Server},
9 ActionDefinition, Parameter, ParameterDefinition,
10};
11use crate::protocol::log_template::LogTemplate;
12use crate::protocol::EventType;
13use crate::state::app_state::AppState;
14use anyhow::{anyhow, Context, Result};
15use serde_json::json;
16use std::sync::LazyLock;
17
18pub static SET_ATR_ACTION: LazyLock<ActionDefinition> = LazyLock::new(set_atr_action);
20pub static SET_NDEF_MESSAGE_ACTION: LazyLock<ActionDefinition> =
22 LazyLock::new(set_ndef_message_action);
23pub static RESPOND_TO_APDU_ACTION: LazyLock<ActionDefinition> =
25 LazyLock::new(respond_to_apdu_action);
26
27pub static NFC_SERVER_STARTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
30 EventType::new(
31 "nfc_server_started",
32 "Virtual NFC tag bound and ready; configure the tag before readers connect",
33 json!({
34 "type": "set_ndef_message",
35 "records": [{"type": "text", "language": "en", "text": "Hello NFC!"}]
36 }),
37 )
38 .with_parameters(vec![
39 Parameter {
40 name: "tag_type".to_string(),
41 type_hint: "string".to_string(),
42 description: "Tag type this server was started with".to_string(),
43 required: true,
44 },
45 Parameter {
46 name: "uid".to_string(),
47 type_hint: "string".to_string(),
48 description: "Tag UID (hex), supplied at startup or generated".to_string(),
49 required: true,
50 },
51 Parameter {
52 name: "listen_addr".to_string(),
53 type_hint: "string".to_string(),
54 description: "Address the virtual tag is listening on".to_string(),
55 required: true,
56 },
57 ])
58 .with_actions(vec![
59 SET_ATR_ACTION.clone(),
60 SET_NDEF_MESSAGE_ACTION.clone(),
61 ])
62 .with_log_template(
63 LogTemplate::new()
64 .with_info("NFC virtual tag ready on {listen_addr} (type={tag_type})")
65 .with_debug("NFC started: type={tag_type} uid={uid} addr={listen_addr}"),
66 )
67});
68
69pub static NFC_TAG_SELECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
74 EventType::new(
75 "nfc_tag_selected",
76 "Reader selected an application on the virtual tag (SELECT by AID)",
77 json!({
78 "type": "respond_to_apdu",
79 "sw1": "90",
80 "sw2": "00"
81 }),
82 )
83 .with_parameters(vec![
84 Parameter {
85 name: "application_id".to_string(),
86 type_hint: "string".to_string(),
87 description:
88 "Application ID (AID) the reader selected, as uppercase hex (e.g. D2760000850101 \
89 for the NDEF application)"
90 .to_string(),
91 required: true,
92 },
93 Parameter {
94 name: "cla".to_string(),
95 type_hint: "string".to_string(),
96 description: "Class byte as two hex digits".to_string(),
97 required: true,
98 },
99 Parameter {
100 name: "p1".to_string(),
101 type_hint: "string".to_string(),
102 description: "Parameter 1 as two hex digits (always 04 for select by AID)".to_string(),
103 required: true,
104 },
105 Parameter {
106 name: "p2".to_string(),
107 type_hint: "string".to_string(),
108 description: "Parameter 2 as two hex digits".to_string(),
109 required: true,
110 },
111 Parameter {
112 name: "le".to_string(),
113 type_hint: "number".to_string(),
114 description: "Maximum response length the reader will accept, null if absent"
115 .to_string(),
116 required: false,
117 },
118 Parameter {
119 name: "tag_type".to_string(),
120 type_hint: "string".to_string(),
121 description: "Tag type this server was started with".to_string(),
122 required: true,
123 },
124 Parameter {
125 name: "uid".to_string(),
126 type_hint: "string".to_string(),
127 description: "Tag UID (hex)".to_string(),
128 required: true,
129 },
130 Parameter {
131 name: "ndef_records".to_string(),
132 type_hint: "array".to_string(),
133 description: "NDEF records configured by set_ndef_message, so they can be served \
134 from this handler"
135 .to_string(),
136 required: false,
137 },
138 ])
139 .with_actions(vec![RESPOND_TO_APDU_ACTION.clone()])
140 .with_log_template(
141 LogTemplate::new()
142 .with_info("NFC SELECT AID {application_id}")
143 .with_debug("NFC tag_selected: aid={application_id} cla={cla} p2={p2}"),
144 )
145});
146
147pub static NFC_APDU_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
149 EventType::new(
150 "nfc_apdu_received",
151 "Command APDU received by the virtual NFC tag",
152 json!({
153 "type": "respond_to_apdu",
154 "data_text": "Hello NFC!",
155 "sw1": "90",
156 "sw2": "00"
157 }),
158 )
159 .with_parameters(vec![
160 Parameter {
161 name: "ins_name".to_string(),
162 type_hint: "string".to_string(),
163 description: "Decoded instruction name: SELECT, READ_BINARY, UPDATE_BINARY, VERIFY, \
164 GET_DATA, ... or UNKNOWN"
165 .to_string(),
166 required: true,
167 },
168 Parameter {
169 name: "cla".to_string(),
170 type_hint: "string".to_string(),
171 description: "Class byte as two hex digits".to_string(),
172 required: true,
173 },
174 Parameter {
175 name: "ins".to_string(),
176 type_hint: "string".to_string(),
177 description: "Instruction byte as two hex digits".to_string(),
178 required: true,
179 },
180 Parameter {
181 name: "p1".to_string(),
182 type_hint: "string".to_string(),
183 description: "Parameter 1 as two hex digits".to_string(),
184 required: true,
185 },
186 Parameter {
187 name: "p2".to_string(),
188 type_hint: "string".to_string(),
189 description: "Parameter 2 as two hex digits".to_string(),
190 required: true,
191 },
192 Parameter {
193 name: "lc".to_string(),
194 type_hint: "number".to_string(),
195 description: "Number of bytes in the command data field".to_string(),
196 required: true,
197 },
198 Parameter {
199 name: "data_hex".to_string(),
200 type_hint: "string".to_string(),
201 description:
202 "Command data field as uppercase hex. Hex is used because this field is opaque \
203 bytes chosen by the reader; prefer data_text when it is present."
204 .to_string(),
205 required: true,
206 },
207 Parameter {
208 name: "data_text".to_string(),
209 type_hint: "string".to_string(),
210 description: "Command data as text, present only when every byte is printable ASCII"
211 .to_string(),
212 required: false,
213 },
214 Parameter {
215 name: "le".to_string(),
216 type_hint: "number".to_string(),
217 description: "Maximum response length the reader will accept, null if absent"
218 .to_string(),
219 required: false,
220 },
221 Parameter {
222 name: "tag_type".to_string(),
223 type_hint: "string".to_string(),
224 description: "Tag type this server was started with".to_string(),
225 required: true,
226 },
227 Parameter {
228 name: "uid".to_string(),
229 type_hint: "string".to_string(),
230 description: "Tag UID (hex)".to_string(),
231 required: true,
232 },
233 Parameter {
234 name: "ndef_records".to_string(),
235 type_hint: "array".to_string(),
236 description: "NDEF records configured by set_ndef_message, so they can be served \
237 from this handler"
238 .to_string(),
239 required: false,
240 },
241 ])
242 .with_actions(vec![RESPOND_TO_APDU_ACTION.clone()])
243 .with_log_template(
244 LogTemplate::new()
245 .with_info("NFC APDU {ins_name} (Lc={lc})")
246 .with_debug(
247 "NFC apdu_received: {ins_name} cla={cla} ins={ins} p1={p1} p2={p2} lc={lc}",
248 ),
249 )
250});
251
252fn set_atr_action() -> ActionDefinition {
254 ActionDefinition {
255 name: "set_atr".to_string(),
256 description: "Set the Answer to Reset (ATR) the virtual tag returns when a reader powers \
257 it up"
258 .to_string(),
259 parameters: vec![Parameter {
260 name: "atr_hex".to_string(),
261 type_hint: "string".to_string(),
262 description: "ATR bytes as a hex string (ISO 7816-3 defines this as raw bytes, so hex \
263 is the only faithful form)"
264 .to_string(),
265 required: true,
266 }],
267 example: json!({
268 "type": "set_atr",
269 "atr_hex": "3B8F8001804F0CA0000003060300030000000068"
270 }),
271 log_template: Some(
272 LogTemplate::new()
273 .with_info("-> NFC set ATR")
274 .with_debug("NFC set_atr: atr={atr_hex}"),
275 ),
276 }
277}
278
279fn set_ndef_message_action() -> ActionDefinition {
281 ActionDefinition {
282 name: "set_ndef_message".to_string(),
283 description: "Store the NDEF records this tag carries. They are handed back to you in \
284 every nfc_apdu_received / nfc_tag_selected event so you can serve them."
285 .to_string(),
286 parameters: vec![Parameter {
287 name: "records".to_string(),
288 type_hint: "array".to_string(),
289 description:
290 "Array of NDEF records, each an object such as {\"type\": \"text\", \"language\": \"en\", \"text\": \"...\"} or {\"type\": \"uri\", \"uri\": \"...\"}"
291 .to_string(),
292 required: true,
293 }],
294 example: json!({
295 "type": "set_ndef_message",
296 "records": [
297 {
298 "type": "text",
299 "language": "en",
300 "text": "Hello NFC!"
301 },
302 {
303 "type": "uri",
304 "uri": "https://example.com"
305 }
306 ]
307 }),
308 log_template: Some(
309 LogTemplate::new()
310 .with_info("-> NFC set NDEF ({records_len} records)")
311 .with_debug("NFC set_ndef_message: records={records_len}"),
312 ),
313 }
314}
315
316fn respond_to_apdu_action() -> ActionDefinition {
318 ActionDefinition {
319 name: "respond_to_apdu".to_string(),
320 description: "Answer the command APDU with an optional response body and a REQUIRED \
321 two-byte status word. This is the only way to reply; if you do not use \
322 it, or you use it without naming sw1 and sw2, the tag answers 6F00 \
323 (card error)."
324 .to_string(),
325 parameters: vec![
326 Parameter {
327 name: "data_text".to_string(),
328 type_hint: "string".to_string(),
329 description: "Response body as text; sent as its UTF-8 bytes. Use this whenever \
330 the answer is text. Mutually exclusive with data_hex."
331 .to_string(),
332 required: false,
333 },
334 Parameter {
335 name: "data_hex".to_string(),
336 type_hint: "string".to_string(),
337 description: "Response body as a hex string, for genuinely binary answers such as \
338 a capability container. Mutually exclusive with data_text."
339 .to_string(),
340 required: false,
341 },
342 Parameter {
343 name: "sw1".to_string(),
344 type_hint: "string".to_string(),
345 description: "Status byte 1 as two hex digits. REQUIRED - there is no default, \
346 because the only plausible default is success. Approve with '90'; \
347 refuse with '69' (security), '6A' (wrong parameters) or '6D' \
348 (unsupported)."
349 .to_string(),
350 required: true,
351 },
352 Parameter {
353 name: "sw2".to_string(),
354 type_hint: "string".to_string(),
355 description: "Status byte 2 as two hex digits. REQUIRED. '90' '00' together mean \
356 success; naming both bytes is what makes approving a command a \
357 deliberate act rather than an omission."
358 .to_string(),
359 required: true,
360 },
361 ],
362 example: json!({
363 "type": "respond_to_apdu",
364 "data_text": "Hello NFC!",
365 "sw1": "90",
366 "sw2": "00"
367 }),
368 log_template: Some(
369 LogTemplate::new()
370 .with_info("-> NFC APDU response SW={sw1}{sw2}")
371 .with_debug("NFC respond_to_apdu: data={data_hex} sw1={sw1} sw2={sw2}"),
372 ),
373 }
374}
375
376fn parse_hex(field: &str, value: &str) -> Result<Vec<u8>> {
380 hex::decode(value).map_err(|e| anyhow!("Invalid hex in '{field}' ({value}): {e}"))
381}
382
383fn parse_status_byte(field: &str, value: &str) -> Result<u8> {
385 match parse_hex(field, value)?.as_slice() {
386 [byte] => Ok(*byte),
387 other => Err(anyhow!(
388 "'{field}' must be exactly one hex byte (two digits), got {} byte(s)",
389 other.len()
390 )),
391 }
392}
393
394pub struct NfcServerProtocol;
396
397impl Protocol for NfcServerProtocol {
398 fn protocol_name(&self) -> &'static str {
399 "nfc"
400 }
401
402 fn stack_name(&self) -> &'static str {
403 "application"
404 }
405
406 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
407 use crate::protocol::metadata::{
408 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
409 };
410
411 ProtocolMetadataV2::builder()
412 .state(DevelopmentState::Experimental)
413 .privilege_requirement(PrivilegeRequirement::None)
414 .implementation(
415 "Virtual NFC Forum Type 4 tag: ISO 7816-4 command APDUs over a bound TCP socket \
416 using the vsmartcard vpcd framing (u16-BE length prefix; 1-byte frames are vpcd \
417 control codes, 04 requesting the ATR). No PC/SC call is made and no reader \
418 hardware is used.",
419 )
420 .llm_control(
421 "ATR and NDEF records at startup; the response body and ISO 7816-4 status word \
422 for every command APDU. Nothing is answered by built-in card logic, and a \
423 handler that produces no respond_to_apdu gets 6F00 rather than a success.",
424 )
425 .e2e_testing(
426 "Mocked E2E drives the bound socket with a real vpcd-framed client and asserts \
427 the response APDU bytes (tests/server/nfc/e2e_test.rs). No hardware needed. \
428 Interop with pcscd via a real vpcd in client mode is untested.",
429 )
430 .notes(
431 "Card emulation over RF is impossible with a normal PC/SC reader, so the tag is \
432 exposed over TCP instead. All three events fire: nfc_server_started at bind, \
433 nfc_tag_selected on SELECT by AID (INS A4 / P1 04), nfc_apdu_received on every \
434 other APDU. A real reader reaches it only through vpcd configured as a TCP \
435 client (DEVICENAME /dev/null:<host>:<port>), which has not been tested against \
436 hardware. Frames are capped at 4096 bytes and malformed APDUs are answered 6700.",
437 )
438 .max_inbound_bytes(crate::server::nfc::MAX_FRAME_LEN)
439 .build()
440 }
441
442 fn description(&self) -> &'static str {
443 "Virtual NFC Type 4 tag answering ISO 7816-4 APDUs over TCP (vpcd framing)"
444 }
445
446 fn get_event_types(&self) -> Vec<EventType> {
447 vec![
448 (*NFC_SERVER_STARTED_EVENT).clone(),
449 (*NFC_TAG_SELECTED_EVENT).clone(),
450 (*NFC_APDU_RECEIVED_EVENT).clone(),
451 ]
452 }
453
454 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
455 vec![SET_ATR_ACTION.clone(), SET_NDEF_MESSAGE_ACTION.clone()]
456 }
457
458 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
459 vec![RESPOND_TO_APDU_ACTION.clone()]
460 }
461
462 fn keywords(&self) -> Vec<&'static str> {
463 vec![
464 "nfc",
465 "smart card",
466 "apdu",
467 "iso7816",
468 "tag",
469 "ndef",
470 "card emulation",
471 ]
472 }
473
474 fn example_prompt(&self) -> &'static str {
475 "Create a virtual NFC tag on port {AVAILABLE_PORT} that responds to APDU commands"
476 }
477
478 fn group_name(&self) -> &'static str {
479 "NFC & Smart Cards"
480 }
481
482 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
483 use crate::llm::actions::StartupExamples;
484 use serde_json::json;
485
486 let script = r#"import json, sys
489data = json.load(sys.stdin)
490event = data["event"]
491if data["event_type_id"] == "nfc_apdu_received":
492 actions = [{"type": "respond_to_apdu",
493 "data_text": "Hello NFC!", "sw1": "90", "sw2": "00"}]
494else:
495 actions = []
496print(json.dumps({"actions": actions}))"#;
497
498 StartupExamples::new(
499 json!({
501 "type": "open_server",
502 "port": 0,
503 "base_stack": "nfc",
504 "instruction": "Act as a virtual NFC Type 4 tag that responds to APDU commands",
505 "startup_params": {
506 "tag_type": "type4",
507 "uid": "04A1B2C3D4E5F6"
508 }
509 }),
510 json!({
512 "type": "open_server",
513 "port": 0,
514 "base_stack": "nfc",
515 "startup_params": {
516 "tag_type": "type4"
517 },
518 "event_handlers": [{
519 "event_pattern": "nfc_apdu_received",
520 "handler": {
521 "type": "script",
522 "language": "python",
523 "code": script
524 }
525 }]
526 }),
527 json!({
529 "type": "open_server",
530 "port": 0,
531 "base_stack": "nfc",
532 "startup_params": {
533 "tag_type": "type4"
534 },
535 "event_handlers": [
536 {
537 "event_pattern": "nfc_server_started",
538 "handler": {
539 "type": "static",
540 "actions": [{
541 "type": "set_ndef_message",
542 "records": [{"type": "text", "language": "en", "text": "Hello NFC!"}]
543 }]
544 }
545 },
546 {
547 "event_pattern": "nfc_tag_selected",
548 "handler": {
549 "type": "static",
550 "actions": [{
551 "type": "respond_to_apdu",
552 "sw1": "90",
553 "sw2": "00"
554 }]
555 }
556 },
557 {
558 "event_pattern": "nfc_apdu_received",
559 "handler": {
560 "type": "static",
561 "actions": [{
562 "type": "respond_to_apdu",
563 "data_text": "Hello NFC!",
564 "sw1": "90",
565 "sw2": "00"
566 }]
567 }
568 }
569 ]
570 }),
571 )
572 }
573
574 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
575 vec![
576 ParameterDefinition {
577 name: "tag_type".to_string(),
578 type_hint: "string".to_string(),
579 description:
580 "Virtual tag type, reported to the handler in every APDU event: 'type2' \
581 (MIFARE), 'type4' (ISO 14443-4, default), 'generic'"
582 .to_string(),
583 required: false,
584
585 example: json!("type4"),
586 default: None,
587 },
588 ParameterDefinition {
589 name: "uid".to_string(),
590 type_hint: "string".to_string(),
591 description:
592 "Tag UID (hex), reported to the handler in every APDU event; a random 7-byte \
593 UID is generated when omitted"
594 .to_string(),
595 required: false,
596
597 example: json!("04A1B2C3D4E5F6"),
598 default: None,
599 },
600 ParameterDefinition {
601 name: "first_byte_timeout_secs".to_string(),
602 type_hint: "number".to_string(),
603 description:
604 "Seconds a connected reader may send no frame at all before the tag closes \
605 it. Default 30: vpcd is reader-driven, so every peer this socket can have \
606 speaks first, and NetGet's own NFC client talks to a PC/SC reader rather \
607 than to this socket, so no peer here is ever waiting on a person."
608 .to_string(),
609 required: false,
610
611 example: json!(30),
612 default: Some(serde_json::json!(super::FIRST_FRAME_READ_TIMEOUT.as_secs())),
613 },
614 ParameterDefinition {
615 name: "idle_timeout_secs".to_string(),
616 type_hint: "number".to_string(),
617 description:
618 "Seconds an established reader may send no further frame before the tag \
619 closes it. Default 300. A reader goes quiet whenever no application is \
620 watching it, so raise this rather than lower it if pcscd hosts mount this \
621 card and use it rarely."
622 .to_string(),
623 required: false,
624
625 example: json!(300),
626 default: Some(serde_json::json!(
627 super::IDLE_BETWEEN_FRAMES_TIMEOUT.as_secs()
628 )),
629 },
630 ]
631 }
632}
633
634impl Server for NfcServerProtocol {
636 fn spawn(
637 &self,
638 ctx: crate::protocol::SpawnContext,
639 ) -> std::pin::Pin<
640 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
641 > {
642 Box::pin(async move {
643 use crate::server::nfc::NfcServer;
644
645 let startup_params_json = if let Some(ref params) = ctx.startup_params {
647 serde_json::json!({
648 "tag_type": params.get_optional_string("tag_type")?,
649 "uid": params.get_optional_string("uid")?,
650 "first_byte_timeout_secs": params.get_optional_u64("first_byte_timeout_secs")?,
651 "idle_timeout_secs": params.get_optional_u64("idle_timeout_secs")?,
652 })
653 } else {
654 serde_json::json!({})
655 };
656
657 NfcServer::start(
658 ctx.legacy_listen_addr(),
659 ctx.llm_client,
660 ctx.state,
661 ctx.status_tx,
662 ctx.server_id,
663 startup_params_json,
664 )
665 .await
666 })
667 }
668
669 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
670 let action_type = action
671 .get("type")
672 .and_then(|v| v.as_str())
673 .context("Missing 'type' field in action")?;
674
675 match action_type {
676 "set_atr" => {
677 let atr_hex = action
678 .get("atr_hex")
679 .and_then(|v| v.as_str())
680 .context("Missing 'atr_hex' parameter")?;
681 let atr = parse_hex("atr_hex", atr_hex)?;
682 if atr.is_empty() {
683 return Err(anyhow!("'atr_hex' must not be empty"));
684 }
685 Ok(ActionResult::Custom {
686 name: "set_atr".to_string(),
687 data: json!({ "atr_hex": atr_hex }),
688 })
689 }
690 "set_ndef_message" => {
691 let records = action
692 .get("records")
693 .and_then(|v| v.as_array())
694 .context("Missing 'records' parameter")?;
695 Ok(ActionResult::Custom {
696 name: "set_ndef_message".to_string(),
697 data: json!({ "records": records }),
698 })
699 }
700 "respond_to_apdu" => {
701 let data_hex = action.get("data_hex").and_then(|v| v.as_str());
702 let data_text = action.get("data_text").and_then(|v| v.as_str());
703
704 let body_hex = match (data_text, data_hex) {
709 (Some(text), None) => hex::encode_upper(text.as_bytes()),
710 (None, Some(hex_str)) => {
711 parse_hex("data_hex", hex_str)?;
712 hex_str.to_string()
713 }
714 (None, None) => String::new(),
715 (Some(_), Some(_)) => {
716 return Err(anyhow!(
717 "respond_to_apdu accepts 'data_text' or 'data_hex', not both"
718 ))
719 }
720 };
721
722 let sw1 = action.get("sw1").and_then(|v| v.as_str()).ok_or_else(|| {
731 anyhow!(
732 "respond_to_apdu requires an explicit 'sw1' (two hex digits): there is \
733 no default, because the default would be success"
734 )
735 })?;
736 let sw2 = action.get("sw2").and_then(|v| v.as_str()).ok_or_else(|| {
737 anyhow!(
738 "respond_to_apdu requires an explicit 'sw2' (two hex digits): there is \
739 no default, because the default would be success"
740 )
741 })?;
742 parse_status_byte("sw1", sw1)?;
743 parse_status_byte("sw2", sw2)?;
744
745 Ok(ActionResult::Custom {
746 name: "respond_to_apdu".to_string(),
747 data: json!({
748 "data_hex": body_hex,
749 "sw1": sw1,
750 "sw2": sw2,
751 }),
752 })
753 }
754 _ => Err(anyhow!("Unknown action type: {}", action_type)),
755 }
756 }
757}