Skip to main content

netget/server/nfc/
actions.rs

1//! NFC (Near Field Communication) server protocol actions.
2//!
3//! The server emulates an NFC Forum Type 4 tag over a bound TCP socket using
4//! the vsmartcard `vpcd` framing; see `src/server/nfc/mod.rs` and
5//! `src/server/nfc/CLAUDE.md`.
6
7use 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
18/// Set the virtual tag's Answer to Reset
19pub static SET_ATR_ACTION: LazyLock<ActionDefinition> = LazyLock::new(set_atr_action);
20/// Set the virtual tag's NDEF message
21pub static SET_NDEF_MESSAGE_ACTION: LazyLock<ActionDefinition> =
22    LazyLock::new(set_ndef_message_action);
23/// Answer an APDU command sent to the virtual tag
24pub static RESPOND_TO_APDU_ACTION: LazyLock<ActionDefinition> =
25    LazyLock::new(respond_to_apdu_action);
26
27/// Emitted once, after the socket is bound and before the first reader is
28/// accepted, so the tag is fully configured by the time a reader can talk to it.
29pub 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
69/// Emitted when a reader sends `SELECT` by DF name (INS `A4`, P1 `04`) — the
70/// command that picks an application on the tag. Every other APDU, including
71/// `SELECT` by file identifier, raises `nfc_apdu_received` instead, so exactly
72/// one event (and one handler call) happens per command.
73pub 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
147/// Emitted for every command APDU that is not a SELECT-by-AID.
148pub 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
252/// Action definition for set_atr (async)
253fn 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
279/// Action definition for set_ndef_message (async)
280fn 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
316/// Action definition for respond_to_apdu (sync)
317fn 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
376/// Parse a hex string, rejecting anything that is not an even run of hex digits
377/// so a malformed value fails where it is produced instead of being logged as if
378/// it had been accepted.
379fn 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
383/// Parse a single-byte hex field such as `sw1`.
384fn 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
394/// NFC server protocol implementation
395pub 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        // Deterministic: answer every APDU with a text NDEF payload and a 90 00
487        // success status, no LLM call.
488        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            // LLM mode: LLM handles NFC tag emulation
500            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            // Script mode: Code-based NFC handling
511            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            // Static mode: Fixed NFC tag responses
528            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
634// Implement Server trait
635impl 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            // Build startup params JSON manually since StartupParams doesn't expose to_json
646            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                // Normalise both spellings of the body to hex so the server has
705                // exactly one form to decode. Refusing the ambiguous case is the
706                // point: "48656c6c6f" is simultaneously valid text and valid hex
707                // and only the sender knows which it meant.
708                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                // No default, deliberately. A tag is an access-control device and the
723                // only status word a default could reasonably be is 9000 - success. That
724                // would mean the most degenerate thing a model can emit, a bare
725                // `{"type": "respond_to_apdu"}`, approves a VERIFY. Success has to be
726                // named, the same way `eapol` gives EAP-Success its own eight literal
727                // octets rather than a boolean anything could flip. A missing status word
728                // is an error here, and reaches the wire as 6F00 through
729                // `decision=fail_closed_no_action`.
730                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}