From 2478810d5a7912b5f9739d585b12e4d8f0ee3e8e Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Sat, 11 Jul 2026 23:48:43 +0200 Subject: [PATCH 01/22] Add Transparent Reader Support (TRS) TRS tunnels raw smart-card APDUs from the CP, through a PD acting as a transparent pipe, to a card in the reader (OSDP CMD_XWR/REPLY_XRD, capability SMART_CARD_SUPPORT). The control panel drives the card directly, so a compromised reader cannot forge a credential. The CP runs the session as a library-driven state machine: it negotiates transparent mode, streams the app's APDUs, and tears the session down on osdp_cp_trs_stop(). Apps submit OSDP_CMD_XWR commands and receive card responses as OSDP_EVENT_TRS events. The PD answers each APDU either synchronously, in the command callback, or deferred: it ACKs "working" and delivers the response on a later poll once a slow card responds. Deferral keeps a shared multi-drop bus non-blocking, so each PD's session makes progress independently. A Card Present reply is accepted with or without the trailing status byte -- the spec marks it optional (v2.2 section 7.26.8) and readers like the HID RPK40 omit it; the status then reports as unspecified. Gated behind OPT_BUILD_OSDP_TRS (default off); wired into both build systems and covered by a CP<->PD APDU round-trip unit test. Fixes: #22 Co-developed-by: Aaron Tulino (Aaronjamt) Signed-off-by: Siddharth Chandrasekaran --- .github/workflows/reusable-ci.yml | 7 +- CMakeLists.txt | 1 + configure.sh | 10 + include/osdp.h | 226 +++++++ library.json | 1 + python/setup.py | 11 + src/CMakeLists.txt | 10 + src/osdp_common.h | 96 ++- src/osdp_cp.c | 321 +++++++++- src/osdp_pd.c | 57 ++ src/osdp_trs.c | 837 ++++++++++++++++++++++++ src/osdp_trs.h | 85 +++ tests/unit-tests/test-sources.list | 1 + tests/unit-tests/test-suites.def | 1 + tests/unit-tests/test-trs.c | 997 +++++++++++++++++++++++++++++ tests/unit-tests/test.c | 15 + tests/unit-tests/test.h | 2 + 17 files changed, 2671 insertions(+), 7 deletions(-) create mode 100644 src/osdp_trs.c create mode 100644 src/osdp_trs.h create mode 100644 tests/unit-tests/test-trs.c diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index a42ee96f..98a3d618 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -81,12 +81,17 @@ jobs: Test: runs-on: ubuntu-latest + strategy: + matrix: + # TRS is off by default; run the suite both ways so the transparent + # reader sources and their tests are built and exercised too. + trs: [ OFF, ON ] steps: - uses: actions/checkout@v7 with: submodules: recursive - name: Configure - run: cmake -DCMAKE_BUILD_TYPE=Debug . + run: cmake -DCMAKE_BUILD_TYPE=Debug -DOPT_BUILD_OSDP_TRS=${{ matrix.trs }} . - name: Run unit-tests run: cmake --build . --parallel 7 --target check diff --git a/CMakeLists.txt b/CMakeLists.txt index ecf5e5fe..2464a06c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ option(OPT_OSDP_STATIC "Build without dynamic memory allocation" OFF) option(OPT_OSDP_LIB_ONLY "Only build the library" OFF) option(OPT_BUILD_BARE_METAL "Build library for bare metal targets" OFF) option(OPT_USE_32BIT_TICK_T "Use uint32_t tick_t on bare-metal targets" OFF) +option(OPT_BUILD_OSDP_TRS "Enable Transparent Reader Support" OFF) set(OPT_OSDP_CRYPTO_BACKEND "auto" CACHE STRING "Crypto backend selection: auto, openssl, mbedtls, or tinyaes") set_property(CACHE OPT_OSDP_CRYPTO_BACKEND PROPERTY STRINGS diff --git a/configure.sh b/configure.sh index 0a9a7808..1c1c4636 100755 --- a/configure.sh +++ b/configure.sh @@ -28,6 +28,7 @@ usage() { --lib-only Only build the library --bare-metal Enable bare-metal build paths --use-32bit-tick-t Use uint32_t tick_t (requires --bare-metal) + --enable-trs Enable Transparent Reader Support (TRS) --cross-compile PREFIX Use to pass a compiler prefix --prefix PATH Install path prefix (default: /usr) --build-dir Build output directory (default: ./build) @@ -57,6 +58,7 @@ while [ $# -gt 0 ]; do --lib-only) LIB_ONLY=1;; --bare-metal) BARE_METAL=1;; --use-32bit-tick-t) USE_32BIT_TICK_T=1;; + --enable-trs) ENABLE_TRS=1;; --build-dir) BUILD_DIR=$2; shift;; -d|--debug) DEBUG=1;; -f|--force) FORCE=1;; @@ -133,6 +135,10 @@ if [[ ! -z "${USE_32BIT_TICK_T}" ]]; then CCFLAGS+=" -DUSE_32BIT_TICK_T" fi +if [[ ! -z "${ENABLE_TRS}" ]]; then + CCFLAGS+=" -DOPT_BUILD_OSDP_TRS" +fi + ## Repo meta data echo "Extracting source code meta information" PROJECT_VERSION=$(perl -ne 'print if s/^project\(libosdp VERSION ([0-9.]+)\)$/\1/' CMakeLists.txt) @@ -239,6 +245,10 @@ fi TARGETS="cp_app pd_app" +if [[ ! -z "${ENABLE_TRS}" ]]; then + LIBOSDP_SOURCES+=" src/osdp_trs.c" +fi + # Suite source files come from the shared manifest so the lean and CMake # builds never drift; see tests/unit-tests/test-sources.list. TEST_SOURCES="" diff --git a/include/osdp.h b/include/osdp.h index be740e47..9a29a775 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1119,6 +1119,15 @@ enum osdp_notification_type { * reconnect that reports the same identity is silent. */ OSDP_NOTIFICATION_PD_ID, + /** + * TRS card session state change (CP only). Payload: `trs_status` + * (@ref osdp_notification_trs_status). + * + * Fires when the reader enters transparent mode (OPENED), and once more + * when the session ends, either because a STOP was honoured (CLOSED) or + * because it could not be established or was cut short (FAILED). + */ + OSDP_NOTIFICATION_TRS_STATUS, }; /** @@ -1179,9 +1188,215 @@ enum osdp_cmd_e { OSDP_CMD_PIVDATA, /**< Retrieve PIV object data command */ OSDP_CMD_GENAUTH, /**< General authenticate command */ OSDP_CMD_CRAUTH, /**< Challenge/response authenticate command */ + OSDP_CMD_XWR, /**< Transparent mode command */ OSDP_CMD_SENTINEL /**< Max command value */ }; +/** + * @brief Transparent Reader Support (TRS) commands a CP application can issue to + * a smart card in the reader. Set in @c struct osdp_trs_cmd::command and + * submitted as an @c OSDP_CMD_XWR command. + * + * A card session is the band of commands between an @c OSDP_TRS_CMD_START and + * the matching @c OSDP_TRS_CMD_STOP, in submission order: + * + * START, SEND_APDU, SEND_APDU, ..., STOP + * + * The library owns the wire handshakes the band implies (it negotiates + * transparent mode on START, and disconnects the card and restores the reader + * on STOP) and reports the session's progress as @c OSDP_NOTIFICATION_TRS_STATUS + * events. The band is enforced when a command is submitted, so misuse is an + * error from osdp_cp_submit_command() and never a command that runs and then + * has to be taken back: inside a band only TRS commands are accepted (anything + * else would interrupt the card transaction), and a card command is refused + * outside one (it would reach a reader that is not in transparent mode). + * + * To abandon a session and the APDUs still queued for it, flush the command + * queue (osdp_cp_flush_commands()) and then submit @c OSDP_TRS_CMD_STOP; the + * flushed APDUs are reported to the app individually. If the session ends on its + * own before its STOP is reached -- the reader rejects transparent mode, say -- + * the APDUs left in the band are completed @c OSDP_COMPLETION_FLUSHED and an + * @c OSDP_TRS_SESSION_FAILED notification says why. + */ +enum osdp_trs_cmd_e { + OSDP_TRS_CMD_START = 1, /**< Open a card session */ + OSDP_TRS_CMD_STOP, /**< Close the card session opened by START */ + OSDP_TRS_CMD_SEND_APDU, /**< Send a C-APDU to the card */ + OSDP_TRS_CMD_ENTER_PIN, /**< EMV PIN entry */ + OSDP_TRS_CMD_CARD_SCAN, /**< Scan for a card in the field */ +}; + +/** + * @brief Life-cycle of a TRS card session, reported in the `trs_status` + * payload of an @c OSDP_NOTIFICATION_TRS_STATUS notification. + */ +enum osdp_trs_session_status_e { + OSDP_TRS_SESSION_OPENED = 0, /**< Reader is in transparent mode; APDUs flow */ + OSDP_TRS_SESSION_CLOSED, /**< STOP honoured; reader restored */ + OSDP_TRS_SESSION_FAILED, /**< PD refused transparent mode, or the + * session was aborted by a link error */ +}; + +/** @brief Max APDU length carried in a TRS command or reply */ +#define OSDP_TRS_APDU_MAX_LEN 64 +/** @brief Max CSN length carried in a TRS card-info reply */ +#define OSDP_TRS_CSN_MAX_LEN 32 +/** @brief Max protocol-data length carried in a TRS card-info reply */ +#define OSDP_TRS_PROTOCOL_DATA_MAX_LEN 64 + +struct osdp_trs_apdu { + uint16_t length; /**< APDU length in bytes */ + uint8_t data[OSDP_TRS_APDU_MAX_LEN]; /**< APDU bytes */ +}; + +/** @brief Encoding of the PIN digits the reader inserts into the C-APDU */ +enum osdp_trs_pin_format_e { + OSDP_TRS_PIN_FORMAT_BINARY = 1, /**< PIN digits as binary values */ + OSDP_TRS_PIN_FORMAT_BCD, /**< PIN digits as packed BCD */ + OSDP_TRS_PIN_FORMAT_ASCII, /**< PIN digits as ASCII characters */ +}; + +/** + * @brief Conditions that end PIN entry; OR them into + * @c osdp_trs_pin_entry::complete_on. + */ +enum osdp_trs_pin_complete_e { + OSDP_TRS_PIN_COMPLETE_ON_MAX_DIGITS = 1 << 0, /**< max_digits entered */ + OSDP_TRS_PIN_COMPLETE_ON_KEY = 1 << 1, /**< Validation (enter) key pressed */ + OSDP_TRS_PIN_COMPLETE_ON_TIMEOUT = 1 << 2, /**< Entry timed out */ +}; + +/** + * @brief TRS secure PIN entry request: the reader prompts the user for their + * PIN, inserts it into @a apdu as described by the layout fields below, and + * sends the result to the card. + * + * APDU positions are expressed in bits from the start of the APDU payload. + * Not every position is expressible on the wire: it must be byte-aligned (up + * to 120 bits) or fall within the first 15 bits; anything else fails the + * command submission. + */ +struct osdp_trs_pin_entry { + uint8_t timeout_initial; /**< First-digit timeout in seconds (0 = reader default) */ + uint8_t timeout_digit; /**< Per-digit timeout in seconds after the first key */ + + /** + * The PIN block: the region of the C-APDU where the reader formats + * and inserts the entered PIN. + */ + struct { + enum osdp_trs_pin_format_e format; /**< PIN digit encoding */ + bool right_justify; /**< Right-justify the PIN within the block + * (default: left-justified) */ + uint16_t offset_bits; /**< Block position in the APDU payload, in bits */ + uint8_t size_bytes; /**< Block size in bytes, after justification + * and formatting, as defined by the card + * scheme (8 for EMV/ISO 9564 PIN blocks) */ + } pin_block; + + /* + * Optional slot in the C-APDU where the reader records how many PIN + * digits the user entered; the app cannot pre-fill it because only + * the reader knows the entered length. + */ + struct { + uint8_t size_bits; /**< Slot size in bits (0 = APDU has no such slot) */ + uint16_t offset_bits; /**< Slot position in the APDU payload, in bits */ + } pin_length_field; + + uint8_t min_digits; /**< Minimum PIN length, in digits */ + uint8_t max_digits; /**< Maximum PIN length, in digits */ + uint32_t complete_on; /**< When PIN entry ends: OR of + * enum osdp_trs_pin_complete_e conditions */ + + uint8_t num_messages; /**< Number of display messages */ + uint16_t language_id; /**< Display language identifier */ + uint8_t msg_index; /**< Index of the message to display */ + uint8_t teo_prologue[3]; /**< T=1 protocol prologue */ + struct osdp_trs_apdu apdu; /**< C-APDU to send after PIN entry */ +}; + +struct osdp_trs_cmd { + enum osdp_trs_cmd_e command; /**< Which TRS command; selects the union */ + union { + struct osdp_trs_apdu apdu; /**< For OSDP_TRS_CMD_SEND_APDU */ + struct osdp_trs_pin_entry pin_entry; + }; +}; + +/** @brief Smart-card communication protocol reported in a TRS card-info reply */ +enum osdp_trs_card_protocol_e { + OSDP_TRS_CARD_PROTOCOL_CONTACT = 1, /**< ISO 7816 contact (T=0/T=1) */ + OSDP_TRS_CARD_PROTOCOL_CONTACTLESS, /**< ISO 14443 A/B contactless */ +}; + +struct osdp_trs_card_info { + uint8_t reader; /**< Reader number (0 = first, 1 = second) */ + enum osdp_trs_card_protocol_e protocol; /**< Card communication protocol */ + uint8_t csn_len; /**< Length of @a csn in bytes */ + uint8_t csn[OSDP_TRS_CSN_MAX_LEN]; /**< Card serial number */ + uint8_t protocol_data_len; /**< Length of @a protocol_data in bytes */ + /** ATR (contact) or ATS/ATQB (contactless) */ + uint8_t protocol_data[OSDP_TRS_PROTOCOL_DATA_MAX_LEN]; +}; + +/** @brief Smart-card presence (and interface) reported in a TRS card-present reply */ +enum osdp_trs_card_status_e { + OSDP_TRS_CARD_NOT_PRESENT = 1, /**< No card detected */ + OSDP_TRS_CARD_PRESENT, /**< Card present; interface not specified */ + OSDP_TRS_CARD_PRESENT_CONTACTLESS, /**< Card present on the contactless (ISO 14443) interface */ + OSDP_TRS_CARD_PRESENT_CONTACT, /**< Card present on the contact (ISO 7816) interface */ +}; + +struct osdp_trs_card_present { + uint8_t reader; /**< Reader number (0 = first, 1 = second) */ + enum osdp_trs_card_status_e status; /**< Smart-card presence status */ +}; + +struct osdp_trs_card_data { + uint8_t reader; /**< Reader number (0 = first, 1 = second) */ + uint8_t status; /**< Result of the APDU exchange as reported by the reader + * (reader-defined; not standardized by OSDP) */ + struct osdp_trs_apdu apdu; /**< R-APDU returned by the card */ +}; + +struct osdp_trs_pin_complete { + uint8_t reader; /**< Reader number (0 = first, 1 = second) */ + uint8_t status; /**< Result of the secure PIN entry sequence as reported by the + * reader (reader-defined; not standardized by OSDP) */ + uint8_t tries; /**< Number of PIN-entry attempts */ +}; + +struct osdp_trs_error { + uint8_t code; /**< Error/NAK condition from the reader or card + * (reader-defined; not standardized by OSDP) */ +}; + +/** + * @brief Transparent Reader Support (TRS) replies delivered to a CP application + * as an @c OSDP_EVENT_TRS event, or submitted by a PD application (via + * @c osdp_pd_submit_event) in answer to a TRS command. The @a reply field + * selects the active union member. + */ +enum osdp_trs_reply_e { + OSDP_TRS_REPLY_CARD_INFO = 1, /**< A card entered the field (CSN, protocol) */ + OSDP_TRS_REPLY_CARD_PRESENT, /**< Card-present status for a reader */ + OSDP_TRS_REPLY_CARD_DATA, /**< R-APDU returned by the card */ + OSDP_TRS_REPLY_PIN_COMPLETE, /**< PIN entry completed */ + OSDP_TRS_REPLY_ERROR, /**< Transparent-mode error / NAK from reader */ +}; + +struct osdp_trs_reply { + enum osdp_trs_reply_e reply; /**< Which TRS reply; selects the union */ + union { + struct osdp_trs_card_info card_info; + struct osdp_trs_card_present card_present; + struct osdp_trs_card_data card_data; + struct osdp_trs_pin_complete pin_complete; + struct osdp_trs_error error; + }; +}; + /** * @brief Payload for OSDP_NOTIFICATION_COMMAND. */ @@ -1205,6 +1420,13 @@ struct osdp_notification_pd_status { bool online; /**< Peer link is online/reachable */ }; +/** + * @brief Payload for OSDP_NOTIFICATION_TRS_STATUS. + */ +struct osdp_notification_trs_status { + enum osdp_trs_session_status_e status; /**< Session life-cycle state */ +}; + /** * @brief LibOSDP notification payload. * @@ -1221,6 +1443,7 @@ struct osdp_notification { struct osdp_notification_pd_status pd_status; /**< PD_STATUS */ struct osdp_mp_notification mp; /**< MP_* */ struct osdp_pd_id pd_id; /**< PD_ID */ + struct osdp_notification_trs_status trs_status; /**< TRS_STATUS */ }; }; @@ -1270,6 +1493,7 @@ struct osdp_cmd { struct osdp_cmd_tdset tdset; /**< Time and date set command structure */ struct osdp_cmd_pivdata pivdata; /**< PIV data retrieval command structure */ struct osdp_cmd_auth auth; /**< GENAUTH/CRAUTH command structure */ + struct osdp_trs_cmd trs; /**< Transparent mode command structure */ }; }; @@ -1523,6 +1747,7 @@ enum osdp_event_type { OSDP_EVENT_PIVDATAR, /**< PIV data reply event */ OSDP_EVENT_GENAUTHR, /**< General authenticate reply event */ OSDP_EVENT_CRAUTHR, /**< Challenge/response authenticate reply event */ + OSDP_EVENT_TRS, /**< Transparent mode response event */ OSDP_EVENT_SENTINEL /**< Max event value */ }; @@ -1545,6 +1770,7 @@ struct osdp_event { struct osdp_event_piv_reply piv_reply; /**< Smartcard/PIV reply event structure */ struct osdp_status_report status; /**< Status report event structure */ struct osdp_notification notif; /**< LibOSDP notification (CP mode) */ + struct osdp_trs_reply trs; /**< Transparent mode reply event structure */ }; }; diff --git a/library.json b/library.json index b4b2d818..72f42406 100644 --- a/library.json +++ b/library.json @@ -14,6 +14,7 @@ "srcFilter": [ "+<**/*.c>", "-", + "-", "-", "-", "+<../utils/src/disjoint_set.c>", diff --git a/python/setup.py b/python/setup.py index 30aad162..a4e535ef 100644 --- a/python/setup.py +++ b/python/setup.py @@ -185,6 +185,7 @@ def try_vendor_sources(src_dir, src_files, vendor_dir): "src/osdp_piv.h", "src/osdp_bio.h", "src/osdp_metrics.h", + "src/osdp_trs.h", "src/crypto/tinyaes_src.h", ] @@ -209,6 +210,9 @@ def try_vendor_sources(src_dir, src_files, vendor_dir): "src/osdp_diag.h", "utils/include/utils/pcap_gen.h", "utils/src/pcap_gen.c", + + # Optional when TRS is enabled + "src/osdp_trs.c", ] # LICENSE lives at the repo root; vendor a copy so wheel/sdist builds @@ -217,6 +221,8 @@ def try_vendor_sources(src_dir, src_files, vendor_dir): definitions = [ "OPT_OSDP_PACKET_TRACE", + # osdp_sys exposes no TRS bindings yet; keep TRS out of the extension. + # "OPT_BUILD_OSDP_TRS", # "OPT_OSDP_DATA_TRACE", # "OPT_OSDP_SKIP_MARK_BYTE", ] @@ -236,6 +242,11 @@ def try_vendor_sources(src_dir, src_files, vendor_dir): "utils/src/pcap_gen.c", ] +if "OPT_BUILD_OSDP_TRS" in definitions: + source_files += [ + "src/osdp_trs.c", + ] + source_files = add_prefix_to_path(source_files, "vendor") include_dirs = [ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9c7716be..5d51376d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,10 @@ if (OPT_OSDP_STATIC) list(APPEND LIB_OSDP_DEFINITIONS "-DOPT_OSDP_STATIC=1") endif() +if (OPT_BUILD_OSDP_TRS) + list(APPEND LIB_OSDP_DEFINITIONS "-DOPT_BUILD_OSDP_TRS") +endif() + # Crypto backend selection driven by OPT_OSDP_CRYPTO_BACKEND: # auto - probe openssl, then mbedtls, else fall back to bundled tinyaes # openssl - require OpenSSL (hard-fail if missing) @@ -96,6 +100,12 @@ if (OPT_OSDP_PACKET_TRACE OR OPT_OSDP_DATA_TRACE) ) endif() +if (OPT_BUILD_OSDP_TRS) + list(APPEND LIB_OSDP_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/osdp_trs.c + ) +endif() + list(APPEND LIB_OSDP_INCLUDE_DIRS ${PROJECT_BINARY_DIR}/include ) diff --git a/src/osdp_common.h b/src/osdp_common.h index 8617664f..b98ccd7a 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -367,6 +367,8 @@ static inline __noreturn void die() #define PD_FLAG_PKT_BROADCAST BIT(13) /* this packet was addressed to 0x7F */ #define PD_FLAG_CP_USE_CRC BIT(14) /* CP uses CRC-16 instead of checksum */ #define PD_FLAG_ONLINE BIT(15) /* PD mode: CP link is active */ +#define PD_FLAG_TRS_CAPABLE BIT(16) /* PD reported a smart card reader */ +#define PD_FLAG_TRS_ACTIVE BIT(17) /* a TRS card session is in progress */ /* PD Init flags */ #define PD_FLAG_ENFORCE_SECURE BIT(24) /* See: OSDP_FLAG_ENFORCE_SECURE */ @@ -385,6 +387,17 @@ static inline __noreturn void die() #define CP_REQ_OFFLINE 0x00000004 #define CP_REQ_DISABLE 0x00000008 #define CP_REQ_ENABLE 0x00000010 +#ifdef OPT_BUILD_OSDP_TRS +/* Open a TRS session (a START was dequeued). Posted by the online command + * selector and consumed like every other transition request in + * state_update(). */ +#define CP_REQ_TRS_OPEN 0x00000020 +#endif + +#define TRS_MODE_00 0x00 +#define TRS_MODE_01 0x01 + +#define TRS_DISABLE_CARD_INFO_REPORT 0x00 enum osdp_cp_phy_state_e { OSDP_CP_PHY_STATE_IDLE, @@ -406,9 +419,41 @@ enum osdp_cp_state_e { OSDP_CP_STATE_ONLINE, OSDP_CP_STATE_OFFLINE, OSDP_CP_STATE_DISABLED, + OSDP_CP_STATE_TRS_SETUP, + OSDP_CP_STATE_TRS_RUN, OSDP_CP_STATE_SENTINEL }; +#ifdef OPT_BUILD_OSDP_TRS +enum trs_state_e { + TRS_STATE_SET_MODE, /* CP negotiating TRS mode 01 with PD */ + TRS_STATE_XMIT, /* session up; draining app APDU commands */ + TRS_STATE_DISCONNECT_CARD, /* terminating the current card session */ + TRS_STATE_TEARDOWN, /* CP restoring TRS mode 00 (transparent off) */ + TRS_STATE_DONE, /* session finished; return to ONLINE */ +}; + +struct osdp_trs { + enum trs_state_e state; /* current TRS session sub-state */ + uint8_t mode; /* negotiated TRS mode (TRS_MODE_00/01) */ + bool failed; /* session did not open, or was cut short by an error */ + /* + * Tracks the tail of cmd_queue, not the session: set when a START is + * queued and cleared when its STOP is. A command is inside the band iff + * this is set when it is submitted; pd->state cannot answer that, as it + * trails the tail by everything still queued behind it. + */ + bool band_open; + bool stop_pending; /* this session's STOP has not been dequeued yet */ + /* + * The last REPLY_XRD was an error report: the reader took the command + * but the card transaction failed. Written on every REPLY_XRD, and read + * only for the CMD_XWR it answered. + */ + bool reply_error; +}; +#endif + enum osdp_pkt_errors_e { OSDP_ERR_PKT_NONE = 0, /** @@ -566,6 +611,9 @@ struct osdp_pd { struct osdp_piv *piv; /* Smartcard/PIV multipart context */ struct osdp_bio *bio; /* Biometric read multipart context */ struct osdp_metrics metrics; /* link/protocol health counters */ +#ifdef OPT_BUILD_OSDP_TRS + struct osdp_trs trs; /* TRS mode context (embedded) */ +#endif /* PD command callback to app with opaque arg pointer as passed by app */ void *command_callback_arg; @@ -601,6 +649,12 @@ struct osdp { void osdp_keyset_complete(struct osdp_pd *pd); +#ifdef OPT_BUILD_OSDP_TRS +/* --- from osdp_trs.c --- */ +enum osdp_cp_state_e osdp_trs_state_update(struct osdp_pd *pd); +enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd); +#endif + /* --- from osdp_phy.c --- */ int osdp_phy_packet_init(struct osdp_pd *p, uint8_t *buf, int max_len); int osdp_phy_check_packet(struct osdp_pd *pd); @@ -862,15 +916,49 @@ static inline void make_request(struct osdp_pd *pd, uint32_t req) { pd->request |= req; } +static inline bool trs_capable(struct osdp_pd *pd) +{ + return ISSET_FLAG(pd, PD_FLAG_TRS_CAPABLE); +} + +static inline bool trs_active(struct osdp_pd *pd) +{ + return ISSET_FLAG(pd, PD_FLAG_TRS_ACTIVE); +} + +static inline void trs_set_reply_error(struct osdp_pd *pd, bool error) +{ +#ifdef OPT_BUILD_OSDP_TRS + pd->trs.reply_error = error; +#else + ARG_UNUSED(pd); + ARG_UNUSED(error); +#endif +} + +static inline bool trs_reply_error(struct osdp_pd *pd) +{ +#ifdef OPT_BUILD_OSDP_TRS + return pd->trs.reply_error; +#else + ARG_UNUSED(pd); + return false; +#endif +} /* - * "Is this PD up and serving the app?" -- use this over a bare - * `state == ONLINE` test wherever that is the real question, so that modes - * layered on top of an established link (e.g. a transparent-reader session) - * can extend it without touching every call site. + * A TRS session runs on top of an established link: the PD is still online, + * just busy with a card. Use this over a bare `state == ONLINE` test wherever + * the question is "is this PD up and serving the app?". */ static inline bool cp_is_online(struct osdp_pd *pd) { +#ifdef OPT_BUILD_OSDP_TRS + if (pd->state == OSDP_CP_STATE_TRS_SETUP || + pd->state == OSDP_CP_STATE_TRS_RUN) { + return true; + } +#endif return pd->state == OSDP_CP_STATE_ONLINE; } diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 07b77574..c4901c6b 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -12,6 +12,7 @@ #include "osdp_bio.h" #include "osdp_diag.h" #include "osdp_metrics.h" +#include "osdp_trs.h" enum osdp_cp_error_e { OSDP_CP_ERR_NONE = 0, @@ -67,6 +68,14 @@ static int cp_cmd_dequeue(struct osdp_pd *pd, const struct osdp_cmd **cmd) return 0; } +#ifdef OPT_BUILD_OSDP_TRS +/* TRS session markers are consumed by the library; they never go on the wire */ +static bool cp_cmd_is_trs(const struct osdp_cmd *cmd, enum osdp_trs_cmd_e which) +{ + return cmd->id == OSDP_CMD_XWR && cmd->trs.command == which; +} +#endif + static inline void cp_complete_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd, enum osdp_completion_status status) @@ -322,6 +331,13 @@ static int cp_build_command(struct osdp_pd *pd, const struct osdp_cmd *cmd, } len += ret; break; + case CMD_XWR: + ret = osdp_trs_cmd_build(pd, cmd, buf + len, max_len - len); + if (ret <= 0) { + return OSDP_CP_ERR_GENERIC; + } + len += ret; + break; case CMD_KEYSET: if (!sc_is_active(pd)) { LOG_ERR("Cannot perform KEYSET without SC!"); @@ -553,6 +569,15 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) CLEAR_FLAG(pd, PD_FLAG_CP_USE_CRC); } } + + /* Check for Transparent Reader Support */ + t = OSDP_PD_CAP_SMART_CARD_SUPPORT; + if (pd->cap[t].compliance_level & 0x01) { + SET_FLAG(pd, PD_FLAG_TRS_CAPABLE); + } else { + CLEAR_FLAG(pd, PD_FLAG_TRS_CAPABLE); + } + ret = OSDP_CP_ERR_NONE; break; case REPLY_OSTATR: @@ -763,6 +788,22 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) case REPLY_FTSTAT: ret = osdp_file_cmd_stat_decode(pd, buf + pos, len); break; + case REPLY_XRD: + if (!trs_active(pd)) { + LOG_WRN("Unsolicited REPLY_XRD outside a TRS session"); + return OSDP_CP_ERR_UNKNOWN; + } + ret = osdp_trs_reply_decode(pd, buf + pos, len, &event); + if (ret < 0) { + break; + } + trs_set_reply_error(pd, + ret == OSDP_TRS_REPLY_ACTION_DISPATCH_ERROR); + if (ret != OSDP_TRS_REPLY_ACTION_NONE) { + cp_dispatch_event(pd, &event); + } + ret = OSDP_CP_ERR_NONE; + break; case REPLY_CCRYPT: if (sc_is_active(pd) || pd->cmd_id != CMD_CHLNG) { LOG_EM("Out of order REPLY_CCRYPT; has PD gone rogue?"); @@ -928,6 +969,8 @@ static int cp_translate_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd) return CMD_BIOREAD; case OSDP_CMD_BIOMATCH: return CMD_BIOMATCH; + case OSDP_CMD_XWR: + return CMD_XWR; case OSDP_CMD_STATUS: switch (cmd->status.type) { case OSDP_STATUS_REPORT_INPUT: @@ -1157,6 +1200,10 @@ static const char *state_get_name(enum osdp_cp_state_e state) return "Offline"; case OSDP_CP_STATE_DISABLED: return "Disabled"; + case OSDP_CP_STATE_TRS_SETUP: + return "TRS-Setup"; + case OSDP_CP_STATE_TRS_RUN: + return "TRS-Run"; default: BUG(); } @@ -1184,6 +1231,40 @@ static int cp_setup_active_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd) return ret; } +#ifdef OPT_BUILD_OSDP_TRS +/* Consume a command the library handles itself; nothing goes on the wire */ +static void cp_discard_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd, + enum osdp_completion_status status) +{ + cp_complete_cmd(pd, cmd, status); + cp_cmd_free(pd, cmd); + pd->active_cmd = NULL; +} + +/* + * A session that died before reaching its STOP leaves the rest of its band + * queued behind it with no session left to run in. Drop it, up to and including + * the STOP that would have closed it, so that whatever the app queued after the + * band runs as it would have. + */ +static void cp_flush_trs_band(struct osdp_pd *pd) +{ + const struct osdp_cmd *cmd; + bool stop_seen = false; + + while (!stop_seen && cp_cmd_dequeue(pd, &cmd) == 0) { + stop_seen = cp_cmd_is_trs(cmd, OSDP_TRS_CMD_STOP); + cp_complete_cmd(pd, cmd, OSDP_COMPLETION_FLUSHED); + cp_cmd_free(pd, cmd); + } + if (!stop_seen) { + /* Ran to the end of the queue: the STOP was never submitted, so + * the tail is inside the band we just dropped. */ + pd->trs.band_open = false; + } +} +#endif + /* Poll only as often as OSDP_PD_POLL_TIMEOUT_MS allows; -1 means "not yet" */ static int cp_get_poll_command(struct osdp_pd *pd) { @@ -1216,6 +1297,15 @@ static int cp_get_online_command(struct osdp_pd *pd) int ret; if (cp_cmd_dequeue(pd, &cmd) == 0) { +#ifdef OPT_BUILD_OSDP_TRS + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_START)) { + /* Open a TRS session; the transition runs through the + * usual request path, nothing goes on the wire now. */ + cp_discard_cmd(pd, cmd, OSDP_COMPLETION_OK); + make_request(pd, CP_REQ_TRS_OPEN); + return -1; + } +#endif return cp_setup_active_cmd(pd, cmd); } @@ -1237,6 +1327,44 @@ static int cp_get_online_command(struct osdp_pd *pd) return cp_get_poll_command(pd); } +#ifdef OPT_BUILD_OSDP_TRS +/* + * Command selector for a TRS session (the band of commands between a START and + * its STOP). Mode negotiation and teardown are library-generated straight from + * pd->trs.state (active_cmd == NULL, which is how osdp_trs_cmd_build() knows to + * serialize a session step instead of a card command); in XMIT the band's card + * commands are drained in order, and CMD_POLL keeps the link warm between them. + * + * Everything the queue can hand us here is a card command or the STOP that ends + * the band; cp_band_admit() turned away anything else when it was submitted. + */ +static int cp_get_trs_command(struct osdp_pd *pd) +{ + const struct osdp_cmd *cmd; + + switch (pd->trs.state) { + case TRS_STATE_XMIT: + if (cp_cmd_dequeue(pd, &cmd)) { + return cp_get_poll_command(pd); + } + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_STOP)) { + cp_discard_cmd(pd, cmd, OSDP_COMPLETION_OK); + pd->trs.stop_pending = false; + pd->trs.state = TRS_STATE_DISCONNECT_CARD; + return CMD_XWR; + } + return cp_setup_active_cmd(pd, cmd); + case TRS_STATE_SET_MODE: + case TRS_STATE_DISCONNECT_CARD: + case TRS_STATE_TEARDOWN: + pd->active_cmd = NULL; + return CMD_XWR; + default: + return cp_get_poll_command(pd); + } +} +#endif + static void notify_pd_status(struct osdp_pd *pd, bool is_online) { struct osdp *ctx = pd_to_osdp(pd); @@ -1269,6 +1397,25 @@ static void notify_pd_id(struct osdp_pd *pd) osdp_metrics_report(pd, OSDP_METRIC_EVENT); } +#ifdef OPT_BUILD_OSDP_TRS +static void notify_trs_status(struct osdp_pd *pd, + enum osdp_trs_session_status_e status) +{ + struct osdp *ctx = pd_to_osdp(pd); + struct osdp_event evt; + + if (!ctx->event_callback || !is_notifications_enabled(pd)) { + return; + } + + evt.type = OSDP_EVENT_NOTIFICATION; + evt.notif.type = OSDP_NOTIFICATION_TRS_STATUS; + evt.notif.trs_status.status = status; + ctx->event_callback(ctx->event_callback_arg, pd->idx, &evt); + osdp_metrics_report(pd, OSDP_METRIC_EVENT); +} +#endif + static void notify_sc_status(struct osdp_pd *pd) { struct osdp *ctx = pd_to_osdp(pd); @@ -1343,7 +1490,8 @@ static bool cp_check_online_response(struct osdp_pd *pd) pd->reply_id == REPLY_GENAUTHR || pd->reply_id == REPLY_CRAUTHR || pd->reply_id == REPLY_RAW || - pd->reply_id == REPLY_KEYPAD) { + pd->reply_id == REPLY_KEYPAD || + (pd->reply_id == REPLY_XRD && trs_active(pd))) { return true; } return is_ignore_unsolicited_messages(pd); @@ -1393,6 +1541,11 @@ static bool cp_check_online_response(struct osdp_pd *pd) return pd->reply_id == REPLY_OSTATR; case CMD_RSTAT: return pd->reply_id == REPLY_RSTATR; + case CMD_XWR: + /* An XRD error report is a valid reply, but it says the card + * transaction failed; see the soft-failure handling in + * state_update(). */ + return pd->reply_id == REPLY_XRD && !trs_reply_error(pd); default: LOG_ERR("Unexpected respose: CMD: %s(%02x) REPLY: %s(%02x)", osdp_cmd_name(pd->cmd_id), pd->cmd_id, @@ -1418,6 +1571,11 @@ static inline int state_get_cmd(struct osdp_pd *pd) return CMD_KEYSET; case OSDP_CP_STATE_ONLINE: return cp_get_online_command(pd); +#ifdef OPT_BUILD_OSDP_TRS + case OSDP_CP_STATE_TRS_SETUP: + case OSDP_CP_STATE_TRS_RUN: + return cp_get_trs_command(pd); +#endif default: return -1; } @@ -1440,6 +1598,11 @@ static inline bool state_check_reply(struct osdp_pd *pd) return pd->reply_id == REPLY_ACK; case OSDP_CP_STATE_ONLINE: return cp_check_online_response(pd); +#ifdef OPT_BUILD_OSDP_TRS + case OSDP_CP_STATE_TRS_SETUP: + case OSDP_CP_STATE_TRS_RUN: + return cp_check_online_response(pd); +#endif default: return false; } @@ -1629,6 +1792,23 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) osdp_phy_state_reset(pd, true); break; case OSDP_CP_STATE_ONLINE: + CLEAR_FLAG(pd, PD_FLAG_TRS_ACTIVE); +#ifdef OPT_BUILD_OSDP_TRS + /* End of a TRS session: the PD never left online, so report the + * session outcome instead of a spurious online transition. */ + if (cur == OSDP_CP_STATE_TRS_SETUP || + cur == OSDP_CP_STATE_TRS_RUN) { + LOG_INF("TRS session %s", + pd->trs.failed ? "failed" : "closed"); + if (pd->trs.stop_pending) { + cp_flush_trs_band(pd); + } + notify_trs_status(pd, pd->trs.failed ? + OSDP_TRS_SESSION_FAILED : + OSDP_TRS_SESSION_CLOSED); + break; + } +#endif LOG_INF("Online; %s SC", sc_is_active(pd) ? "With" : "Without"); notify_pd_status(pd, true); break; @@ -1660,6 +1840,21 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) osdp_phy_state_reset(pd, true); LOG_INF("PD disabled; going offline until re-enabled"); break; +#ifdef OPT_BUILD_OSDP_TRS + case OSDP_CP_STATE_TRS_SETUP: + pd->trs.state = TRS_STATE_SET_MODE; + pd->trs.mode = TRS_MODE_00; /* until the PD accepts mode 01 */ + pd->trs.failed = false; + pd->trs.stop_pending = true; + SET_FLAG(pd, PD_FLAG_TRS_ACTIVE); + break; + case OSDP_CP_STATE_TRS_RUN: + if (cur == OSDP_CP_STATE_TRS_SETUP) { + LOG_INF("TRS session opened"); + notify_trs_status(pd, OSDP_TRS_SESSION_OPENED); + } + break; +#endif default: break; } @@ -1671,9 +1866,28 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) pd->state = next; } +/* + * CMD_XWR carries two kinds of traffic: the mode-set, card-terminate and + * mode-restore exchanges the library sequences on its own, and the card + * commands the app queued into the session's band. Only the latter are drained + * in TRS_STATE_XMIT, and only they are the app's to fail: if the PD refuses to + * enter or leave transparent mode, the session cannot run at all. + */ +static bool cp_trs_cmd_is_app_owned(struct osdp_pd *pd) +{ +#ifdef OPT_BUILD_OSDP_TRS + return pd->trs.state == TRS_STATE_XMIT; +#else + ARG_UNUSED(pd); + return false; +#endif +} + static bool cp_cmd_is_app_owned(struct osdp_pd *pd) { switch (pd->cmd_id) { + case CMD_XWR: + return cp_trs_cmd_is_app_owned(pd); case CMD_OUT: case CMD_LED: case CMD_BUZ: @@ -1720,7 +1934,10 @@ static bool cp_nak_code_is_app_level(uint8_t nak_code) /** * A command can fail without the link being at fault: the PD received the frame * and declined to act on it. Such failures are reported to the app and the PD - * stays online. This can only happen for a command the app asked for, and only + * stays online -- and, inside a TRS session, the card session stays open, so a + * refused APDU costs the app that one transaction and no more. + * + * This can only happen for a command the app asked for, and only * when the PD answered us; a timeout or a malformed reply is always a hard * failure, as is an unexpected (if well formed) reply, which means the PD has * lost track of the exchange. @@ -1745,6 +1962,13 @@ static bool cp_cmd_failure_is_soft(struct osdp_pd *pd) return true; } + /* Likewise for a card command the reader answered with an error report: + * that transaction failed, but the card session is still up. */ + if (pd->cmd_id == CMD_XWR && pd->reply_id == REPLY_XRD && + trs_reply_error(pd)) { + return true; + } + return pd->reply_id == REPLY_NAK && cp_nak_code_is_app_level(pd->nak_code); } @@ -1819,6 +2043,15 @@ static void notify_command_status(struct osdp_pd *pd, int status) app_cmd = (pd->cmd_id == CMD_BIOREAD) ? OSDP_CMD_BIOREAD : OSDP_CMD_BIOMATCH; break; + case CMD_XWR: + if (status) { + /* The card's answer reaches the app as an OSDP_EVENT_TRS + * of its own, now or on a later poll if the PD deferred + * it; only the failure needs a notification. */ + return; + } + app_cmd = OSDP_CMD_XWR; + break; default: return; } @@ -1888,6 +2121,15 @@ static void state_update(struct osdp_pd *pd) * pick (and any pending session-scoped request): reconnect * from scratch via the OFFLINE dwell. */ next = OSDP_CP_STATE_OFFLINE; +#ifdef OPT_BUILD_OSDP_TRS + } else if (cur == OSDP_CP_STATE_TRS_SETUP || + cur == OSDP_CP_STATE_TRS_RUN) { + /* TRS runs a nested sub-FSM; advancing it mutates pd->trs, so it + * stays out of the pure protocol tables and runs here, once per + * pass, right where the next protocol state is decided. */ + next = (err == OSDP_CP_ERR_NONE) ? osdp_trs_state_update(pd) + : osdp_trs_state_update_err(pd); +#endif } else { next = get_next_state(pd, err); cp_transition_effects(pd, cur, next, err); @@ -1902,6 +2144,12 @@ static void state_update(struct osdp_pd *pd) LOG_INF("Going offline due to request"); next = OSDP_CP_STATE_OFFLINE; } +#ifdef OPT_BUILD_OSDP_TRS + /* A START was dequeued: open a TRS session. */ + if (check_request(pd, CP_REQ_TRS_OPEN)) { + next = OSDP_CP_STATE_TRS_SETUP; + } +#endif } } @@ -1924,6 +2172,48 @@ static void state_update(struct osdp_pd *pd) } } +#ifdef OPT_BUILD_OSDP_TRS +/* + * Decide whether a command may join the queue, and move the band with it. The + * band boundary is a position in the queue, so it is decided here -- at the tail + * the command is about to be appended to -- and never at dequeue: by the time a + * command is dequeued, the app has long since been told it was accepted. + */ +static int cp_band_admit(struct osdp_pd *pd, const struct osdp_cmd *cmd) +{ + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_START)) { + if (pd->trs.band_open) { + LOG_ERR("A TRS session is already open; STOP it first"); + return -1; + } + pd->trs.band_open = true; + return 0; + } + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_STOP)) { + if (!pd->trs.band_open) { + LOG_ERR("TRS stop without a session to close"); + return -1; + } + pd->trs.band_open = false; + return 0; + } + if (cmd->id == OSDP_CMD_XWR && !pd->trs.band_open) { + LOG_ERR("TRS command outside a session; queue a START first"); + return -1; + } + if (cmd->id != OSDP_CMD_XWR && pd->trs.band_open) { + /* + * Sending it would interrupt the card transaction; queuing it + * would stall the APDUs behind it. Neither is ours to choose. + */ + LOG_ERR("Cannot queue %s inside a TRS session", + osdp_cmd_name(cp_translate_cmd(pd, cmd))); + return -1; + } + return 0; +} +#endif + static int cp_submit_command(struct osdp_pd *pd, const struct osdp_cmd *cmd) { const uint32_t all_flags = ( @@ -1980,6 +2270,24 @@ static int cp_submit_command(struct osdp_pd *pd, const struct osdp_cmd *cmd) return -1; } + if (cmd->id == OSDP_CMD_XWR) { +#ifdef OPT_BUILD_OSDP_TRS + if (!trs_capable(pd)) { + LOG_ERR("PD has no smart-card support; dropping XWR"); + return -1; + } +#else + LOG_ERR("TRS support not enabled in this build"); + return -1; +#endif + } + +#ifdef OPT_BUILD_OSDP_TRS + if (cp_band_admit(pd, cmd)) { + return -1; + } +#endif + return cp_cmd_enqueue(pd, cmd); } @@ -2306,6 +2614,15 @@ int osdp_cp_flush_commands(osdp_t *ctx, int pd_idx) cp_cmd_free(pd, cmd); count++; } +#ifdef OPT_BUILD_OSDP_TRS + /* + * The band markers went out with the queue, so the tail is now wherever + * the state machine is: still inside a session that only a STOP can + * close, or outside one. This is what makes flush-then-STOP an abort. + */ + pd->trs.band_open = ISSET_FLAG(pd, PD_FLAG_TRS_ACTIVE) && + pd->trs.stop_pending; +#endif return count; } diff --git a/src/osdp_pd.c b/src/osdp_pd.c index 82e0f051..f4385d87 100644 --- a/src/osdp_pd.c +++ b/src/osdp_pd.c @@ -10,6 +10,7 @@ #include "osdp_bio.h" #include "osdp_diag.h" #include "osdp_metrics.h" +#include "osdp_trs.h" #ifndef OPT_OSDP_STATIC #include @@ -194,6 +195,12 @@ static int pd_translate_event(struct osdp_pd *pd, case OSDP_EVENT_CRAUTHR: reply_code = REPLY_CRAUTHR; break; + case OSDP_EVENT_TRS: + /* Deferred TRS reply: a card-data/APDU response the app + * submitted after its CMD_XWR callback returned. Delivered as an + * unsolicited REPLY_XRD on this poll. */ + reply_code = REPLY_XRD; + break; default: LOG_ERR("Unknown event type %d", event->type); BUG(); @@ -1090,6 +1097,48 @@ static int pd_decode_command(struct osdp_pd *pd, uint8_t *buf, int len) ret = OSDP_PD_ERR_NONE; break; } + case CMD_XWR: { + const struct osdp_event *trs_event; + int trs_ret; + + trs_ret = osdp_trs_cmd_decode(pd, &cmd, buf + pos, len); + if (trs_ret < 0) { + break; /* NAK */ + } + if (trs_ret == OSDP_TRS_DECODE_ACK) { + /* library-handled command (mode set / card terminate): + * acknowledge per spec Table 36/42, no app payload */ + pd->reply_id = REPLY_ACK; + ret = OSDP_PD_ERR_NONE; + break; + } + if (trs_ret == OSDP_TRS_DECODE_MODE_REPORT) { + /* mode-read (Table 39): reply with the current mode */ + pd->reply_id = REPLY_XRD; + ret = OSDP_PD_ERR_NONE; + break; + } + /* OSDP_TRS_DECODE_TO_APP: mode-1 command; hand it to the app */ + if (!do_command_callback(pd, &cmd)) { + ret = OSDP_PD_ERR_REPLY; + break; + } + /* Only consume the app's TRS answer; an older non-TRS event at + * the queue head must ride out on a poll, not be eaten here. */ + if (pd_event_peek(pd, &trs_event) == 0 && + trs_event->type == OSDP_EVENT_TRS) { + /* app answered synchronously: reply now (fast card) */ + pd_event_dequeue(pd, &trs_event); + pd->active_event = trs_event; + pd->reply_id = REPLY_XRD; + } else { + /* app deferred: ACK ("working"). The R-APDU rides out as + * a REPLY_XRD on a later poll once the app submits it. */ + pd->reply_id = REPLY_ACK; + } + ret = OSDP_PD_ERR_NONE; + break; + } case CMD_KEYSET: if (len != CMD_KEYSET_DATA_LEN) { break; @@ -1456,6 +1505,14 @@ static int pd_build_reply(struct osdp_pd *pd, uint8_t *buf, int max_len) len += ret; ret = OSDP_PD_ERR_NONE; break; + case REPLY_XRD: + ret = osdp_trs_reply_build(pd, buf + len, max_len - len); + if (ret <= 0) { + break; + } + len += ret; + ret = OSDP_PD_ERR_NONE; + break; case REPLY_CCRYPT: if (smb == NULL) { break; diff --git a/src/osdp_trs.c b/src/osdp_trs.c new file mode 100644 index 00000000..3d0175c2 --- /dev/null +++ b/src/osdp_trs.c @@ -0,0 +1,837 @@ +/* + * Copyright (c) 2022 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file OSDP Transparent Reader Support (TRS) + * + * TRS tunnels raw smart-card APDUs from the CP, through a PD acting as a + * transparent pipe, to a contact/contactless card in the reader. It rides on + * the reserved OSDP CMD_XWR (0xA1) / REPLY_XRD (0xB1) command-reply pair. + * + * Every TRS payload begins with a 2-byte header: `mode` (0 = mode handshake, + * 1 = card session) followed by a per-mode command/reply `code`. Mode-0 + * commands (mode-get / mode-set) drive the session lifecycle and are handled + * internally by the library. Mode-1 commands (send-APDU, enter-PIN, card-scan, + * terminate) carry application payloads: + * + * CP --CMD_XWR(mode-set 01)--> PD (osdp_trs_cmd_build / _cmd_decode) + * CP <--REPLY_ACK-------------- PD (mode accepted) + * CP --CMD_XWR(send-apdu)-----> PD -> app callback + * CP <--REPLY_XRD(card-data)---- PD <- app osdp_pd_submit_event() + * ... CP delivers OSDP_EVENT_TRS to its app for each REPLY_XRD ... + */ + +#include "osdp_trs.h" + +#define TO_TRS(pd) (&(pd)->trs) + +/* Wire encoding of a transparent-mode command/reply: (mode << 8) | code */ +#define MODE_CODE(mode, pcmnd) (uint16_t)(((mode) & 0xff) << 8u | ((pcmnd) & 0xff)) + +#define CMD_MODE_GET MODE_CODE(0, 1) +#define CMD_MODE_SET MODE_CODE(0, 2) +#define CMD_SEND_APDU MODE_CODE(1, 1) +#define CMD_TERMINATE MODE_CODE(1, 2) +#define CMD_ENTER_PIN MODE_CODE(1, 3) +#define CMD_CARD_SCAN MODE_CODE(1, 4) + +#define REPLY_ERROR_MODE0 MODE_CODE(0, 0) +#define REPLY_CURRENT_MODE MODE_CODE(0, 1) +#define REPLY_CARD_INFO_REPORT MODE_CODE(0, 2) +#define REPLY_ERROR_MODE1 MODE_CODE(1, 0) +#define REPLY_CARD_PRESENT MODE_CODE(1, 1) +#define REPLY_CARD_DATA MODE_CODE(1, 2) +#define REPLY_PIN_ENTRY_COMPLETE MODE_CODE(1, 3) + +/* On-the-wire card-protocol codes (mapped to enum osdp_trs_card_protocol_e) */ +#define TRS_WIRE_PROTOCOL_CONTACT_T0T1 0x00 +#define TRS_WIRE_PROTOCOL_14443AB 0x01 + +/* On-the-wire card-present status codes (mapped to enum osdp_trs_card_status_e) */ +#define TRS_WIRE_CARD_NOT_PRESENT 0x00 +#define TRS_WIRE_CARD_PRESENT_UNSPECIFIED 0x01 +#define TRS_WIRE_CARD_PRESENT_CONTACTLESS 0x02 +#define TRS_WIRE_CARD_PRESENT_CONTACT 0x03 + +static int trs_card_status_from_wire(uint8_t wire, + enum osdp_trs_card_status_e *status) +{ + switch (wire) { + case TRS_WIRE_CARD_NOT_PRESENT: + *status = OSDP_TRS_CARD_NOT_PRESENT; + return 0; + case TRS_WIRE_CARD_PRESENT_UNSPECIFIED: + *status = OSDP_TRS_CARD_PRESENT; + return 0; + case TRS_WIRE_CARD_PRESENT_CONTACTLESS: + *status = OSDP_TRS_CARD_PRESENT_CONTACTLESS; + return 0; + case TRS_WIRE_CARD_PRESENT_CONTACT: + *status = OSDP_TRS_CARD_PRESENT_CONTACT; + return 0; + default: + return -1; + } +} + +static int trs_card_status_to_wire(enum osdp_trs_card_status_e status, + uint8_t *wire) +{ + switch (status) { + case OSDP_TRS_CARD_NOT_PRESENT: + *wire = TRS_WIRE_CARD_NOT_PRESENT; + return 0; + case OSDP_TRS_CARD_PRESENT: + *wire = TRS_WIRE_CARD_PRESENT_UNSPECIFIED; + return 0; + case OSDP_TRS_CARD_PRESENT_CONTACTLESS: + *wire = TRS_WIRE_CARD_PRESENT_CONTACTLESS; + return 0; + case OSDP_TRS_CARD_PRESENT_CONTACT: + *wire = TRS_WIRE_CARD_PRESENT_CONTACT; + return 0; + default: + return -1; + } +} + +/* On-the-wire PIN-format codes in bmFormatString bits 1-0 (mapped to + * enum osdp_trs_pin_format_e); 0x03 is reserved */ +#define TRS_WIRE_PIN_FORMAT_BINARY 0x00 +#define TRS_WIRE_PIN_FORMAT_BCD 0x01 +#define TRS_WIRE_PIN_FORMAT_ASCII 0x02 + +static int trs_pin_format_from_wire(uint8_t wire, + enum osdp_trs_pin_format_e *format) +{ + switch (wire) { + case TRS_WIRE_PIN_FORMAT_BINARY: + *format = OSDP_TRS_PIN_FORMAT_BINARY; + return 0; + case TRS_WIRE_PIN_FORMAT_BCD: + *format = OSDP_TRS_PIN_FORMAT_BCD; + return 0; + case TRS_WIRE_PIN_FORMAT_ASCII: + *format = OSDP_TRS_PIN_FORMAT_ASCII; + return 0; + default: + return -1; + } +} + +static int trs_pin_format_to_wire(enum osdp_trs_pin_format_e format, + uint8_t *wire) +{ + switch (format) { + case OSDP_TRS_PIN_FORMAT_BINARY: + *wire = TRS_WIRE_PIN_FORMAT_BINARY; + return 0; + case OSDP_TRS_PIN_FORMAT_BCD: + *wire = TRS_WIRE_PIN_FORMAT_BCD; + return 0; + case OSDP_TRS_PIN_FORMAT_ASCII: + *wire = TRS_WIRE_PIN_FORMAT_ASCII; + return 0; + default: + return -1; + } +} + +/* + * The wire carries APDU positions as a 4-bit offset plus a bits/bytes unit + * flag; the API expresses them plainly in bits. A position is representable + * only if it is byte-aligned (up to 15 bytes) or fits the 4-bit offset as-is + * (up to 15 bits). + */ +static int trs_pin_pos_to_wire(uint16_t pos_bits, uint8_t *offset, + bool *unit_bytes) +{ + if (pos_bits % 8 == 0 && pos_bits / 8 <= 15) { + *offset = (uint8_t)(pos_bits / 8); + *unit_bytes = true; + return 0; + } + if (pos_bits <= 15) { + *offset = (uint8_t)pos_bits; + *unit_bytes = false; + return 0; + } + return -1; +} + +static int trs_pin_pos_from_wire(uint8_t offset, bool unit_bytes) +{ + return offset * (unit_bytes ? 8 : 1); +} + +/* Validate the (mode, code) pair against the transparent-mode command space */ +static bool trs_mode_code_valid(uint8_t mode, uint8_t code) +{ + return !(code == 0 || (mode != 0 && mode != 1) || + (mode == 0 && code > 2) || (mode == 1 && code > 4)); +} + +/* --- CP: build outgoing CMD_XWR from an app command --- */ + +/* + * Serialize the library-driven CMD_XWR for the current TRS session state: the + * transparent-mode handshake (mode-set) and card-session teardown (terminate) + * steps the CP sequences on its own. These never pass through a public + * struct osdp_trs_cmd, so their wire form is emitted straight into the buffer. + */ +static int trs_local_cmd_build(struct osdp_pd *pd, uint8_t *buf, int max_len) +{ + int len = 0; + + switch (pd->trs.state) { + case TRS_STATE_SET_MODE: + case TRS_STATE_TEARDOWN: + if (max_len < 4) { + return -1; + } + bwrite_u16_be(CMD_MODE_SET, buf, &len); + buf[len++] = (pd->trs.state == TRS_STATE_SET_MODE) ? + TRS_MODE_01 : TRS_MODE_00; + buf[len++] = TRS_DISABLE_CARD_INFO_REPORT; + break; + case TRS_STATE_DISCONNECT_CARD: + if (max_len < 3) { + return -1; + } + bwrite_u16_be(CMD_TERMINATE, buf, &len); + buf[len++] = 0; /* reader (always 0) */ + break; + default: + return -1; + } + return len; +} + +/* Map an app-facing TRS command to its wire (mode << 8 | code) encoding */ +static int trs_cmd_to_wire(enum osdp_trs_cmd_e command, uint16_t *mode_code) +{ + switch (command) { + case OSDP_TRS_CMD_SEND_APDU: + *mode_code = CMD_SEND_APDU; + return 0; + case OSDP_TRS_CMD_ENTER_PIN: + *mode_code = CMD_ENTER_PIN; + return 0; + case OSDP_TRS_CMD_CARD_SCAN: + *mode_code = CMD_CARD_SCAN; + return 0; + default: + return -1; + } +} + +int osdp_trs_cmd_build(struct osdp_pd *pd, const struct osdp_cmd *cmd, + uint8_t *buf, int max_len) +{ + const struct osdp_trs_cmd *c; + int len = 0; + uint32_t apdu_length; + uint16_t mode_code; + uint8_t byte; + + /* No app payload: this is one of the mode-set / terminate steps the CP + * sequences for itself, built from the session state instead. */ + if (cmd == NULL) { + return trs_local_cmd_build(pd, buf, max_len); + } + + c = &cmd->trs; + if (trs_cmd_to_wire(c->command, &mode_code)) { + return -1; + } + + /* App commands are all mode-1: 2-byte header + a reader byte */ + if (max_len < 3) { + return -1; + } + bwrite_u16_be(mode_code, buf, &len); + buf[len++] = 0; /* reader (always 0) */ + + switch (c->command) { + case OSDP_TRS_CMD_SEND_APDU: + apdu_length = c->apdu.length; + if (apdu_length > sizeof(c->apdu.data) || + apdu_length > (uint32_t)(max_len - len)) { + LOG_ERR("TRS: APDU length invalid! need/have: %d/%d", + (int)apdu_length, (max_len - len)); + return -1; + } + memcpy(buf + len, c->apdu.data, apdu_length); + len += apdu_length; + break; + case OSDP_TRS_CMD_ENTER_PIN: { + const struct osdp_trs_pin_entry *pe = &c->pin_entry; + bool unit_bytes; + uint8_t offset; + + if (max_len - len < 17 || + trs_pin_format_to_wire(pe->pin_block.format, &byte)) { + return -1; + } + buf[len++] = pe->timeout_initial; + buf[len++] = pe->timeout_digit; + /* bmFormatString: [7]=offset unit is bytes, [6:3]=PIN offset, + * [2]=right justify, [1:0]=PIN format */ + if (trs_pin_pos_to_wire(pe->pin_block.offset_bits, &offset, + &unit_bytes)) { + LOG_ERR("TRS: PIN offset %u not wire-representable", + pe->pin_block.offset_bits); + return -1; + } + byte |= pe->pin_block.right_justify ? BIT(2) : 0; + byte |= (uint8_t)(offset << 3); + byte |= unit_bytes ? BIT(7) : 0; + buf[len++] = byte; + /* bmPINBlockString: [7:4]=PIN-length field size (bits), + * [3:0]=PIN block size (bytes). Both are 4-bit wire nibbles; + * reject an out-of-range value rather than masking it down to + * something the reader would silently act on. */ + if (pe->pin_length_field.size_bits > 0x0f || + pe->pin_block.size_bytes > 0x0f) { + LOG_ERR("TRS: PIN length/block size not wire-representable (%u/%u)", + pe->pin_length_field.size_bits, + pe->pin_block.size_bytes); + return -1; + } + buf[len++] = (uint8_t)(pe->pin_length_field.size_bits << 4 | + pe->pin_block.size_bytes); + /* bmPINLengthFormat: [4]=offset unit is bytes, + * [3:0]=PIN-length field offset */ + if (trs_pin_pos_to_wire(pe->pin_length_field.offset_bits, + &offset, &unit_bytes)) { + LOG_ERR("TRS: PIN-length offset %u not wire-representable", + pe->pin_length_field.offset_bits); + return -1; + } + byte = offset; + byte |= unit_bytes ? BIT(4) : 0; + buf[len++] = byte; + buf[len++] = pe->min_digits; + buf[len++] = pe->max_digits; + + /* bEntryValidationCondition: + * [0]=max digits reached, + * [1]=validation key pressed + * [2]=timeout */ + byte = 0; + byte |= (pe->complete_on & OSDP_TRS_PIN_COMPLETE_ON_MAX_DIGITS) ? BIT(0) : 0; + byte |= (pe->complete_on & OSDP_TRS_PIN_COMPLETE_ON_KEY) ? BIT(1) : 0; + byte |= (pe->complete_on & OSDP_TRS_PIN_COMPLETE_ON_TIMEOUT) ? BIT(2) : 0; + buf[len++] = byte; + + buf[len++] = pe->num_messages; + bwrite_u16_be(pe->language_id, buf, &len); + buf[len++] = pe->msg_index; + buf[len++] = pe->teo_prologue[0]; + buf[len++] = pe->teo_prologue[1]; + buf[len++] = pe->teo_prologue[2]; + + apdu_length = pe->apdu.length; + /* Validate before emitting: the length field is written ahead + * of the payload, so a rejected APDU must not leave a stale + * length behind it. */ + if (apdu_length > sizeof(pe->apdu.data) || + apdu_length > (uint32_t)(max_len - len - 2)) { + LOG_ERR("TRS: PIN APDU length invalid! need/have: %d/%d", + (int)apdu_length, (max_len - len - 2)); + return -1; + } + bwrite_u16_be((uint16_t)apdu_length, buf, &len); + memcpy(buf + len, pe->apdu.data, apdu_length); + len += apdu_length; + break; + } + case OSDP_TRS_CMD_CARD_SCAN: + break; + default: + return -1; + } + return len; +} + +/* --- CP: decode incoming REPLY_XRD into an app event --- */ + +/* + * Decode a REPLY_XRD payload into *event. Returns <0 on error, else one of + * enum osdp_trs_reply_action_e telling osdp_cp.c whether the reply carried + * anything for the app: the mode handshake is the library's own business, but + * card data, card status and reader errors are dispatched as OSDP_EVENT_TRS. + * An error report is dispatched and also fails the command it answered. + */ +int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, + struct osdp_event *event) +{ + uint8_t card_protocol, card_status, csn_len, prot_data_len; + uint16_t mode_code; + int pos = 0, data_len, apdu_len; + bool dispatch = true, is_error = false; + + if (len < 2) { + return -1; + } + + memset(event, 0, sizeof(*event)); + event->type = OSDP_EVENT_TRS; + + mode_code = bread_u16_be(buf, &pos); + data_len = len - pos; + + switch (mode_code) { + case REPLY_CURRENT_MODE: + if (data_len < 1) { + return -1; + } + /* internal handshake reply: record mode, don't notify app */ + pd->trs.mode = buf[pos++]; + dispatch = false; + break; + case REPLY_CARD_INFO_REPORT: + event->trs.reply = OSDP_TRS_REPLY_CARD_INFO; + if (data_len < 4) { + return -1; + } + event->trs.card_info.reader = buf[pos++]; + card_protocol = buf[pos++]; + if (card_protocol == TRS_WIRE_PROTOCOL_CONTACT_T0T1) { + event->trs.card_info.protocol = + OSDP_TRS_CARD_PROTOCOL_CONTACT; + } else if (card_protocol == TRS_WIRE_PROTOCOL_14443AB) { + event->trs.card_info.protocol = + OSDP_TRS_CARD_PROTOCOL_CONTACTLESS; + } else { + LOG_ERR("TRS: unsupported card protocol %02x", + card_protocol); + return -1; + } + csn_len = buf[pos++]; + prot_data_len = buf[pos++]; + if (csn_len > OSDP_TRS_CSN_MAX_LEN || + prot_data_len > OSDP_TRS_PROTOCOL_DATA_MAX_LEN) { + LOG_ERR("TRS: CSN/protocol-data too large (%d/%d)", + csn_len, prot_data_len); + return -1; + } + if (data_len < 4 + csn_len + prot_data_len) { + LOG_ERR("TRS: truncated card-info report"); + return -1; + } + event->trs.card_info.csn_len = csn_len; + event->trs.card_info.protocol_data_len = prot_data_len; + memcpy(event->trs.card_info.csn, buf + pos, csn_len); + pos += csn_len; + memcpy(event->trs.card_info.protocol_data, buf + pos, + prot_data_len); + pos += prot_data_len; + break; + case REPLY_CARD_PRESENT: + event->trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + if (data_len < 1) { + return -1; + } + event->trs.card_present.reader = buf[pos++]; + if (data_len < 2) { + /* The status byte is optional (v2.2 section 7.26.8); + * without it the reply itself is the news that a card + * is there, on an unspecified interface. */ + event->trs.card_present.status = OSDP_TRS_CARD_PRESENT; + break; + } + card_status = buf[pos++]; + if (trs_card_status_from_wire(card_status, + &event->trs.card_present.status)) { + LOG_ERR("TRS: reserved card-present status %02x", + card_status); + return -1; + } + break; + case REPLY_ERROR_MODE0: + case REPLY_ERROR_MODE1: + event->trs.reply = OSDP_TRS_REPLY_ERROR; + if (data_len < 1) { + return -1; + } + event->trs.error.code = buf[pos++]; + is_error = true; + break; + case REPLY_CARD_DATA: + event->trs.reply = OSDP_TRS_REPLY_CARD_DATA; + if (data_len < 2) { + return -1; + } + event->trs.card_data.reader = buf[pos++]; + event->trs.card_data.status = buf[pos++]; + apdu_len = len - pos; + if (apdu_len > (int)sizeof(event->trs.card_data.apdu.data)) { + LOG_ERR("TRS: R-APDU too large (%d); rejecting reply", + apdu_len); + return -1; + } + event->trs.card_data.apdu.length = apdu_len; + memcpy(event->trs.card_data.apdu.data, buf + pos, apdu_len); + pos += apdu_len; + break; + case REPLY_PIN_ENTRY_COMPLETE: + event->trs.reply = OSDP_TRS_REPLY_PIN_COMPLETE; + if (data_len < 3) { + return -1; + } + event->trs.pin_complete.reader = buf[pos++]; + event->trs.pin_complete.status = buf[pos++]; + event->trs.pin_complete.tries = buf[pos++]; + break; + default: + LOG_ERR("TRS: unknown reply mode/code %04x", mode_code); + return -1; + } + + if (!dispatch) { + return OSDP_TRS_REPLY_ACTION_NONE; + } + return is_error ? OSDP_TRS_REPLY_ACTION_DISPATCH_ERROR : + OSDP_TRS_REPLY_ACTION_DISPATCH; +} + +/* --- PD: build outgoing REPLY_XRD --- */ + +/* Map an app-facing TRS reply to its wire (mode << 8 | code) encoding */ +static uint16_t trs_reply_to_wire(enum osdp_trs_reply_e reply) +{ + switch (reply) { + case OSDP_TRS_REPLY_CARD_INFO: + return REPLY_CARD_INFO_REPORT; + case OSDP_TRS_REPLY_CARD_PRESENT: + return REPLY_CARD_PRESENT; + case OSDP_TRS_REPLY_CARD_DATA: + return REPLY_CARD_DATA; + case OSDP_TRS_REPLY_PIN_COMPLETE: + return REPLY_PIN_ENTRY_COMPLETE; + case OSDP_TRS_REPLY_ERROR: + return REPLY_ERROR_MODE1; + default: + return 0; + } +} + +int osdp_trs_reply_build(struct osdp_pd *pd, uint8_t *buf, int max_len) +{ + int len = 0, csn_len, prot_data_len, apdu_len; + const struct osdp_event *event = pd->active_event; + const struct osdp_trs_reply *reply; + uint16_t mode_code; + uint8_t card_status; + + if (event == NULL || event->type != OSDP_EVENT_TRS) { + /* No app payload: answer with the current-mode report (Table 71), + * which carries both the mode code and its config byte. */ + if (max_len < 4) { + return -1; + } + bwrite_u16_be(REPLY_CURRENT_MODE, buf, &len); + buf[len++] = pd->trs.mode; + buf[len++] = TRS_DISABLE_CARD_INFO_REPORT; /* mode config */ + return len; + } + + reply = &event->trs; + mode_code = trs_reply_to_wire(reply->reply); + if (mode_code == 0 || max_len < 2) { + return -1; + } + bwrite_u16_be(mode_code, buf, &len); + + switch (reply->reply) { + case OSDP_TRS_REPLY_CARD_INFO: + csn_len = reply->card_info.csn_len; + prot_data_len = reply->card_info.protocol_data_len; + if (csn_len > OSDP_TRS_CSN_MAX_LEN) { + csn_len = OSDP_TRS_CSN_MAX_LEN; + } + if (prot_data_len > OSDP_TRS_PROTOCOL_DATA_MAX_LEN) { + prot_data_len = OSDP_TRS_PROTOCOL_DATA_MAX_LEN; + } + if (max_len - len < 4 + csn_len + prot_data_len) { + return -1; + } + buf[len++] = reply->card_info.reader; + buf[len++] = (reply->card_info.protocol == + OSDP_TRS_CARD_PROTOCOL_CONTACTLESS) ? + TRS_WIRE_PROTOCOL_14443AB : + TRS_WIRE_PROTOCOL_CONTACT_T0T1; + buf[len++] = (uint8_t)csn_len; + buf[len++] = (uint8_t)prot_data_len; + memcpy(buf + len, reply->card_info.csn, csn_len); + len += csn_len; + memcpy(buf + len, reply->card_info.protocol_data, + prot_data_len); + len += prot_data_len; + break; + case OSDP_TRS_REPLY_CARD_PRESENT: + if (max_len - len < 2 || + trs_card_status_to_wire(reply->card_present.status, + &card_status)) { + return -1; + } + buf[len++] = reply->card_present.reader; + buf[len++] = card_status; + break; + case OSDP_TRS_REPLY_ERROR: + if (max_len - len < 1) { + return -1; + } + buf[len++] = reply->error.code; + break; + case OSDP_TRS_REPLY_CARD_DATA: + apdu_len = reply->card_data.apdu.length; + if (apdu_len > (int)sizeof(reply->card_data.apdu.data)) { + LOG_ERR("TRS: R-APDU length invalid (%d)", apdu_len); + return -1; + } + if (max_len - len < 2 + apdu_len) { + return -1; + } + buf[len++] = reply->card_data.reader; + buf[len++] = reply->card_data.status; + memcpy(buf + len, reply->card_data.apdu.data, apdu_len); + len += apdu_len; + break; + case OSDP_TRS_REPLY_PIN_COMPLETE: + if (max_len - len < 3) { + return -1; + } + buf[len++] = reply->pin_complete.reader; + buf[len++] = reply->pin_complete.status; + buf[len++] = reply->pin_complete.tries; + break; + default: + return -1; + } + return len; +} + +/* --- PD: decode incoming CMD_XWR --- + * + * Returns <0 on error (NAK), else one of enum osdp_trs_decode_e telling the + * caller how to answer: ACK a library-handled command (mode set / card + * terminate), send an XRD mode report (mode read), or deliver the decoded + * card-session command to the app. + */ +int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, + int len) +{ + int pos = 0, apdu_length; + uint8_t mode, code, new_mode; + uint16_t mode_code; + + if (len < 2) { + return -1; + } + mode_code = bread_u16_be(buf, &pos); + mode = BYTE_1(mode_code); + code = BYTE_0(mode_code); + + if (!trs_mode_code_valid(mode, code)) { + return -1; + } + + /* mode-0 (handshake) commands are handled by the library itself */ + if (mode == 0) { + if (mode_code == CMD_MODE_SET) { + if (len - pos < 2) { + return -1; + } + new_mode = buf[pos++]; + /* config byte (buf[pos]) currently unused */ + if (new_mode != TRS_MODE_00 && new_mode != TRS_MODE_01) { + LOG_ERR("TRS: unsupported mode %02x requested", + new_mode); + return -1; + } + pd->trs.mode = new_mode; + return OSDP_TRS_DECODE_ACK; /* Table 36: Mode-Set is ACK'd */ + } + /* CMD_MODE_GET (Table 39): answer with the current-mode report */ + return OSDP_TRS_DECODE_MODE_REPORT; + } + + /* Card-session commands are only meaningful once the CP has put us in + * transparent mode; refuse them otherwise. */ + if (pd->trs.mode != TRS_MODE_01) { + LOG_ERR("TRS: mode-1 command received in mode %02x", + pd->trs.mode); + return -1; + } + + /* Card-session teardown is library-driven; ACK it without the app */ + if (mode_code == CMD_TERMINATE) { + return OSDP_TRS_DECODE_ACK; + } + + /* mode-1 commands carry a reader byte and are delivered to the app */ + if (len - pos < 1) { + return -1; + } + pos++; /* reader -- always 0 */ + + cmd->id = OSDP_CMD_XWR; + + switch (mode_code) { + case CMD_SEND_APDU: + cmd->trs.command = OSDP_TRS_CMD_SEND_APDU; + apdu_length = len - pos; + if (apdu_length > (int)sizeof(cmd->trs.apdu.data)) { + LOG_ERR("TRS: C-APDU too large (%d); NAK-ing command", + apdu_length); + return -1; + } + cmd->trs.apdu.length = apdu_length; + memcpy(cmd->trs.apdu.data, buf + pos, apdu_length); + pos += apdu_length; + break; + case CMD_ENTER_PIN: { + struct osdp_trs_pin_entry *pe = &cmd->trs.pin_entry; + uint8_t byte; + + cmd->trs.command = OSDP_TRS_CMD_ENTER_PIN; + if (len - pos < 17) { + return -1; + } + pe->timeout_initial = buf[pos++]; + pe->timeout_digit = buf[pos++]; + /* bmFormatString: [7]=offset unit is bytes, [6:3]=PIN offset, + * [2]=right justify, [1:0]=PIN format */ + byte = buf[pos++]; + if (trs_pin_format_from_wire(byte & 0x03, &pe->pin_block.format)) { + LOG_ERR("TRS: reserved PIN format in %02x", byte); + return -1; + } + pe->pin_block.right_justify = byte & BIT(2); + pe->pin_block.offset_bits = + trs_pin_pos_from_wire((byte >> 3) & 0x0f, byte & BIT(7)); + /* bmPINBlockString: [7:4]=PIN-length field size (bits), + * [3:0]=PIN block size (bytes) */ + byte = buf[pos++]; + pe->pin_length_field.size_bits = (byte >> 4) & 0x0f; + pe->pin_block.size_bytes = byte & 0x0f; + /* bmPINLengthFormat: [4]=offset unit is bytes, + * [3:0]=PIN-length field offset */ + byte = buf[pos++]; + pe->pin_length_field.offset_bits = + trs_pin_pos_from_wire(byte & 0x0f, byte & BIT(4)); + pe->min_digits = buf[pos++]; + pe->max_digits = buf[pos++]; + /* bEntryValidationCondition: [0]=max digits reached, + * [1]=validation key pressed, [2]=timeout */ + byte = buf[pos++]; + pe->complete_on = 0; + pe->complete_on |= (byte & BIT(0)) ? + OSDP_TRS_PIN_COMPLETE_ON_MAX_DIGITS : 0; + pe->complete_on |= (byte & BIT(1)) ? + OSDP_TRS_PIN_COMPLETE_ON_KEY : 0; + pe->complete_on |= (byte & BIT(2)) ? + OSDP_TRS_PIN_COMPLETE_ON_TIMEOUT : 0; + pe->num_messages = buf[pos++]; + pe->language_id = bread_u16_be(buf, &pos); + pe->msg_index = buf[pos++]; + pe->teo_prologue[0] = buf[pos++]; + pe->teo_prologue[1] = buf[pos++]; + pe->teo_prologue[2] = buf[pos++]; + apdu_length = bread_u16_be(buf, &pos); + pe->apdu.length = apdu_length; + if (apdu_length > (int)sizeof(pe->apdu.data) || + apdu_length > (len - pos)) { + LOG_ERR("TRS: PIN APDU length invalid! need/have: %d/%d", + apdu_length, (len - pos)); + return -1; + } + memcpy(pe->apdu.data, buf + pos, apdu_length); + pos += apdu_length; + break; + } + case CMD_CARD_SCAN: + cmd->trs.command = OSDP_TRS_CMD_CARD_SCAN; + /* no additional payload */ + break; + default: + return -1; + } + return OSDP_TRS_DECODE_TO_APP; +} + +/* --- CP: library-driven session state machine --- */ + +/* + * Advance the TRS sub-state after the PD accepted a command, and return the next + * CP role state. The XMIT->DISCONNECT transition is driven by a stop request in + * cp_get_trs_command(); here we only handle post-reply advances. + */ +enum osdp_cp_state_e osdp_trs_state_update(struct osdp_pd *pd) +{ + switch (pd->trs.state) { + case TRS_STATE_SET_MODE: + /* + * A PD that entered transparent mode ACKs the mode-set; one that + * declined answers with a mode report naming the mode it stayed + * in (recorded by osdp_trs_reply_decode). Streaming APDUs to a + * PD that is not in mode 01 would just draw errors, so give up + * on the session instead. + */ + if (pd->reply_id == REPLY_ACK) { + pd->trs.mode = TRS_MODE_01; + } + if (pd->trs.mode != TRS_MODE_01) { + LOG_ERR("TRS: PD refused transparent mode; aborting"); + pd->trs.state = TRS_STATE_DONE; + pd->trs.failed = true; + return OSDP_CP_STATE_ONLINE; + } + pd->trs.state = TRS_STATE_XMIT; + return OSDP_CP_STATE_TRS_RUN; + case TRS_STATE_XMIT: + return OSDP_CP_STATE_TRS_RUN; + case TRS_STATE_DISCONNECT_CARD: + pd->trs.state = TRS_STATE_TEARDOWN; + return OSDP_CP_STATE_TRS_RUN; + case TRS_STATE_TEARDOWN: + pd->trs.state = TRS_STATE_DONE; + __fallthrough; + case TRS_STATE_DONE: + default: + return OSDP_CP_STATE_ONLINE; + } +} + +/* + * A TRS command failed (NAK, or no usable reply). If the PD already entered + * transparent mode, it must not be left there -- a reader in mode 01 stops + * reporting ordinary card reads -- so make one attempt to restore mode 00 + * before letting go of the session. If that attempt is what failed, the PD is + * past helping; hand it back to the online path, which takes it offline if it + * stays unresponsive. + */ +enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd) +{ + pd->trs.failed = true; + + switch (pd->trs.state) { + case TRS_STATE_XMIT: + case TRS_STATE_DISCONNECT_CARD: + LOG_WRN("TRS: session error; restoring transparent mode off"); + pd->trs.state = TRS_STATE_TEARDOWN; + return OSDP_CP_STATE_TRS_RUN; + case TRS_STATE_TEARDOWN: + LOG_ERR("TRS: failed to restore transparent mode off"); + __fallthrough; + case TRS_STATE_SET_MODE: /* mode never took effect; nothing to undo */ + default: + pd->trs.state = TRS_STATE_DONE; + return OSDP_CP_STATE_ONLINE; + } +} diff --git a/src/osdp_trs.h b/src/osdp_trs.h new file mode 100644 index 00000000..bec7afa5 --- /dev/null +++ b/src/osdp_trs.h @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _OSDP_TRS_H_ +#define _OSDP_TRS_H_ + +#include "osdp_common.h" + +/* + * Reply action returned by osdp_trs_cmd_decode() (a negative return means NAK). + * Tells osdp_pd.c how to answer a CMD_XWR the library handled internally. + */ +enum osdp_trs_decode_e { + OSDP_TRS_DECODE_ACK = 0, /* library handled it; answer osdp_ACK */ + OSDP_TRS_DECODE_MODE_REPORT, /* answer osdp_XRD current-mode report */ + OSDP_TRS_DECODE_TO_APP, /* deliver the decoded command to the app */ +}; + +/* + * Action returned by osdp_trs_reply_decode() (a negative return means the reply + * was malformed). Tells osdp_cp.c what to do with the decoded event. + */ +enum osdp_trs_reply_action_e { + OSDP_TRS_REPLY_ACTION_NONE = 0, /* mode handshake; nothing for the app */ + OSDP_TRS_REPLY_ACTION_DISPATCH, /* deliver the decoded event to the app */ + /* Deliver the event, and fail the command it answered: the reader took + * the command but reported that the card transaction did not work. */ + OSDP_TRS_REPLY_ACTION_DISPATCH_ERROR, +}; + +#ifdef OPT_BUILD_OSDP_TRS + +/* @cmd is the app's card command, or NULL for a library-driven session step */ +int osdp_trs_cmd_build(struct osdp_pd *pd, const struct osdp_cmd *cmd, + uint8_t *buf, int max_len); +int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, + struct osdp_event *event); +int osdp_trs_reply_build(struct osdp_pd *pd, uint8_t *buf, int max_len); +int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, + int len); + +#else /* OPT_BUILD_OSDP_TRS */ + +static inline int osdp_trs_cmd_build(struct osdp_pd *pd, + const struct osdp_cmd *cmd, uint8_t *buf, + int max_len) +{ + ARG_UNUSED(pd); + ARG_UNUSED(cmd); + ARG_UNUSED(buf); + ARG_UNUSED(max_len); + return -1; +} +static inline int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, + int len, struct osdp_event *event) +{ + ARG_UNUSED(pd); + ARG_UNUSED(buf); + ARG_UNUSED(len); + ARG_UNUSED(event); + return -1; +} +static inline int osdp_trs_reply_build(struct osdp_pd *pd, uint8_t *buf, int max_len) +{ + ARG_UNUSED(pd); + ARG_UNUSED(buf); + ARG_UNUSED(max_len); + return -1; +} +static inline int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, + uint8_t *buf, int len) +{ + ARG_UNUSED(pd); + ARG_UNUSED(cmd); + ARG_UNUSED(buf); + ARG_UNUSED(len); + return -1; +} + +#endif /* OPT_BUILD_OSDP_TRS */ + +#endif /* _OSDP_TRS_H_ */ \ No newline at end of file diff --git a/tests/unit-tests/test-sources.list b/tests/unit-tests/test-sources.list index ec54f63e..c1f9b734 100644 --- a/tests/unit-tests/test-sources.list +++ b/tests/unit-tests/test-sources.list @@ -20,4 +20,5 @@ test-async-fuzz.c test-codec-fuzz.c test-sc.c test-sc-sia-vectors.c +test-trs.c test-pd-zc.c diff --git a/tests/unit-tests/test-suites.def b/tests/unit-tests/test-suites.def index 5536e114..71e137a5 100644 --- a/tests/unit-tests/test-suites.def +++ b/tests/unit-tests/test-suites.def @@ -32,3 +32,4 @@ SUITE(async_fuzz, run_async_fuzz_tests) SUITE(codec_fuzz, run_codec_fuzz_tests) SUITE(sc, run_sc_tests) SUITE(vectors, run_vector_tests) +SUITE(trs, run_trs_tests) diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c new file mode 100644 index 00000000..b8024eb4 --- /dev/null +++ b/tests/unit-tests/test-trs.c @@ -0,0 +1,997 @@ +/* + * Copyright (c) 2022-2026 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include "osdp_common.h" +#include "osdp_trs.h" +#include "test.h" + +#ifdef OPT_BUILD_OSDP_TRS + +/* + * Drives a full library-managed TRS session over the mock channel: + * CP submits OSDP_CMD_XWR(send-apdu) -> library runs mode-set handshake -> + * PD receives the APDU and answers with an OSDP_EVENT_TRS(card-data) -> + * CP delivers OSDP_EVENT_TRS to its app -> CP asks for an orderly stop. + */ + +/* APDU the CP sends and the PD echoes back inside the card-data reply */ +static const uint8_t g_req_apdu[] = { 0x00, 0xA4, 0x04, 0x00, 0x0E }; +static const uint8_t g_rsp_apdu[] = { 0x6F, 0x1A, 0x84, 0x0E, 0x90, 0x00 }; + +struct test_trs_ctx { + osdp_t *cp_ctx; + osdp_t *pd_ctx; + int cp_runner; + int pd_runner; + + /* CP side: last OSDP_EVENT_TRS seen */ + bool event_seen; + struct osdp_trs_reply last_reply; + + /* PD side: last CMD_XWR delivered to the app */ + bool cmd_seen; + struct osdp_trs_cmd last_cmd; + + /* When set, the PD app does NOT answer the send-APDU in the callback; + * the test submits the R-APDU later to exercise the deferred path. */ + bool defer; + + /* When set, the PD app declines the send-APDU with a NAK */ + bool nak_apdu; + + /* When set, the PD app answers the send-APDU with an error report + * instead of card data */ + bool error_reply; + + /* PD reply event; app-owned, must outlive the command callback since + * osdp_pd_submit_event() stores it by reference until it is sent. */ + struct osdp_event resp_event; + + /* CP side: last OSDP_NOTIFICATION_TRS_STATUS seen */ + bool status_seen; + enum osdp_trs_session_status_e last_status; + + /* CP side: last command completion seen */ + bool completion_seen; + enum osdp_completion_status last_completion; +}; + +static struct test_trs_ctx g_trs = { 0 }; + +/* Build the card-data R-APDU reply into the app-owned resp_event */ +static void trs_fill_response(struct test_trs_ctx *ctx) +{ + struct osdp_event *resp = &ctx->resp_event; + + memset(resp, 0, sizeof(*resp)); + resp->type = OSDP_EVENT_TRS; + resp->trs.reply = OSDP_TRS_REPLY_CARD_DATA; + resp->trs.card_data.reader = 0; + resp->trs.card_data.status = 0; + resp->trs.card_data.apdu.length = sizeof(g_rsp_apdu); + memcpy(resp->trs.card_data.apdu.data, g_rsp_apdu, sizeof(g_rsp_apdu)); +} + +/* Build a transparent-mode error report into the app-owned resp_event */ +static void trs_fill_error(struct test_trs_ctx *ctx) +{ + struct osdp_event *resp = &ctx->resp_event; + + memset(resp, 0, sizeof(*resp)); + resp->type = OSDP_EVENT_TRS; + resp->trs.reply = OSDP_TRS_REPLY_ERROR; + resp->trs.error.code = 0x6A; +} + +static int trs_cp_event_callback(void *arg, int pd, struct osdp_event *ev) +{ + ARG_UNUSED(pd); + struct test_trs_ctx *ctx = arg; + + if (ev->type == OSDP_EVENT_TRS) { + ctx->event_seen = true; + memcpy(&ctx->last_reply, &ev->trs, sizeof(ctx->last_reply)); + } + if (ev->type == OSDP_EVENT_NOTIFICATION && + ev->notif.type == OSDP_NOTIFICATION_TRS_STATUS) { + ctx->status_seen = true; + ctx->last_status = ev->notif.trs_status.status; + } + return 0; +} + +static void trs_cp_completion_callback(void *arg, int pd, + const struct osdp_cmd *cmd, + enum osdp_completion_status status) +{ + struct test_trs_ctx *ctx = arg; + + ARG_UNUSED(pd); + if (cmd->id == OSDP_CMD_XWR) { + ctx->completion_seen = true; + ctx->last_completion = status; + } +} + +/* + * Submit a bare TRS marker/command (START, STOP, ...) to the CP. The command is + * queued by reference, so @a cmd is the caller's to keep alive until the command + * completes -- callers here block on the session notification that follows it. + */ +static int submit_trs_cmd(struct osdp_cmd *cmd, enum osdp_trs_cmd_e command) +{ + *cmd = (struct osdp_cmd){ + .id = OSDP_CMD_XWR, + .trs = { .command = command }, + }; + + return osdp_cp_submit_command(g_trs.cp_ctx, 0, cmd); +} + +static bool wait_for_trs_status(enum osdp_trs_session_status_e want, + int timeout_sec) +{ + int rc = 0; + + while (rc++ < timeout_sec) { + if (g_trs.status_seen && g_trs.last_status == want) { + return true; + } + usleep(1000 * 1000); + } + return false; +} + +static int trs_pd_command_callback(void *arg, struct osdp_cmd *cmd) +{ + struct test_trs_ctx *ctx = arg; + + if (cmd->id != OSDP_CMD_XWR) { + return 0; + } + + ctx->cmd_seen = true; + memcpy(&ctx->last_cmd, &cmd->trs, sizeof(ctx->last_cmd)); + + /* The app cannot act on this APDU; decline it */ + if (ctx->nak_apdu) { + return -OSDP_PD_NAK_RECORD; + } + + /* Fast card: answer the send-APDU synchronously so the R-APDU rides + * back in the immediate REPLY_XRD. In deferred mode the app submits the + * response later (see test_trs_deferred_apdu), so the PD replies ACK + * ("working") and the R-APDU is delivered on a subsequent poll. */ + if (cmd->trs.command == OSDP_TRS_CMD_SEND_APDU && !ctx->defer) { + if (ctx->error_reply) { + trs_fill_error(ctx); + } else { + trs_fill_response(ctx); + } + osdp_pd_submit_event(ctx->pd_ctx, &ctx->resp_event); + } + return 0; +} + +static int setup_test_environment(struct test *t) +{ + printf(SUB_1 "setting up OSDP devices\n"); + + if (test_setup_devices_ext(t, &g_trs.cp_ctx, &g_trs.pd_ctx, + OSDP_FLAG_ENABLE_NOTIFICATION, 0)) { + printf(SUB_1 "Failed to setup devices!\n"); + return -1; + } + + osdp_cp_set_event_callback(g_trs.cp_ctx, trs_cp_event_callback, &g_trs); + osdp_cp_set_command_completion_callback(g_trs.cp_ctx, + trs_cp_completion_callback, + &g_trs); + osdp_pd_set_command_callback(g_trs.pd_ctx, trs_pd_command_callback, + &g_trs); + + printf(SUB_1 "starting async runners\n"); + g_trs.cp_runner = async_runner_start(g_trs.cp_ctx, osdp_cp_refresh); + g_trs.pd_runner = async_runner_start(g_trs.pd_ctx, osdp_pd_refresh); + if (g_trs.cp_runner < 0 || g_trs.pd_runner < 0) { + printf(SUB_1 "Failed to create CP/PD runners\n"); + return -1; + } + + if (!test_wait_for_online(g_trs.cp_ctx, 0, 10)) { + printf(SUB_1 "PD failed to come online\n"); + return -1; + } + return 0; +} + +static void teardown_test_environment() +{ + printf(SUB_1 "tearing down test environment\n"); + async_runner_stop(g_trs.cp_runner); + async_runner_stop(g_trs.pd_runner); + osdp_cp_teardown(g_trs.cp_ctx); + osdp_pd_teardown(g_trs.pd_ctx); + memset(&g_trs, 0, sizeof(g_trs)); +} + +static bool wait_for_trs_event(int timeout_sec) +{ + int rc = 0; + while (rc++ < timeout_sec) { + if (g_trs.event_seen) { + return true; + } + usleep(1000 * 1000); + } + return false; +} + +static bool wait_for_trs_completion(int timeout_sec) +{ + int rc = 0; + while (rc++ < timeout_sec) { + if (g_trs.completion_seen) { + return true; + } + usleep(1000 * 1000); + } + return false; +} + +/* A card command with no START must be turned away at submit, not queued */ +static bool test_trs_apdu_outside_band() +{ + printf(SUB_2 "testing TRS APDU outside a session\n"); + + struct osdp_cmd cmd = { + .id = OSDP_CMD_XWR, + .trs = { + .command = OSDP_TRS_CMD_SEND_APDU, + .apdu = { .length = sizeof(g_req_apdu) }, + }, + }; + memcpy(cmd.trs.apdu.data, g_req_apdu, sizeof(g_req_apdu)); + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd) == 0) { + printf(SUB_2 "sessionless APDU must be rejected\n"); + return false; + } + /* So must a STOP that closes nothing */ + struct osdp_cmd stop; + + if (submit_trs_cmd(&stop, OSDP_TRS_CMD_STOP) == 0) { + printf(SUB_2 "sessionless TRS stop must be rejected\n"); + return false; + } + return true; +} + +/* Inside a band, a non-TRS command is refused: it would cut the card session */ +static bool test_trs_non_trs_cmd_in_band() +{ + printf(SUB_2 "testing non-TRS command inside a session\n"); + + struct osdp_cmd cmd = { + .id = OSDP_CMD_BUZZER, + .buzzer = { .reader = 0, .control_code = 1, .on_count = 10, + .off_count = 10, .rep_count = 1 }, + }; + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd) == 0) { + printf(SUB_2 "in-band buzzer command must be rejected\n"); + return false; + } + /* A nested START is refused too */ + struct osdp_cmd start; + + if (submit_trs_cmd(&start, OSDP_TRS_CMD_START) == 0) { + printf(SUB_2 "nested TRS start must be rejected\n"); + return false; + } + return true; +} + +/* START opens the band: the library negotiates transparent mode and says so */ +static bool test_trs_session_start() +{ + printf(SUB_2 "testing TRS session start\n"); + + struct osdp_cmd cmd; + + g_trs.status_seen = false; + + if (submit_trs_cmd(&cmd, OSDP_TRS_CMD_START)) { + printf(SUB_2 "Failed to submit TRS start\n"); + return false; + } + if (!wait_for_trs_status(OSDP_TRS_SESSION_OPENED, 10)) { + printf(SUB_2 "TRS session-opened notification not received\n"); + return false; + } + return true; +} + +static bool test_trs_apdu_exchange() +{ + printf(SUB_2 "testing TRS APDU exchange (synchronous)\n"); + + g_trs.event_seen = false; + g_trs.cmd_seen = false; + g_trs.defer = false; + + /* Stack storage must outlive the submission; wait_for_trs_event() + * below blocks here until the whole exchange completes. */ + struct osdp_cmd cmd = { + .id = OSDP_CMD_XWR, + .trs = { + .command = OSDP_TRS_CMD_SEND_APDU, + .apdu = { + .length = sizeof(g_req_apdu), + }, + }, + }; + memcpy(cmd.trs.apdu.data, g_req_apdu, sizeof(g_req_apdu)); + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd)) { + printf(SUB_2 "Failed to submit TRS command\n"); + return false; + } + + if (!wait_for_trs_event(10)) { + printf(SUB_2 "TRS event not received\n"); + return false; + } + + /* PD must have received the exact APDU the CP sent */ + if (!g_trs.cmd_seen || + g_trs.last_cmd.command != OSDP_TRS_CMD_SEND_APDU || + g_trs.last_cmd.apdu.length != (int)sizeof(g_req_apdu) || + memcmp(g_trs.last_cmd.apdu.data, g_req_apdu, + sizeof(g_req_apdu)) != 0) { + printf(SUB_2 "PD did not receive the request APDU intact\n"); + return false; + } + + /* CP must have received the card-data reply the PD sent */ + if (g_trs.last_reply.reply != OSDP_TRS_REPLY_CARD_DATA || + g_trs.last_reply.card_data.apdu.length != (int)sizeof(g_rsp_apdu) || + memcmp(g_trs.last_reply.card_data.apdu.data, g_rsp_apdu, + sizeof(g_rsp_apdu)) != 0) { + printf(SUB_2 "CP did not receive the response APDU intact\n"); + return false; + } + + return true; +} + +static bool test_trs_deferred_apdu() +{ + int rc; + + printf(SUB_2 "testing TRS APDU exchange (deferred / slow card)\n"); + + /* The session is already active (TRS_RUN) from the synchronous test; + * submit a second C-APDU that the PD app answers late. */ + g_trs.event_seen = false; + g_trs.cmd_seen = false; + g_trs.defer = true; + + struct osdp_cmd cmd = { + .id = OSDP_CMD_XWR, + .trs = { + .command = OSDP_TRS_CMD_SEND_APDU, + .apdu = { + .length = sizeof(g_req_apdu), + }, + }, + }; + memcpy(cmd.trs.apdu.data, g_req_apdu, sizeof(g_req_apdu)); + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd)) { + printf(SUB_2 "Failed to submit deferred TRS command\n"); + return false; + } + + /* Wait until the PD has received the C-APDU and replied ACK ("working"). + * The PD app deliberately did not answer, so no event must arrive yet. */ + rc = 0; + while (rc++ < 8 && !g_trs.cmd_seen) { + usleep(1000 * 1000); + } + if (!g_trs.cmd_seen) { + printf(SUB_2 "PD never received the deferred C-APDU\n"); + g_trs.defer = false; + return false; + } + if (g_trs.event_seen) { + printf(SUB_2 "R-APDU arrived before the deferred submit\n"); + g_trs.defer = false; + return false; + } + + /* Simulate the card finally responding: the app submits the R-APDU now. + * The CP is still polling, so it rides out on the next poll as XRD. */ + trs_fill_response(&g_trs); + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_trs.resp_event)) { + printf(SUB_2 "Failed to submit deferred R-APDU\n"); + g_trs.defer = false; + return false; + } + + if (!wait_for_trs_event(10)) { + printf(SUB_2 "Deferred TRS event not received\n"); + g_trs.defer = false; + return false; + } + + g_trs.defer = false; + + /* The deferred R-APDU must round-trip intact */ + if (g_trs.last_reply.reply != OSDP_TRS_REPLY_CARD_DATA || + g_trs.last_reply.card_data.apdu.length != (int)sizeof(g_rsp_apdu) || + memcmp(g_trs.last_reply.card_data.apdu.data, g_rsp_apdu, + sizeof(g_rsp_apdu)) != 0) { + printf(SUB_2 "Deferred response APDU mismatch\n"); + return false; + } + + return true; +} + +/* + * Submit one send-APDU and wait for the CP to complete it. The command is queued + * by reference, so it must outlive the exchange: this blocks until it does. + */ +static bool submit_apdu_and_wait(enum osdp_completion_status *status) +{ + struct osdp_cmd cmd = { + .id = OSDP_CMD_XWR, + .trs = { + .command = OSDP_TRS_CMD_SEND_APDU, + .apdu = { .length = sizeof(g_req_apdu) }, + }, + }; + memcpy(cmd.trs.apdu.data, g_req_apdu, sizeof(g_req_apdu)); + + g_trs.completion_seen = false; + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd)) { + printf(SUB_2 "Failed to submit TRS command\n"); + return false; + } + if (!wait_for_trs_completion(10)) { + printf(SUB_2 "TRS command never completed\n"); + return false; + } + *status = g_trs.last_completion; + return true; +} + +/* + * A PD that declines one APDU has not broken the card session. The command must + * fail, no session-failure must be reported, and the next APDU must still go + * through on the same session. + */ +static bool test_trs_apdu_nak_keeps_session() +{ + enum osdp_completion_status status; + bool ok; + + printf(SUB_2 "testing TRS APDU declined by the PD\n"); + + g_trs.event_seen = false; + g_trs.cmd_seen = false; + g_trs.status_seen = false; + g_trs.nak_apdu = true; + + ok = submit_apdu_and_wait(&status); + g_trs.nak_apdu = false; + if (!ok) { + return false; + } + + if (!g_trs.cmd_seen) { + printf(SUB_2 "PD never received the C-APDU\n"); + return false; + } + if (status != OSDP_COMPLETION_FAILED) { + printf(SUB_2 "NAK'd APDU must complete as failed\n"); + return false; + } + if (g_trs.event_seen) { + printf(SUB_2 "NAK'd APDU must not produce a card-data event\n"); + return false; + } + /* Any session status here can only be OPENED->FAILED/CLOSED: the + * session was already open, so a notification means it ended. */ + if (g_trs.status_seen) { + printf(SUB_2 "NAK'd APDU must not end the session (status %d)\n", + g_trs.last_status); + return false; + } + + /* The session survived: the next APDU must round-trip as usual */ + return test_trs_apdu_exchange(); +} + +/* + * The reader took the APDU but reports that the card transaction failed. The app + * gets the error event and a failed command; the session stays open. + */ +static bool test_trs_apdu_error_reply_fails_cmd() +{ + enum osdp_completion_status status; + bool ok; + + printf(SUB_2 "testing TRS APDU answered with an error report\n"); + + g_trs.event_seen = false; + g_trs.status_seen = false; + g_trs.error_reply = true; + + ok = submit_apdu_and_wait(&status); + g_trs.error_reply = false; + if (!ok) { + return false; + } + + if (!g_trs.event_seen || + g_trs.last_reply.reply != OSDP_TRS_REPLY_ERROR || + g_trs.last_reply.error.code != 0x6A) { + printf(SUB_2 "CP did not receive the error report\n"); + return false; + } + if (status != OSDP_COMPLETION_FAILED) { + printf(SUB_2 "APDU answered with an error must complete as failed\n"); + return false; + } + if (g_trs.status_seen) { + printf(SUB_2 "error report must not end the session (status %d)\n", + g_trs.last_status); + return false; + } + + return test_trs_apdu_exchange(); +} + +/* --- wire-level unit tests: assert exact on-wire bytes vs SIA OSDP 2.2 --- */ + +/* + * A bare PD for codec tests. The codec logs through pd_to_osdp(), so it needs + * an osdp context even when no device is set up; `mode` seeds the transparent + * mode the PD believes it is in. + */ +static void trs_wire_pd_init(struct osdp *ctx, struct osdp_pd *pd, uint8_t mode) +{ + memset(ctx, 0, sizeof(*ctx)); + memset(pd, 0, sizeof(*pd)); + pd->osdp_ctx = ctx; + pd->trs.mode = mode; +} + +/* Table 71: Mode Setting Report must carry both Mode code and Mode config */ +static bool test_trs_wire_mode_report(void) +{ + struct osdp ctx; + struct osdp_pd pd; + uint8_t buf[8]; + int len; + + printf(SUB_2 "testing TRS wire: mode-setting-report layout\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + pd.active_event = NULL; /* handshake reply, no app payload */ + + len = osdp_trs_reply_build(&pd, buf, sizeof(buf)); + if (len != 4 || buf[0] != 0x00 || buf[1] != 0x01 || + buf[2] != TRS_MODE_01 || buf[3] != 0x00) { + printf(SUB_2 "want [00 01 mode 00] len 4; got len %d\n", len); + return false; + } + return true; +} + +/* Table 75: Card Present reply must carry both Reader and Status */ +static bool test_trs_wire_card_present_build(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_event ev; + uint8_t buf[8]; + int len; + + printf(SUB_2 "testing TRS wire: card-present layout\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + memset(&ev, 0, sizeof(ev)); + ev.type = OSDP_EVENT_TRS; + ev.trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + ev.trs.card_present.reader = 0; + ev.trs.card_present.status = OSDP_TRS_CARD_PRESENT_CONTACT; + pd.active_event = &ev; + + len = osdp_trs_reply_build(&pd, buf, sizeof(buf)); + if (len != 4 || buf[0] != 0x01 || buf[1] != 0x01 || buf[2] != 0x00 || + buf[3] != 0x03) { + printf(SUB_2 "card-present want len 4; got %d\n", len); + return false; + } + return true; +} + +/* + * The Card Present status byte is optional per v2.2 section 7.26.8; readers + * like the HID RPK40 send the reader byte alone. Take the reply at face + * value -- a card is there -- and report the status as unspecified. + */ +static bool test_trs_wire_card_present_decode(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_event ev; + uint8_t reader_only[] = { 0x01, 0x01, 0x00 }; + uint8_t with_status[] = { 0x01, 0x01, 0x00, 0x03 }; + int r; + + printf(SUB_2 "testing TRS wire: card-present decode\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + + r = osdp_trs_reply_decode(&pd, reader_only, sizeof(reader_only), &ev); + if (r < 0 || ev.trs.reply != OSDP_TRS_REPLY_CARD_PRESENT || + ev.trs.card_present.reader != 0 || + ev.trs.card_present.status != OSDP_TRS_CARD_PRESENT) { + printf(SUB_2 "status-less card-present must decode as present\n"); + return false; + } + + r = osdp_trs_reply_decode(&pd, with_status, sizeof(with_status), &ev); + if (r < 0 || + ev.trs.card_present.status != OSDP_TRS_CARD_PRESENT_CONTACT) { + printf(SUB_2 "card-present status byte must still be honoured\n"); + return false; + } + return true; +} + +/* Table 74: Mode-1 Error reply (osdp_PR01ERROR) = [01 00 error-code] */ +static bool test_trs_wire_error_build(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_event ev; + uint8_t buf[8]; + int len; + + printf(SUB_2 "testing TRS wire: error-reply layout\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + memset(&ev, 0, sizeof(ev)); + ev.type = OSDP_EVENT_TRS; + ev.trs.reply = OSDP_TRS_REPLY_ERROR; + ev.trs.error.code = 0x6A; + pd.active_event = &ev; + + len = osdp_trs_reply_build(&pd, buf, sizeof(buf)); + if (len != 3 || buf[0] != 0x01 || buf[1] != 0x00 || buf[2] != 0x6A) { + printf(SUB_2 "want [01 00 6A] len 3; got len %d\n", len); + return false; + } + return true; +} + +/* + * Table 36/39/42: Mode-Set and Terminate are ACK'd; Mode-Read (Table 39) is + * answered with an XRD mode report; card-session commands go to the app. + */ +static bool test_trs_wire_cmd_decode_actions(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_cmd cmd; + uint8_t set_mode[] = { 0x00, 0x02, TRS_MODE_01, 0x00 }; + uint8_t read_mode[] = { 0x00, 0x01 }; + uint8_t terminate[] = { 0x01, 0x02, 0x00 }; + uint8_t send_apdu[] = { 0x01, 0x01, 0x00, 0x00, 0xA4 }; + int r; + + printf(SUB_2 "testing TRS wire: command decode reply-actions\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_00); + + r = osdp_trs_cmd_decode(&pd, &cmd, set_mode, sizeof(set_mode)); + if (r != OSDP_TRS_DECODE_ACK) { + printf(SUB_2 "mode-set must be ACK'd; got %d\n", r); + return false; + } + r = osdp_trs_cmd_decode(&pd, &cmd, read_mode, sizeof(read_mode)); + if (r != OSDP_TRS_DECODE_MODE_REPORT) { + printf(SUB_2 "mode-read want mode report; got %d\n", r); + return false; + } + r = osdp_trs_cmd_decode(&pd, &cmd, terminate, sizeof(terminate)); + if (r != OSDP_TRS_DECODE_ACK) { + printf(SUB_2 "terminate must be ACK'd; got %d\n", r); + return false; + } + r = osdp_trs_cmd_decode(&pd, &cmd, send_apdu, sizeof(send_apdu)); + if (r != OSDP_TRS_DECODE_TO_APP) { + printf(SUB_2 "send-apdu must be delivered to app; got %d\n", r); + return false; + } + return true; +} + +/* + * The PD must refuse a mode it cannot honour, and must not act on card-session + * commands until the CP has actually put it in transparent mode. + */ +static bool test_trs_wire_mode_gating(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_cmd cmd; + uint8_t bad_mode[] = { 0x00, 0x02, 0x05, 0x00 }; + uint8_t send_apdu[] = { 0x01, 0x01, 0x00, 0x00, 0xA4 }; + uint8_t set_mode[] = { 0x00, 0x02, TRS_MODE_01, 0x00 }; + int r; + + printf(SUB_2 "testing TRS wire: transparent-mode gating\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_00); + + /* An unsupported mode must be NAK'd, not stored and ACK'd */ + r = osdp_trs_cmd_decode(&pd, &cmd, bad_mode, sizeof(bad_mode)); + if (r >= 0 || pd.trs.mode != TRS_MODE_00) { + printf(SUB_2 "mode 0x05 must be refused; got %d mode %02x\n", r, + pd.trs.mode); + return false; + } + + /* Card-session commands are invalid while transparent mode is off */ + r = osdp_trs_cmd_decode(&pd, &cmd, send_apdu, sizeof(send_apdu)); + if (r >= 0) { + printf(SUB_2 "send-apdu in mode 00 must be refused; got %d\n", r); + return false; + } + + /* ... and valid once the CP negotiates mode 01 */ + r = osdp_trs_cmd_decode(&pd, &cmd, set_mode, sizeof(set_mode)); + if (r != OSDP_TRS_DECODE_ACK || pd.trs.mode != TRS_MODE_01) { + printf(SUB_2 "mode-set 01 must be ACK'd; got %d\n", r); + return false; + } + r = osdp_trs_cmd_decode(&pd, &cmd, send_apdu, sizeof(send_apdu)); + if (r != OSDP_TRS_DECODE_TO_APP) { + printf(SUB_2 "send-apdu in mode 01 must reach app; got %d\n", r); + return false; + } + return true; +} + +/* Oversized APDUs must be rejected on decode, never silently truncated */ +static bool test_trs_wire_oversized_apdu_reject(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_cmd cmd; + struct osdp_event ev; + uint8_t buf[128]; + int r; + + printf(SUB_2 "testing TRS wire: oversized APDU rejection\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + memset(buf, 0xA5, sizeof(buf)); + + /* CP side: REPLY_XRD card-data carrying an R-APDU 6 bytes too long */ + buf[0] = 0x01; /* mode 1 */ + buf[1] = 0x02; /* card data */ + buf[2] = 0x00; /* reader */ + buf[3] = 0x00; /* status */ + r = osdp_trs_reply_decode(&pd, buf, 4 + OSDP_TRS_APDU_MAX_LEN + 6, &ev); + if (r >= 0) { + printf(SUB_2 "oversized R-APDU must be rejected; got %d\n", r); + return false; + } + + /* PD side: CMD_XWR send-APDU carrying a C-APDU 6 bytes too long */ + buf[1] = 0x01; /* send-apdu */ + r = osdp_trs_cmd_decode(&pd, &cmd, buf, 3 + OSDP_TRS_APDU_MAX_LEN + 6); + if (r >= 0) { + printf(SUB_2 "oversized C-APDU must be NAK'd; got %d\n", r); + return false; + } + + /* ... while an exactly max-size C-APDU still goes through */ + r = osdp_trs_cmd_decode(&pd, &cmd, buf, 3 + OSDP_TRS_APDU_MAX_LEN); + if (r != OSDP_TRS_DECODE_TO_APP || + cmd.trs.apdu.length != OSDP_TRS_APDU_MAX_LEN) { + printf(SUB_2 "max-size C-APDU must be accepted; got %d\n", r); + return false; + } + return true; +} + +/* Table 43: PIN-entry packed bytes must survive a CP-build -> PD-decode trip */ +static bool test_trs_wire_pin_entry_roundtrip(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_cmd in, out; + struct osdp_trs_pin_entry *pe = &in.trs.pin_entry; + uint8_t buf[64]; + int len, r; + + printf(SUB_2 "testing TRS wire: PIN-entry round-trip\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + memset(&in, 0, sizeof(in)); + memset(&out, 0, sizeof(out)); + + in.id = OSDP_CMD_XWR; + in.trs.command = OSDP_TRS_CMD_ENTER_PIN; + pe->timeout_initial = 30; + pe->timeout_digit = 10; + pe->pin_block.format = OSDP_TRS_PIN_FORMAT_ASCII; + pe->pin_block.right_justify = true; + pe->pin_block.offset_bits = 40; /* byte-aligned: goes out in byte units */ + pe->pin_block.size_bytes = 8; + pe->pin_length_field.size_bits = 4; + pe->pin_length_field.offset_bits = 3; /* sub-byte: goes out in bit units */ + pe->min_digits = 4; + pe->max_digits = 12; + pe->complete_on = OSDP_TRS_PIN_COMPLETE_ON_MAX_DIGITS | + OSDP_TRS_PIN_COMPLETE_ON_KEY; + pe->num_messages = 1; + pe->language_id = 0x0409; + pe->msg_index = 0; + pe->apdu.length = sizeof(g_req_apdu); + memcpy(pe->apdu.data, g_req_apdu, sizeof(g_req_apdu)); + + /* header(2) + reader(1) + fixed fields(15) + apdu len(2) + apdu(5) */ + len = osdp_trs_cmd_build(&pd, &in, buf, sizeof(buf)); + if (len != 25) { + printf(SUB_2 "PIN-entry build: want len 25; got %d\n", len); + return false; + } + r = osdp_trs_cmd_decode(&pd, &out, buf, len); + if (r != OSDP_TRS_DECODE_TO_APP || + out.trs.command != OSDP_TRS_CMD_ENTER_PIN) { + printf(SUB_2 "PIN-entry decode failed: %d\n", r); + return false; + } + if (memcmp(pe, &out.trs.pin_entry, sizeof(*pe)) != 0) { + printf(SUB_2 "PIN-entry did not round-trip intact\n"); + return false; + } + return true; +} + +/* End-to-end: PD reports a card-present status; CP app must receive it */ +static bool test_trs_card_present() +{ + printf(SUB_2 "testing TRS card-present notification\n"); + + g_trs.event_seen = false; + memset(&g_trs.resp_event, 0, sizeof(g_trs.resp_event)); + g_trs.resp_event.type = OSDP_EVENT_TRS; + g_trs.resp_event.trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + g_trs.resp_event.trs.card_present.reader = 0; + g_trs.resp_event.trs.card_present.status = OSDP_TRS_CARD_PRESENT_CONTACT; + + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_trs.resp_event)) { + printf(SUB_2 "Failed to submit card-present event\n"); + return false; + } + if (!wait_for_trs_event(10)) { + printf(SUB_2 "card-present event not received\n"); + return false; + } + if (g_trs.last_reply.reply != OSDP_TRS_REPLY_CARD_PRESENT || + g_trs.last_reply.card_present.reader != 0 || + g_trs.last_reply.card_present.status != + OSDP_TRS_CARD_PRESENT_CONTACT) { + printf(SUB_2 "card-present reader/status not intact\n"); + return false; + } + return true; +} + +/* End-to-end: PD reports a transparent-mode error; CP app must receive it */ +static bool test_trs_error_reply() +{ + printf(SUB_2 "testing TRS error reply\n"); + + g_trs.event_seen = false; + trs_fill_error(&g_trs); + + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_trs.resp_event)) { + printf(SUB_2 "Failed to submit error event\n"); + return false; + } + if (!wait_for_trs_event(10)) { + printf(SUB_2 "error event not received\n"); + return false; + } + if (g_trs.last_reply.reply != OSDP_TRS_REPLY_ERROR || + g_trs.last_reply.error.code != 0x6A) { + printf(SUB_2 "error code not intact\n"); + return false; + } + return true; +} + +static bool test_trs_session_stop() +{ + printf(SUB_2 "testing TRS session stop\n"); + + struct osdp_cmd cmd; + + g_trs.status_seen = false; + + if (submit_trs_cmd(&cmd, OSDP_TRS_CMD_STOP)) { + printf(SUB_2 "Failed to submit TRS stop\n"); + return false; + } + if (!wait_for_trs_status(OSDP_TRS_SESSION_CLOSED, 10)) { + printf(SUB_2 "TRS session-closed notification not received\n"); + return false; + } + + /* After teardown the PD is back to ordinary online operation */ + if (!test_wait_for_online(g_trs.cp_ctx, 0, 10)) { + printf(SUB_2 "PD did not return online after TRS stop\n"); + return false; + } + + return true; +} + +void run_trs_tests(struct test *t) +{ + bool result = true; + + printf("\nBegin TRS Tests\n"); + + /* wire-level unit tests need no devices; run them first */ + printf(SUB_1 "running TRS wire-encoding tests\n"); + result &= test_trs_wire_mode_report(); + result &= test_trs_wire_card_present_build(); + result &= test_trs_wire_card_present_decode(); + result &= test_trs_wire_error_build(); + result &= test_trs_wire_cmd_decode_actions(); + result &= test_trs_wire_mode_gating(); + result &= test_trs_wire_oversized_apdu_reject(); + result &= test_trs_wire_pin_entry_roundtrip(); + + if (setup_test_environment(t) != 0) { + printf(SUB_1 "Failed to setup test environment\n"); + TEST_REPORT(t, false); + return; + } + + printf(SUB_1 "running TRS tests\n"); + result &= test_trs_apdu_outside_band(); + result &= test_trs_session_start(); + result &= test_trs_non_trs_cmd_in_band(); + result &= test_trs_apdu_exchange(); + result &= test_trs_deferred_apdu(); + result &= test_trs_apdu_nak_keeps_session(); + result &= test_trs_apdu_error_reply_fails_cmd(); + result &= test_trs_card_present(); + result &= test_trs_error_reply(); + result &= test_trs_session_stop(); + + teardown_test_environment(); + + printf(SUB_1 "TRS tests %s\n", result ? "succeeded" : "failed"); + TEST_REPORT(t, result); +} + +#else /* OPT_BUILD_OSDP_TRS */ + +/* TRS-only suite: compiles to a no-op when TRS support is not built in, so the + * file can sit in the shared source list without a link dependency. */ +void run_trs_tests(struct test *t) +{ + ARG_UNUSED(t); +} + +#endif /* OPT_BUILD_OSDP_TRS */ diff --git a/tests/unit-tests/test.c b/tests/unit-tests/test.c index 571e1afa..a1318a3f 100644 --- a/tests/unit-tests/test.c +++ b/tests/unit-tests/test.c @@ -959,6 +959,21 @@ int test_setup_devices(struct test *t, osdp_t **cp, osdp_t **pd) return test_setup_devices_ext(t, cp, pd, 0, 0); } +bool test_wait_for_online(osdp_t *cp_ctx, int pd_idx, int timeout_sec) +{ + uint8_t status = 0; + int rc = 0; + + while (rc++ < timeout_sec) { + osdp_get_status_mask(cp_ctx, &status); + if (status & (1 << pd_idx)) { + return true; + } + usleep(1000 * 1000); + } + return false; +} + void test_start(struct test *t, int log_level) { memset(t, 0, sizeof(*t)); diff --git a/tests/unit-tests/test.h b/tests/unit-tests/test.h index ef2ef782..aca0dd86 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -142,6 +142,7 @@ void test_write_junit(struct test *t, const char *path); int test_setup_devices(struct test *t, osdp_t **cp, osdp_t **pd); int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, uint32_t cp_flags, uint32_t pd_flags); +bool test_wait_for_online(osdp_t *cp_ctx, int pd_idx, int timeout_sec); /* * Channel interceptor: a registered hook sees every frame crossing the mock @@ -195,6 +196,7 @@ void run_async_fuzz_tests(struct test *t); void run_codec_fuzz_tests(struct test *t); void run_sc_tests(struct test *t); void run_vector_tests(struct test *t); +void run_trs_tests(struct test *t); /* no-op unless OPT_BUILD_OSDP_TRS */ void run_pd_zc_tests(struct test *t); /* no-op unless OPT_OSDP_RX_ZERO_COPY */ #define printf(...) test_printf(__VA_ARGS__) From 1b6420b950889b1b4c258aebcb6fab12032644ae Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Tue, 14 Jul 2026 18:39:44 +0200 Subject: [PATCH 02/22] trs: Accept Mode-Set without the optional config byte Table 36 marks the mode-config byte optional: in its absence 0x00 is used. The PD decoder demanded both mode and config, NAK-ing a 3-byte Mode-Set that a foreign ACU may legitimately send. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- src/osdp_trs.c | 5 +++-- tests/unit-tests/test-trs.c | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 3d0175c2..c98c6cd0 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -644,11 +644,12 @@ int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, /* mode-0 (handshake) commands are handled by the library itself */ if (mode == 0) { if (mode_code == CMD_MODE_SET) { - if (len - pos < 2) { + /* The config byte is optional (Table 36: absent means + * 0x00) and currently unused either way. */ + if (len - pos < 1) { return -1; } new_mode = buf[pos++]; - /* config byte (buf[pos]) currently unused */ if (new_mode != TRS_MODE_00 && new_mode != TRS_MODE_01) { LOG_ERR("TRS: unsupported mode %02x requested", new_mode); diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index b8024eb4..a4a24acb 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -690,6 +690,7 @@ static bool test_trs_wire_cmd_decode_actions(void) struct osdp_pd pd; struct osdp_cmd cmd; uint8_t set_mode[] = { 0x00, 0x02, TRS_MODE_01, 0x00 }; + uint8_t set_mode_no_cfg[] = { 0x00, 0x02, TRS_MODE_01 }; uint8_t read_mode[] = { 0x00, 0x01 }; uint8_t terminate[] = { 0x01, 0x02, 0x00 }; uint8_t send_apdu[] = { 0x01, 0x01, 0x00, 0x00, 0xA4 }; @@ -703,6 +704,13 @@ static bool test_trs_wire_cmd_decode_actions(void) printf(SUB_2 "mode-set must be ACK'd; got %d\n", r); return false; } + /* Table 36: the config byte is optional; in its absence 0x00 is used */ + r = osdp_trs_cmd_decode(&pd, &cmd, set_mode_no_cfg, + sizeof(set_mode_no_cfg)); + if (r != OSDP_TRS_DECODE_ACK || pd.trs.mode != TRS_MODE_01) { + printf(SUB_2 "config-less mode-set must be ACK'd; got %d\n", r); + return false; + } r = osdp_trs_cmd_decode(&pd, &cmd, read_mode, sizeof(read_mode)); if (r != OSDP_TRS_DECODE_MODE_REPORT) { printf(SUB_2 "mode-read want mode report; got %d\n", r); From bc6ca72fd3536c3284ae42ec766ca34bacea5880 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:09:11 +0200 Subject: [PATCH 03/22] cp: Never retransmit a NAK'd transparent-card command The RPK40 NAKs a CARD_SCAN it does not implement with MSG_CHK, which the CP's corrupted-frame heuristic answered with up to eight blind retransmissions before tearing the card session down. Replaying card traffic can have card-side effects (PIN retry counters, transaction state), so CMD_XWR is exempt from the resend heuristic entirely; and since a reader may pick any NAK code it fancies for an unimplemented card command, every NAK to an app-owned card command is a soft failure -- one failed command, session intact -- unless the code says the link itself is broken (SEQ_NUM, SC_UNSUP, SC_COND). The test runs on a plaintext link, as in the field trace: the mock channel rewrites the reader's reply into a NAK(MSG_CHK) at the same sequence number, which is also why the mock gained a no-SCBK device setup -- rewriting a secured reply would desync the MAC chain, which a real quirky reader never does. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- src/osdp_cp.c | 16 ++++ tests/unit-tests/test-trs.c | 155 +++++++++++++++++++++++++++++++++++- tests/unit-tests/test.c | 21 ++++- tests/unit-tests/test.h | 2 + 4 files changed, 187 insertions(+), 7 deletions(-) diff --git a/src/osdp_cp.c b/src/osdp_cp.c index c4901c6b..236bb989 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -472,7 +472,12 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) } osdp_metrics_report(pd, OSDP_METRIC_NAK); pd->nak_code = buf[pos]; + /* Never blind-retransmit a card command: replaying card + * traffic can have card-side effects (PIN retry counters, + * transaction state), and readers are known to NAK an + * unimplemented XWR sub-command with MSG_CHK. */ if (pd->nak_code == OSDP_PD_NAK_MSG_CHK && + pd->cmd_id != CMD_XWR && ISSET_FLAG(pd, PD_FLAG_CP_USE_CRC)) { if (!cp_pd_declared_crc(pd)) { LOG_INF("PD NAK'd CRC-16, falling back to checksum"); @@ -1969,6 +1974,17 @@ static bool cp_cmd_failure_is_soft(struct osdp_pd *pd) return true; } + /* A reader may NAK a card command it does not implement with any code + * it fancies -- the RPK40 answers CARD_SCAN with MSG_CHK. The command + * was delivered and answered; unless the code says the link itself is + * broken, the failure belongs to this one transaction. */ + if (pd->cmd_id == CMD_XWR && pd->reply_id == REPLY_NAK && + pd->nak_code != OSDP_PD_NAK_SEQ_NUM && + pd->nak_code != OSDP_PD_NAK_SC_UNSUP && + pd->nak_code != OSDP_PD_NAK_SC_COND) { + return true; + } + return pd->reply_id == REPLY_NAK && cp_nak_code_is_app_level(pd->nak_code); } diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index a4a24acb..1b3baebb 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -177,12 +177,21 @@ static int trs_pd_command_callback(void *arg, struct osdp_cmd *cmd) return 0; } -static int setup_test_environment(struct test *t) +static int setup_test_environment_ex(struct test *t, bool plaintext) { - printf(SUB_1 "setting up OSDP devices\n"); + int rc; - if (test_setup_devices_ext(t, &g_trs.cp_ctx, &g_trs.pd_ctx, - OSDP_FLAG_ENABLE_NOTIFICATION, 0)) { + printf(SUB_1 "setting up OSDP devices%s\n", + plaintext ? " (plaintext link)" : ""); + + if (plaintext) { + rc = test_setup_devices_plain(t, &g_trs.cp_ctx, &g_trs.pd_ctx, + OSDP_FLAG_ENABLE_NOTIFICATION, 0); + } else { + rc = test_setup_devices_ext(t, &g_trs.cp_ctx, &g_trs.pd_ctx, + OSDP_FLAG_ENABLE_NOTIFICATION, 0); + } + if (rc) { printf(SUB_1 "Failed to setup devices!\n"); return -1; } @@ -209,6 +218,11 @@ static int setup_test_environment(struct test *t) return 0; } +static int setup_test_environment(struct test *t) +{ + return setup_test_environment_ex(t, false); +} + static void teardown_test_environment() { printf(SUB_1 "tearing down test environment\n"); @@ -558,6 +572,122 @@ static bool test_trs_apdu_error_reply_fails_cmd() return test_trs_apdu_exchange(); } +extern uint16_t test_osdp_compute_crc16(const uint8_t *buf, size_t len); + +struct trs_wire_nak_hook { + int xwr_tx_count; + bool nak_next_reply; +}; + +/* + * Model the RPK40 declining a CARD_SCAN it does not implement: the command + * is delivered (so the PD's sequence tracking stays honest), but the reply + * going back is rewritten into a plaintext NAK(MSG_CHK) at the reply's own + * sequence number. + */ +static enum test_channel_hook_verdict trs_wire_nak_hook(void *arg, + bool cp_to_pd, const uint8_t *frame, int len, + uint8_t *out, int *out_len, int out_max) +{ + struct trs_wire_nak_hook *s = arg; + int base = 0, data_off, n = 0, start, pkt_len; + uint8_t ctrl; + uint16_t crc; + +#ifndef OPT_OSDP_SKIP_MARK_BYTE + base = 1; +#endif + if (len < base + 6) + return TEST_HOOK_PASS; + ctrl = frame[base + 4]; + + if (cp_to_pd) { + data_off = base + 5; + if (ctrl & 0x08) /* security control block */ + data_off += frame[base + 5]; + if (data_off < len && frame[data_off] == CMD_XWR) { + s->xwr_tx_count++; + s->nak_next_reply = true; + } + return TEST_HOOK_PASS; + } + + if (!s->nak_next_reply || out_max < 11) + return TEST_HOOK_PASS; + s->nak_next_reply = false; + + n = 0; +#ifndef OPT_OSDP_SKIP_MARK_BYTE + out[n++] = 0xff; +#endif + start = n; + out[n++] = 0x53; + out[n++] = 0x65 | 0x80; /* PD address 101, reply direction */ + pkt_len = 5 + 2 + 2; /* header + NAK/code + CRC */ + out[n++] = pkt_len & 0xff; + out[n++] = (pkt_len >> 8) & 0xff; + out[n++] = 0x04 | (ctrl & 0x03); /* CRC, reply's sequence */ + out[n++] = REPLY_NAK; + out[n++] = OSDP_PD_NAK_MSG_CHK; + crc = test_osdp_compute_crc16(out + start, n - start); + out[n++] = crc & 0xff; + out[n++] = (crc >> 8) & 0xff; + *out_len = n; + return TEST_HOOK_REPLACE; +} + +/* + * Not every reader implements CARD_SCAN: the RPK40 answers it with a wire + * level NAK(MSG_CHK). An APDU-bearing command must never be blind + * retransmitted -- replaying card traffic can have card-side effects -- so + * the NAK costs the app exactly one failed command: one transmission, no + * resends, session intact. + */ +static bool test_trs_card_scan_wire_nak(void) +{ + struct trs_wire_nak_hook s = { 0 }; + struct osdp_cmd cmd; + int rc = 0; + + printf(SUB_2 "testing TRS CARD_SCAN declined on the wire\n"); + + g_trs.completion_seen = false; + g_trs.status_seen = false; + g_trs.event_seen = false; + + test_set_channel_hook(trs_wire_nak_hook, &s); + if (submit_trs_cmd(&cmd, OSDP_TRS_CMD_CARD_SCAN)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to submit CARD_SCAN\n"); + return false; + } + while (rc++ < 120 && !g_trs.completion_seen) { + usleep(100 * 1000); + } + test_set_channel_hook(NULL, NULL); + + if (!g_trs.completion_seen) { + printf(SUB_2 "CARD_SCAN never completed\n"); + return false; + } + if (g_trs.last_completion != OSDP_COMPLETION_FAILED) { + printf(SUB_2 "NAK'd CARD_SCAN must complete as failed\n"); + return false; + } + if (s.xwr_tx_count != 1) { + printf(SUB_2 "NAK'd CARD_SCAN must not be retransmitted; " + "saw %d transmissions\n", s.xwr_tx_count); + return false; + } + if (g_trs.status_seen) { + printf(SUB_2 "NAK'd CARD_SCAN must not end the session " + "(status %d)\n", g_trs.last_status); + return false; + } + + return test_trs_apdu_exchange(); +} + /* --- wire-level unit tests: assert exact on-wire bytes vs SIA OSDP 2.2 --- */ /* @@ -989,6 +1119,23 @@ void run_trs_tests(struct test *t) teardown_test_environment(); + /* + * Reader-quirk tests run on a plaintext link, matching the field + * traces they are modeled on: rewriting a reply on the wire would + * desynchronize the secure channel's MAC chain, which a real quirky + * reader never does. + */ + if (setup_test_environment_ex(t, true) != 0) { + printf(SUB_1 "Failed to setup plaintext test environment\n"); + TEST_REPORT(t, false); + return; + } + result &= test_trs_session_start(); + result &= test_trs_card_scan_wire_nak(); + result &= test_trs_session_stop(); + + teardown_test_environment(); + printf(SUB_1 "TRS tests %s\n", result ? "succeeded" : "failed"); TEST_REPORT(t, result); } diff --git a/tests/unit-tests/test.c b/tests/unit-tests/test.c index a1318a3f..c94fad93 100644 --- a/tests/unit-tests/test.c +++ b/tests/unit-tests/test.c @@ -856,8 +856,9 @@ void test_mock_pd_flush(void *data) #endif /* OPT_OSDP_RX_ZERO_COPY */ -int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, - uint32_t cp_flags, uint32_t pd_flags) +static int test_setup_devices_impl(struct test *t, osdp_t **cp, osdp_t **pd, + uint32_t cp_flags, uint32_t pd_flags, + bool with_scbk) { #ifndef OPT_OSDP_LOG_MINIMAL osdp_logger_init("osdp", t->loglevel, NULL); @@ -897,7 +898,7 @@ int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, .address = 101, .baud_rate = 9600, .flags = cp_flags, - .scbk = scbk, + .scbk = with_scbk ? scbk : NULL, }; *cp = osdp_cp_setup(&cp_channel, 1, &info_cp); @@ -954,6 +955,20 @@ int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, return 0; } +int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, + uint32_t cp_flags, uint32_t pd_flags) +{ + return test_setup_devices_impl(t, cp, pd, cp_flags, pd_flags, true); +} + +/* No SCBK on the CP: the link comes up and stays in plaintext, the way + * legacy readers on unprovisioned installs run. */ +int test_setup_devices_plain(struct test *t, osdp_t **cp, osdp_t **pd, + uint32_t cp_flags, uint32_t pd_flags) +{ + return test_setup_devices_impl(t, cp, pd, cp_flags, pd_flags, false); +} + int test_setup_devices(struct test *t, osdp_t **cp, osdp_t **pd) { return test_setup_devices_ext(t, cp, pd, 0, 0); diff --git a/tests/unit-tests/test.h b/tests/unit-tests/test.h index aca0dd86..812ed867 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -142,6 +142,8 @@ void test_write_junit(struct test *t, const char *path); int test_setup_devices(struct test *t, osdp_t **cp, osdp_t **pd); int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, uint32_t cp_flags, uint32_t pd_flags); +int test_setup_devices_plain(struct test *t, osdp_t **cp, osdp_t **pd, + uint32_t cp_flags, uint32_t pd_flags); bool test_wait_for_online(osdp_t *cp_ctx, int pd_idx, int timeout_sec); /* From 740b634751f0c6cd3641da4c191c8d02b223ba7e Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:27:43 +0200 Subject: [PATCH 04/22] cp: Tolerate REPLY_XRD outside a TRS session A reader left in transparent mode -- failed teardown, CP restart -- keeps answering polls with XRD replies. The CP treated those as unknown responses and cycled the PD offline for the full retry penalty, taking a healthy link down over a stale reader mode. Swallow the reply with a warning instead: it is well-formed, it just has no session to route to. The payload is dropped, no event reaches the app, and the link stays up. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- src/osdp_cp.c | 13 +++++++--- tests/unit-tests/test-trs.c | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 236bb989..fcb31d6b 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -795,8 +795,12 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) break; case REPLY_XRD: if (!trs_active(pd)) { - LOG_WRN("Unsolicited REPLY_XRD outside a TRS session"); - return OSDP_CP_ERR_UNKNOWN; + /* A reader left in transparent mode (failed teardown, + * CP restart) keeps sending these as poll responses; + * shrug them off rather than cycling the PD offline. */ + LOG_WRN("Ignoring REPLY_XRD outside a TRS session"); + ret = OSDP_CP_ERR_NONE; + break; } ret = osdp_trs_reply_decode(pd, buf + pos, len, &event); if (ret < 0) { @@ -1496,7 +1500,10 @@ static bool cp_check_online_response(struct osdp_pd *pd) pd->reply_id == REPLY_CRAUTHR || pd->reply_id == REPLY_RAW || pd->reply_id == REPLY_KEYPAD || - (pd->reply_id == REPLY_XRD && trs_active(pd))) { + pd->reply_id == REPLY_XRD) { + /* An XRD with no session behind it was already + * swallowed by the decoder; it is still a valid, + * well-formed poll response. */ return true; } return is_ignore_unsolicited_messages(pd); diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index 1b3baebb..49780193 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -1056,6 +1056,56 @@ static bool test_trs_error_reply() return true; } +/* Event submitted by reference; must outlive the callback that ships it */ +static struct osdp_event g_unsolicited_ev; + +/* + * A reader left in transparent mode (failed teardown, CP restart) keeps + * sending XRD poll responses. The CP has no session to route them to; it + * must shrug them off, not cycle the PD offline. + */ +static bool test_trs_unsolicited_xrd_keeps_pd_online(void) +{ + uint8_t status = 0; + int rc = 0; + + printf(SUB_2 "testing unsolicited XRD outside a session\n"); + + g_trs.event_seen = false; + + g_unsolicited_ev = (struct osdp_event){ + .type = OSDP_EVENT_TRS, + .trs = { + .reply = OSDP_TRS_REPLY_CARD_PRESENT, + .card_present = { + .reader = 0, + .status = OSDP_TRS_CARD_PRESENT, + }, + }, + }; + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_unsolicited_ev)) { + printf(SUB_2 "failed to submit unsolicited TRS event\n"); + return false; + } + + /* Long enough for the poll loop to carry it across and, were the CP + * still intolerant, for the link to collapse. */ + while (rc++ < 30) { + usleep(100 * 1000); + } + + osdp_get_status_mask(g_trs.cp_ctx, &status); + if (!(status & 1)) { + printf(SUB_2 "a stray XRD must not take the PD offline\n"); + return false; + } + if (g_trs.event_seen) { + printf(SUB_2 "a stray XRD must not surface as an event\n"); + return false; + } + return true; +} + static bool test_trs_session_stop() { printf(SUB_2 "testing TRS session stop\n"); @@ -1116,6 +1166,7 @@ void run_trs_tests(struct test *t) result &= test_trs_card_present(); result &= test_trs_error_reply(); result &= test_trs_session_stop(); + result &= test_trs_unsolicited_xrd_keeps_pd_online(); teardown_test_environment(); From 0488b3f81359448181b5bdb6b86960cc9d0b7f4c Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:48:43 +0200 Subject: [PATCH 05/22] trs: Raise APDU capacity for PIV-class exchanges The 64-byte APDU carrier could not hold what the headline use case moves: PIV certificate reads chain GET RESPONSE chunks up to the short-APDU maximum of 258 bytes (255 data + SW1SW2). Raise the default to 258 behind an #ifndef so constrained builds can dial it back down -- osdp_cmd/osdp_event embed a buffer of this size, so the override is also the memory knob. The usable size is further bound by the negotiated packet size; a C-APDU that cannot fit it is now rejected at submit time instead of failing mid-band. Full-size chunks additionally need -DOSDP_PACKET_BUF_SIZE=512 (default 256). Chunking inside the library would be wrong here: TRS is a transparent tunnel and ISO 7816-4 has its own chaining. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 15 ++++++- src/osdp_cp.c | 6 +++ src/osdp_trs.c | 17 ++++++++ src/osdp_trs.h | 2 + tests/unit-tests/test-trs.c | 82 ++++++++++++++++++++++++++++++++++++- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/include/osdp.h b/include/osdp.h index 9a29a775..ee179cf6 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1237,8 +1237,19 @@ enum osdp_trs_session_status_e { * session was aborted by a link error */ }; -/** @brief Max APDU length carried in a TRS command or reply */ -#define OSDP_TRS_APDU_MAX_LEN 64 +/** + * @brief Max APDU length carried in a TRS command or reply. The default + * covers a short-APDU maximum (255 data + SW1SW2 + margin), which is what + * PIV-class certificate reads move per GET RESPONSE chunk. Overridable at + * build time; note that osdp_cmd/osdp_event embed a buffer of this size, so + * shrinking it is how constrained builds reclaim that memory. Actually + * usable APDU size is further bound by the negotiated packet size (see + * OSDP_PACKET_BUF_SIZE, default 256 -- build with 512 for full-size + * chunks); oversized submissions are rejected up front. + */ +#ifndef OSDP_TRS_APDU_MAX_LEN +#define OSDP_TRS_APDU_MAX_LEN 258 +#endif /** @brief Max CSN length carried in a TRS card-info reply */ #define OSDP_TRS_CSN_MAX_LEN 32 /** @brief Max protocol-data length carried in a TRS card-info reply */ diff --git a/src/osdp_cp.c b/src/osdp_cp.c index fcb31d6b..98eba202 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -2224,6 +2224,12 @@ static int cp_band_admit(struct osdp_pd *pd, const struct osdp_cmd *cmd) LOG_ERR("TRS command outside a session; queue a START first"); return -1; } + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_SEND_APDU) && + (int)cmd->trs.apdu.length > osdp_trs_max_apdu_len(pd)) { + LOG_ERR("C-APDU of %u bytes cannot fit the %d-byte packet", + cmd->trs.apdu.length, get_tx_buf_size(pd)); + return -1; + } if (cmd->id != OSDP_CMD_XWR && pd->trs.band_open) { /* * Sending it would interrupt the card transaction; queuing it diff --git a/src/osdp_trs.c b/src/osdp_trs.c index c98c6cd0..afb8e576 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -228,6 +228,23 @@ static int trs_cmd_to_wire(enum osdp_trs_cmd_e command, uint16_t *mode_code) } } +int osdp_trs_max_apdu_len(struct osdp_pd *pd) +{ + /* + * Worst-case wire overhead around the APDU: mark byte, packet header, + * security control block, command id, TRS mode/code and reader bytes, + * AES block padding, MAC and CRC. Deliberately conservative -- what + * is admitted against this must always fit when the frame is built. + */ + const int overhead = 40; + int max_len = get_tx_buf_size(pd) - overhead; + + if (max_len > OSDP_TRS_APDU_MAX_LEN) { + max_len = OSDP_TRS_APDU_MAX_LEN; + } + return max_len; +} + int osdp_trs_cmd_build(struct osdp_pd *pd, const struct osdp_cmd *cmd, uint8_t *buf, int max_len) { diff --git a/src/osdp_trs.h b/src/osdp_trs.h index bec7afa5..9f3dc902 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -41,6 +41,8 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, int osdp_trs_reply_build(struct osdp_pd *pd, uint8_t *buf, int max_len); int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, int len); +/* Largest C-APDU the negotiated packet size can carry for this PD */ +int osdp_trs_max_apdu_len(struct osdp_pd *pd); #else /* OPT_BUILD_OSDP_TRS */ diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index 49780193..df09f793 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -257,6 +257,36 @@ static bool wait_for_trs_completion(int timeout_sec) return false; } +/* + * An APDU that cannot fit the negotiated packet size must be turned away at + * submit time, not fail mysteriously somewhere mid-band. + */ +static bool test_trs_apdu_too_big_for_packet(void) +{ + /* Static: were a defective admission check to accept it, the queue + * would keep a reference past this function's frame. */ + static struct osdp_cmd cmd; + + printf(SUB_2 "testing TRS APDU beyond packet capacity\n"); + + if (OSDP_TRS_APDU_MAX_LEN + 64 <= OSDP_PACKET_BUF_SIZE) { + /* Carrier fits the packet with room to spare on this build + * config; nothing to reject. */ + return true; + } + + memset(&cmd, 0, sizeof(cmd)); + cmd.id = OSDP_CMD_XWR; + cmd.trs.command = OSDP_TRS_CMD_SEND_APDU; + cmd.trs.apdu.length = OSDP_TRS_APDU_MAX_LEN; + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd) == 0) { + printf(SUB_2 "packet-overflowing APDU must be rejected at submit\n"); + return false; + } + return true; +} + /* A card command with no START must be turned away at submit, not queued */ static bool test_trs_apdu_outside_band() { @@ -912,7 +942,7 @@ static bool test_trs_wire_oversized_apdu_reject(void) struct osdp_pd pd; struct osdp_cmd cmd; struct osdp_event ev; - uint8_t buf[128]; + uint8_t buf[OSDP_TRS_APDU_MAX_LEN + 16]; int r; printf(SUB_2 "testing TRS wire: oversized APDU rejection\n"); @@ -948,6 +978,54 @@ static bool test_trs_wire_oversized_apdu_reject(void) return true; } +/* + * PIV certificate reads move R-APDU chunks up to a short-APDU maximum of + * 258 bytes (255 data + SW1SW2 + margin); the carrier must hold them, and + * a chunk-sized APDU must survive the CP-build -> PD-decode round trip. + */ +static bool test_trs_wire_piv_class_apdu_roundtrip(void) +{ + struct osdp ctx; + struct osdp_pd pd; + struct osdp_cmd in, out; + uint8_t buf[512]; + int len, i, r; + const int apdu_len = 250; + + printf(SUB_2 "testing TRS wire: PIV-class APDU round-trip\n"); + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + + if (OSDP_TRS_APDU_MAX_LEN < 258) { + printf(SUB_2 "APDU carrier too small for PIV chunks: %d\n", + OSDP_TRS_APDU_MAX_LEN); + return false; + } + + memset(&in, 0, sizeof(in)); + memset(&out, 0, sizeof(out)); + in.id = OSDP_CMD_XWR; + in.trs.command = OSDP_TRS_CMD_SEND_APDU; + in.trs.apdu.length = apdu_len; + for (i = 0; i < apdu_len; i++) { + in.trs.apdu.data[i] = (uint8_t)i; + } + + len = osdp_trs_cmd_build(&pd, &in, buf, sizeof(buf)); + if (len != 3 + apdu_len) { + printf(SUB_2 "large APDU build: want len %d; got %d\n", + 3 + apdu_len, len); + return false; + } + r = osdp_trs_cmd_decode(&pd, &out, buf, len); + if (r != OSDP_TRS_DECODE_TO_APP || + out.trs.apdu.length != apdu_len || + memcmp(out.trs.apdu.data, in.trs.apdu.data, apdu_len) != 0) { + printf(SUB_2 "large APDU did not round-trip intact: %d\n", r); + return false; + } + return true; +} + /* Table 43: PIN-entry packed bytes must survive a CP-build -> PD-decode trip */ static bool test_trs_wire_pin_entry_roundtrip(void) { @@ -1147,6 +1225,7 @@ void run_trs_tests(struct test *t) result &= test_trs_wire_cmd_decode_actions(); result &= test_trs_wire_mode_gating(); result &= test_trs_wire_oversized_apdu_reject(); + result &= test_trs_wire_piv_class_apdu_roundtrip(); result &= test_trs_wire_pin_entry_roundtrip(); if (setup_test_environment(t) != 0) { @@ -1159,6 +1238,7 @@ void run_trs_tests(struct test *t) result &= test_trs_apdu_outside_band(); result &= test_trs_session_start(); result &= test_trs_non_trs_cmd_in_band(); + result &= test_trs_apdu_too_big_for_packet(); result &= test_trs_apdu_exchange(); result &= test_trs_deferred_apdu(); result &= test_trs_apdu_nak_keeps_session(); From e47488657827dc16960f5bf69b721bee84457710 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 16:31:32 +0200 Subject: [PATCH 06/22] tests: Add an emulated PIV card and end-to-end TRS scenarios The card (emu-card.c, libosdp-free) speaks just enough ISO 7816-4 to model the headline workflow: SELECT of the PIV AID, GET DATA of a 1.2KB certificate-sized object, and 61xx/GET RESPONSE chaining. The scenarios drive it through a real CP<->PD session over the mock channel, modeled on the reader behaviour reported in issue #22: a full chained certificate read, a session that must ride out a busy spell, a permanently-busy reader costing one soft-failed command, and a card leaving the field mid-band with no card-not-present courtesy from the reader. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- tests/unit-tests/emu-card.c | 157 ++++++++ tests/unit-tests/emu-card.h | 45 +++ tests/unit-tests/test-sources.list | 2 + tests/unit-tests/test-suites.def | 1 + tests/unit-tests/test-trs-emu.c | 564 +++++++++++++++++++++++++++++ tests/unit-tests/test.h | 1 + 6 files changed, 770 insertions(+) create mode 100644 tests/unit-tests/emu-card.c create mode 100644 tests/unit-tests/emu-card.h create mode 100644 tests/unit-tests/test-trs-emu.c diff --git a/tests/unit-tests/emu-card.c b/tests/unit-tests/emu-card.c new file mode 100644 index 00000000..8f4f798d --- /dev/null +++ b/tests/unit-tests/emu-card.c @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "emu-card.h" + +/* NIST SP 800-73 PIV application AID */ +const uint8_t emu_card_piv_aid[11] = { + 0xA0, 0x00, 0x00, 0x03, 0x08, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00 +}; + +/* Stand-in FCI for a successful SELECT */ +static const uint8_t emu_fci[] = { + 0x61, 0x11, 0x4F, 0x06, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, + 0x79, 0x07, 0x4F, 0x05, 0xA0, 0x00, 0x00, 0x03, 0x08 +}; + +/* The certificate-sized object; deterministic bytes so reads can be + * verified against emu_card_object() */ +static uint8_t emu_object[EMU_CARD_OBJECT_LEN]; +static bool emu_object_ready; + +static void emu_object_init(void) +{ + int i; + + if (emu_object_ready) { + return; + } + for (i = 0; i < EMU_CARD_OBJECT_LEN; i++) { + emu_object[i] = (uint8_t)(i * 7 + 3); + } + emu_object_ready = true; +} + +const uint8_t *emu_card_object(int *len) +{ + emu_object_init(); + *len = EMU_CARD_OBJECT_LEN; + return emu_object; +} + +void emu_card_reset(struct emu_card *card) +{ + memset(card, 0, sizeof(*card)); +} + +void emu_card_set_present(struct emu_card *card, bool present) +{ + card->present = present; + if (!present) { + card->selected = false; + card->chain_pos = 0; + card->chain_rem = 0; + } +} + +static int emu_sw(uint8_t *rapdu, int pos, uint8_t sw1, uint8_t sw2) +{ + rapdu[pos] = sw1; + rapdu[pos + 1] = sw2; + return pos + 2; +} + +/* Serve the next slice of the object; SW chains with 61xx while more waits */ +static int emu_serve_chunk(struct emu_card *card, int le, uint8_t *rapdu, + int max_rlen) +{ + int n = le; + + if (n > card->chain_rem) { + n = card->chain_rem; + } + if (n > max_rlen - 2) { + n = max_rlen - 2; + } + memcpy(rapdu, emu_object + card->chain_pos, n); + card->chain_pos += n; + card->chain_rem -= n; + if (card->chain_rem > 0) { + return emu_sw(rapdu, n, 0x61, + card->chain_rem > 255 ? 0 : card->chain_rem); + } + return emu_sw(rapdu, n, 0x90, 0x00); +} + +int emu_card_apdu(struct emu_card *card, const uint8_t *capdu, int clen, + uint8_t *rapdu, int max_rlen) +{ + uint8_t cla, ins, lc; + int le; + + emu_object_init(); + + if (!card->present) { + return -1; + } + if (clen < 4 || max_rlen < 2) { + return emu_sw(rapdu, 0, 0x67, 0x00); /* wrong length */ + } + cla = capdu[0]; + ins = capdu[1]; + + if (cla != 0x00) { + return emu_sw(rapdu, 0, 0x6E, 0x00); /* class not supported */ + } + + switch (ins) { + case 0xA4: /* SELECT */ + if (clen < 5) { + return emu_sw(rapdu, 0, 0x67, 0x00); + } + lc = capdu[4]; + if (clen < 5 + lc) { + return emu_sw(rapdu, 0, 0x67, 0x00); + } + /* The trailing 0x00 of the AID is the optional PIX end; accept + * a SELECT of the AID with or without it */ + if ((lc == sizeof(emu_card_piv_aid) || + lc == sizeof(emu_card_piv_aid) - 2) && + memcmp(capdu + 5, emu_card_piv_aid, lc) == 0) { + card->selected = true; + if (max_rlen < (int)sizeof(emu_fci) + 2) { + return emu_sw(rapdu, 0, 0x67, 0x00); + } + memcpy(rapdu, emu_fci, sizeof(emu_fci)); + return emu_sw(rapdu, sizeof(emu_fci), 0x90, 0x00); + } + return emu_sw(rapdu, 0, 0x6A, 0x82); /* file not found */ + case 0xCB: /* GET DATA */ + if (!card->selected) { + return emu_sw(rapdu, 0, 0x69, 0x82); /* security */ + } + if (capdu[2] != 0x3F || capdu[3] != 0xFF) { + return emu_sw(rapdu, 0, 0x6A, 0x82); + } + card->chain_pos = 0; + card->chain_rem = EMU_CARD_OBJECT_LEN; + le = capdu[clen - 1] ? capdu[clen - 1] : 256; + return emu_serve_chunk(card, le, rapdu, max_rlen); + case 0xC0: /* GET RESPONSE */ + if (card->chain_rem <= 0) { + return emu_sw(rapdu, 0, 0x69, 0x85); /* not chained */ + } + if (clen != 5) { + return emu_sw(rapdu, 0, 0x67, 0x00); + } + le = capdu[4] ? capdu[4] : 256; + return emu_serve_chunk(card, le, rapdu, max_rlen); + default: + return emu_sw(rapdu, 0, 0x6D, 0x00); /* INS not supported */ + } +} diff --git a/tests/unit-tests/emu-card.h b/tests/unit-tests/emu-card.h new file mode 100644 index 00000000..73fe79ae --- /dev/null +++ b/tests/unit-tests/emu-card.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _EMU_CARD_H_ +#define _EMU_CARD_H_ + +#include +#include + +/* + * A minimal ISO 7816-4 smart card, PIV-shaped: SELECT of the PIV AID, GET + * DATA of a certificate-sized object, and 61xx/GET RESPONSE chaining. Kept + * free of libosdp so it models only the card; whatever carries the APDUs + * (a TRS session, a future example app) is someone else's business. + */ + +#define EMU_CARD_OBJECT_LEN 1200 + +struct emu_card { + bool present; /* a card is in the reader's field */ + bool selected; /* PIV application selected */ + int chain_pos; /* GET RESPONSE read position into the object */ + int chain_rem; /* bytes still to be fetched via GET RESPONSE */ +}; + +extern const uint8_t emu_card_piv_aid[11]; + +void emu_card_reset(struct emu_card *card); +void emu_card_set_present(struct emu_card *card, bool present); + +/* The certificate-sized object GET DATA serves, for asserting reads */ +const uint8_t *emu_card_object(int *len); + +/* + * Run one C-APDU against the card. Returns the R-APDU length written to + * @rapdu (always >= 2: data then SW1 SW2), or -1 when no card is present + * to answer. + */ +int emu_card_apdu(struct emu_card *card, const uint8_t *capdu, int clen, + uint8_t *rapdu, int max_rlen); + +#endif /* _EMU_CARD_H_ */ diff --git a/tests/unit-tests/test-sources.list b/tests/unit-tests/test-sources.list index c1f9b734..a71a281d 100644 --- a/tests/unit-tests/test-sources.list +++ b/tests/unit-tests/test-sources.list @@ -21,4 +21,6 @@ test-codec-fuzz.c test-sc.c test-sc-sia-vectors.c test-trs.c +test-trs-emu.c +emu-card.c test-pd-zc.c diff --git a/tests/unit-tests/test-suites.def b/tests/unit-tests/test-suites.def index 71e137a5..c875802e 100644 --- a/tests/unit-tests/test-suites.def +++ b/tests/unit-tests/test-suites.def @@ -33,3 +33,4 @@ SUITE(codec_fuzz, run_codec_fuzz_tests) SUITE(sc, run_sc_tests) SUITE(vectors, run_vector_tests) SUITE(trs, run_trs_tests) +SUITE(trs_emu, run_trs_emu_tests) diff --git a/tests/unit-tests/test-trs-emu.c b/tests/unit-tests/test-trs-emu.c new file mode 100644 index 00000000..c7268780 --- /dev/null +++ b/tests/unit-tests/test-trs-emu.c @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Siddharth Chandrasekaran + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include "osdp_common.h" +#include "test.h" +#include "emu-card.h" + +#ifdef OPT_BUILD_OSDP_TRS + +/* + * End-to-end TRS scenarios against an emulated ISO 7816 PIV card sitting + * behind the PD's app layer, modeled on the workflows (and failure modes) + * reported from real readers in issue #22: a full chained certificate + * read, sessions that must survive BUSY spells, and a card leaving the + * field mid-band. + */ + +extern uint16_t test_osdp_compute_crc16(const uint8_t *buf, size_t len); + +/* Le the CP asks for per chunk; fits the default 256-byte packet */ +#define EMU_CHUNK_LE 200 + +struct trs_emu_ctx { + osdp_t *cp_ctx; + osdp_t *pd_ctx; + int cp_runner; + int pd_runner; + + struct emu_card card; + + /* PD reply event; submitted by reference from the command callback */ + struct osdp_event resp_event; + + /* CP side captures */ + bool event_seen; + struct osdp_trs_reply last_reply; + bool status_seen; + enum osdp_trs_session_status_e last_status; + bool completion_seen; + enum osdp_completion_status last_completion; +}; + +static struct trs_emu_ctx g_emu; + +/* Answer BUSY (plaintext, CRC, SQN 0) to the next N XWR commands seen */ +struct emu_busy_hook { + int busy_remaining; + int xwr_tx_count; +}; + +static int emu_busy_frame(uint8_t *out, int max_len) +{ + int len = 0, start; + uint16_t crc; + + if (max_len < 10) { + return -1; + } +#ifndef OPT_OSDP_SKIP_MARK_BYTE + out[len++] = 0xff; +#endif + start = len; + out[len++] = 0x53; + out[len++] = 0x65 | 0x80; + out[len++] = 8; /* header + id + CRC */ + out[len++] = 0; + out[len++] = 0x04; /* SQN 0, CRC, no SCB */ + out[len++] = REPLY_BUSY; + crc = test_osdp_compute_crc16(out + start, len - start); + out[len++] = crc & 0xff; + out[len++] = (crc >> 8) & 0xff; + return len; +} + +static enum test_channel_hook_verdict emu_busy_hook_fn(void *arg, + bool cp_to_pd, const uint8_t *frame, int len, + uint8_t *out, int *out_len, int out_max) +{ + struct emu_busy_hook *s = arg; + int base = 0, data_off, rc; + uint8_t ctrl; + + if (!cp_to_pd) + return TEST_HOOK_PASS; +#ifndef OPT_OSDP_SKIP_MARK_BYTE + base = 1; +#endif + if (len < base + 6) + return TEST_HOOK_PASS; + ctrl = frame[base + 4]; + data_off = base + 5; + if (ctrl & 0x08) /* security control block */ + data_off += frame[base + 5]; + if (data_off >= len || frame[data_off] != CMD_XWR) + return TEST_HOOK_PASS; + s->xwr_tx_count++; + if (s->busy_remaining == 0) + return TEST_HOOK_PASS; + if (s->busy_remaining > 0) + s->busy_remaining--; + rc = emu_busy_frame(out, out_max); + if (rc < 0) + return TEST_HOOK_PASS; + *out_len = rc; + return TEST_HOOK_INJECT_REPLY; +} + +static int emu_cp_event_callback(void *arg, int pd, struct osdp_event *ev) +{ + ARG_UNUSED(pd); + struct trs_emu_ctx *ctx = arg; + + if (ev->type == OSDP_EVENT_TRS) { + ctx->event_seen = true; + memcpy(&ctx->last_reply, &ev->trs, sizeof(ctx->last_reply)); + } + if (ev->type == OSDP_EVENT_NOTIFICATION && + ev->notif.type == OSDP_NOTIFICATION_TRS_STATUS) { + ctx->status_seen = true; + ctx->last_status = ev->notif.trs_status.status; + } + return 0; +} + +static void emu_cp_completion_callback(void *arg, int pd, + const struct osdp_cmd *cmd, + enum osdp_completion_status status) +{ + struct trs_emu_ctx *ctx = arg; + + ARG_UNUSED(pd); + if (cmd->id == OSDP_CMD_XWR) { + ctx->completion_seen = true; + ctx->last_completion = status; + } +} + +/* The PD app: every C-APDU runs against the emulated card */ +static int emu_pd_command_callback(void *arg, struct osdp_cmd *cmd) +{ + struct trs_emu_ctx *ctx = arg; + struct osdp_event *resp = &ctx->resp_event; + int rlen; + + if (cmd->id != OSDP_CMD_XWR || + cmd->trs.command != OSDP_TRS_CMD_SEND_APDU) { + return 0; + } + + memset(resp, 0, sizeof(*resp)); + resp->type = OSDP_EVENT_TRS; + rlen = emu_card_apdu(&ctx->card, cmd->trs.apdu.data, + cmd->trs.apdu.length, + resp->trs.card_data.apdu.data, + sizeof(resp->trs.card_data.apdu.data)); + if (rlen < 0) { + /* No card in the field to answer */ + resp->trs.reply = OSDP_TRS_REPLY_ERROR; + resp->trs.error.code = 0x01; + } else { + resp->trs.reply = OSDP_TRS_REPLY_CARD_DATA; + resp->trs.card_data.reader = 0; + resp->trs.card_data.status = 0; + resp->trs.card_data.apdu.length = rlen; + } + osdp_pd_submit_event(ctx->pd_ctx, resp); + return 0; +} + +static int emu_setup(struct test *t) +{ + printf(SUB_1 "setting up OSDP devices with an emulated PIV card\n"); + + if (test_setup_devices_ext(t, &g_emu.cp_ctx, &g_emu.pd_ctx, + OSDP_FLAG_ENABLE_NOTIFICATION, 0)) { + printf(SUB_1 "Failed to setup devices!\n"); + return -1; + } + emu_card_reset(&g_emu.card); + + osdp_cp_set_event_callback(g_emu.cp_ctx, emu_cp_event_callback, &g_emu); + osdp_cp_set_command_completion_callback(g_emu.cp_ctx, + emu_cp_completion_callback, + &g_emu); + osdp_pd_set_command_callback(g_emu.pd_ctx, emu_pd_command_callback, + &g_emu); + + g_emu.cp_runner = async_runner_start(g_emu.cp_ctx, osdp_cp_refresh); + g_emu.pd_runner = async_runner_start(g_emu.pd_ctx, osdp_pd_refresh); + if (g_emu.cp_runner < 0 || g_emu.pd_runner < 0) { + printf(SUB_1 "Failed to create CP/PD runners\n"); + return -1; + } + if (!test_wait_for_online(g_emu.cp_ctx, 0, 10)) { + printf(SUB_1 "PD failed to come online\n"); + return -1; + } + return 0; +} + +static void emu_teardown(void) +{ + printf(SUB_1 "tearing down emu test environment\n"); + test_set_channel_hook(NULL, NULL); + async_runner_stop(g_emu.cp_runner); + async_runner_stop(g_emu.pd_runner); + osdp_cp_teardown(g_emu.cp_ctx); + osdp_pd_teardown(g_emu.pd_ctx); + memset(&g_emu, 0, sizeof(g_emu)); +} + +static bool emu_wait_status(enum osdp_trs_session_status_e want, + int timeout_sec) +{ + int rc = 0; + + while (rc++ < timeout_sec * 10) { + if (g_emu.status_seen && g_emu.last_status == want) { + return true; + } + usleep(100 * 1000); + } + return false; +} + +static bool emu_session(enum osdp_trs_cmd_e command, + enum osdp_trs_session_status_e want) +{ + static struct osdp_cmd cmd; + + g_emu.status_seen = false; + cmd = (struct osdp_cmd){ + .id = OSDP_CMD_XWR, + .trs = { .command = command }, + }; + if (osdp_cp_submit_command(g_emu.cp_ctx, 0, &cmd)) { + printf(SUB_2 "failed to submit session command %d\n", command); + return false; + } + return emu_wait_status(want, 10); +} + +/* + * Round-trip one C-APDU through the session. Returns the R-APDU length + * (data + SW), or -1. @expect_fail flips the meaning of the completion. + */ +static int emu_xfer(const uint8_t *capdu, int clen, uint8_t *rapdu, + int max_rlen) +{ + static struct osdp_cmd cmd; + int rc = 0, rlen; + + g_emu.event_seen = false; + g_emu.completion_seen = false; + + cmd = (struct osdp_cmd){ + .id = OSDP_CMD_XWR, + .trs = { .command = OSDP_TRS_CMD_SEND_APDU }, + }; + cmd.trs.apdu.length = clen; + memcpy(cmd.trs.apdu.data, capdu, clen); + + if (osdp_cp_submit_command(g_emu.cp_ctx, 0, &cmd)) { + return -1; + } + while (rc++ < 150 && !g_emu.completion_seen) { + usleep(100 * 1000); + } + if (!g_emu.completion_seen || !g_emu.event_seen || + g_emu.last_reply.reply != OSDP_TRS_REPLY_CARD_DATA) { + return -1; + } + rlen = g_emu.last_reply.card_data.apdu.length; + if (rlen > max_rlen) { + return -1; + } + memcpy(rapdu, g_emu.last_reply.card_data.apdu.data, rlen); + return rlen; +} + +/* + * The headline workflow: detect the card, open a session, SELECT the PIV + * application, read a >1KB object with 61xx/GET RESPONSE chaining, close. + */ +static bool test_emu_piv_certificate_read(void) +{ + uint8_t capdu[16], rapdu[OSDP_TRS_APDU_MAX_LEN]; + uint8_t object[EMU_CARD_OBJECT_LEN]; + const uint8_t *want; + int rlen, pos = 0, want_len, clen; + + printf(SUB_2 "testing emu PIV certificate read\n"); + + emu_card_set_present(&g_emu.card, true); + + if (!emu_session(OSDP_TRS_CMD_START, OSDP_TRS_SESSION_OPENED)) { + printf(SUB_2 "session did not open\n"); + return false; + } + + /* SELECT the PIV AID */ + capdu[0] = 0x00; + capdu[1] = 0xA4; + capdu[2] = 0x04; + capdu[3] = 0x00; + capdu[4] = sizeof(emu_card_piv_aid); + memcpy(capdu + 5, emu_card_piv_aid, sizeof(emu_card_piv_aid)); + clen = 5 + sizeof(emu_card_piv_aid); + rlen = emu_xfer(capdu, clen, rapdu, sizeof(rapdu)); + if (rlen < 2 || rapdu[rlen - 2] != 0x90 || rapdu[rlen - 1] != 0x00) { + printf(SUB_2 "SELECT PIV AID failed (rlen %d)\n", rlen); + return false; + } + + /* GET DATA, then GET RESPONSE until the chain runs dry */ + capdu[0] = 0x00; + capdu[1] = 0xCB; + capdu[2] = 0x3F; + capdu[3] = 0xFF; + capdu[4] = EMU_CHUNK_LE; + clen = 5; + while (1) { + rlen = emu_xfer(capdu, clen, rapdu, sizeof(rapdu)); + if (rlen < 2) { + printf(SUB_2 "chained read failed at offset %d\n", pos); + return false; + } + if (pos + rlen - 2 > (int)sizeof(object)) { + printf(SUB_2 "read overflowed the object\n"); + return false; + } + memcpy(object + pos, rapdu, rlen - 2); + pos += rlen - 2; + if (rapdu[rlen - 2] == 0x90 && rapdu[rlen - 1] == 0x00) { + break; + } + if (rapdu[rlen - 2] != 0x61) { + printf(SUB_2 "unexpected SW %02x%02x\n", + rapdu[rlen - 2], rapdu[rlen - 1]); + return false; + } + capdu[0] = 0x00; + capdu[1] = 0xC0; + capdu[2] = 0x00; + capdu[3] = 0x00; + capdu[4] = EMU_CHUNK_LE; + clen = 5; + } + + want = emu_card_object(&want_len); + if (pos != want_len || memcmp(object, want, want_len) != 0) { + printf(SUB_2 "object mismatch: read %d of %d bytes\n", + pos, want_len); + return false; + } + + if (!emu_session(OSDP_TRS_CMD_STOP, OSDP_TRS_SESSION_CLOSED)) { + printf(SUB_2 "session did not close\n"); + return false; + } + return true; +} + +/* A busy spell mid-session must cost latency, never the session */ +static bool test_emu_session_survives_busy(void) +{ + struct emu_busy_hook s = { .busy_remaining = 2 }; + uint8_t capdu[16], rapdu[OSDP_TRS_APDU_MAX_LEN]; + int rlen, clen; + + printf(SUB_2 "testing emu session survives a busy spell\n"); + + emu_card_set_present(&g_emu.card, true); + if (!emu_session(OSDP_TRS_CMD_START, OSDP_TRS_SESSION_OPENED)) { + printf(SUB_2 "session did not open\n"); + return false; + } + + g_emu.status_seen = false; + test_set_channel_hook(emu_busy_hook_fn, &s); + capdu[0] = 0x00; + capdu[1] = 0xA4; + capdu[2] = 0x04; + capdu[3] = 0x00; + capdu[4] = sizeof(emu_card_piv_aid); + memcpy(capdu + 5, emu_card_piv_aid, sizeof(emu_card_piv_aid)); + clen = 5 + sizeof(emu_card_piv_aid); + rlen = emu_xfer(capdu, clen, rapdu, sizeof(rapdu)); + test_set_channel_hook(NULL, NULL); + + if (rlen < 2 || rapdu[rlen - 2] != 0x90) { + printf(SUB_2 "APDU did not survive the busy spell (%d)\n", + rlen); + return false; + } + if (s.xwr_tx_count != 3) { + printf(SUB_2 "want 2 busy retries (3 TX); saw %d\n", + s.xwr_tx_count); + return false; + } + if (g_emu.status_seen) { + printf(SUB_2 "busy spell must not end the session (%d)\n", + g_emu.last_status); + return false; + } + + return emu_session(OSDP_TRS_CMD_STOP, OSDP_TRS_SESSION_CLOSED); +} + +/* A permanently-busy reader costs the app one failed command and the + * session keeps running for whatever the app tries next */ +static bool test_emu_permanent_busy_fails_softly(void) +{ + struct emu_busy_hook s = { .busy_remaining = -1 }; + uint8_t capdu[] = { 0x00, 0xA4, 0x04, 0x00, 0x00 }; + uint8_t rapdu[OSDP_TRS_APDU_MAX_LEN]; + uint8_t status = 0; + int rc = 0, rlen; + + printf(SUB_2 "testing emu permanently busy reader\n"); + + emu_card_set_present(&g_emu.card, true); + if (!emu_session(OSDP_TRS_CMD_START, OSDP_TRS_SESSION_OPENED)) { + printf(SUB_2 "session did not open\n"); + return false; + } + + g_emu.status_seen = false; + g_emu.completion_seen = false; + test_set_channel_hook(emu_busy_hook_fn, &s); + if (emu_xfer(capdu, sizeof(capdu), rapdu, sizeof(rapdu)) >= 0) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "a never-answered APDU cannot succeed\n"); + return false; + } + test_set_channel_hook(NULL, NULL); + + while (rc++ < 20 && !g_emu.completion_seen) { + usleep(100 * 1000); + } + if (!g_emu.completion_seen || + g_emu.last_completion != OSDP_COMPLETION_FAILED) { + printf(SUB_2 "busy-exhausted APDU must complete as failed\n"); + return false; + } + osdp_get_status_mask(g_emu.cp_ctx, &status); + if (!(status & 1)) { + printf(SUB_2 "PD must stay online through busy exhaustion\n"); + return false; + } + + /* The session is still usable */ + uint8_t select_apdu[16]; + + select_apdu[0] = 0x00; + select_apdu[1] = 0xA4; + select_apdu[2] = 0x04; + select_apdu[3] = 0x00; + select_apdu[4] = sizeof(emu_card_piv_aid); + memcpy(select_apdu + 5, emu_card_piv_aid, sizeof(emu_card_piv_aid)); + rlen = emu_xfer(select_apdu, 5 + sizeof(emu_card_piv_aid), + rapdu, sizeof(rapdu)); + if (rlen < 2 || rapdu[rlen - 2] != 0x90) { + printf(SUB_2 "session unusable after busy exhaustion\n"); + return false; + } + + return emu_session(OSDP_TRS_CMD_STOP, OSDP_TRS_SESSION_CLOSED); +} + +/* + * Readers do not reliably announce a card leaving the field (issue #22); + * the app discovers it by the next APDU failing. That failure must cost + * the transaction, not the session, and STOP must still work. + */ +static bool test_emu_card_removed_mid_band(void) +{ + uint8_t capdu[16], rapdu[OSDP_TRS_APDU_MAX_LEN]; + int rlen, clen, rc = 0; + + printf(SUB_2 "testing emu card removed mid-band\n"); + + emu_card_set_present(&g_emu.card, true); + if (!emu_session(OSDP_TRS_CMD_START, OSDP_TRS_SESSION_OPENED)) { + printf(SUB_2 "session did not open\n"); + return false; + } + + capdu[0] = 0x00; + capdu[1] = 0xA4; + capdu[2] = 0x04; + capdu[3] = 0x00; + capdu[4] = sizeof(emu_card_piv_aid); + memcpy(capdu + 5, emu_card_piv_aid, sizeof(emu_card_piv_aid)); + clen = 5 + sizeof(emu_card_piv_aid); + rlen = emu_xfer(capdu, clen, rapdu, sizeof(rapdu)); + if (rlen < 2 || rapdu[rlen - 2] != 0x90) { + printf(SUB_2 "SELECT before removal failed\n"); + return false; + } + + /* Card leaves the field; the probe comes back as an error report */ + emu_card_set_present(&g_emu.card, false); + g_emu.status_seen = false; + g_emu.event_seen = false; + g_emu.completion_seen = false; + if (emu_xfer(capdu, clen, rapdu, sizeof(rapdu)) >= 0) { + printf(SUB_2 "APDU to an absent card cannot succeed\n"); + return false; + } + while (rc++ < 50 && !g_emu.completion_seen) { + usleep(100 * 1000); + } + if (!g_emu.completion_seen || + g_emu.last_completion != OSDP_COMPLETION_FAILED || + !g_emu.event_seen || + g_emu.last_reply.reply != OSDP_TRS_REPLY_ERROR) { + printf(SUB_2 "removal must surface as error + failed command\n"); + return false; + } + if (g_emu.status_seen) { + printf(SUB_2 "removal must not end the session by itself\n"); + return false; + } + + return emu_session(OSDP_TRS_CMD_STOP, OSDP_TRS_SESSION_CLOSED); +} + +void run_trs_emu_tests(struct test *t) +{ + bool result = true; + + printf("\nBegin TRS card-emulation Tests\n"); + + if (emu_setup(t) != 0) { + printf(SUB_1 "Failed to setup emu test environment\n"); + TEST_REPORT(t, false); + return; + } + + result &= test_emu_piv_certificate_read(); + result &= test_emu_session_survives_busy(); + result &= test_emu_permanent_busy_fails_softly(); + result &= test_emu_card_removed_mid_band(); + + emu_teardown(); + + printf(SUB_1 "TRS card-emulation tests %s\n", + result ? "succeeded" : "failed"); + TEST_REPORT(t, result); +} + +#else /* OPT_BUILD_OSDP_TRS */ + +void run_trs_emu_tests(struct test *t) +{ + ARG_UNUSED(t); +} + +#endif /* OPT_BUILD_OSDP_TRS */ diff --git a/tests/unit-tests/test.h b/tests/unit-tests/test.h index 812ed867..dfdca292 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -199,6 +199,7 @@ void run_codec_fuzz_tests(struct test *t); void run_sc_tests(struct test *t); void run_vector_tests(struct test *t); void run_trs_tests(struct test *t); /* no-op unless OPT_BUILD_OSDP_TRS */ +void run_trs_emu_tests(struct test *t); /* no-op unless OPT_BUILD_OSDP_TRS */ void run_pd_zc_tests(struct test *t); /* no-op unless OPT_OSDP_RX_ZERO_COPY */ #define printf(...) test_printf(__VA_ARGS__) From 62ba4fc32f65423a8c29bdddf5193c2090621844 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 16:46:19 +0200 Subject: [PATCH 07/22] tests: Run the TRS and BUSY suites under zero-copy too The channel-hook frame parsers assumed the leading mark byte from build options, but its presence also varies by direction and RX mode; detect it from the frame instead. Also add test-pd-zc.c to the lean build's test sources -- the zero-copy unit-test binary never linked there. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- tests/unit-tests/test-busy.c | 8 ++++---- tests/unit-tests/test-trs-emu.c | 8 ++++---- tests/unit-tests/test-trs.c | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/unit-tests/test-busy.c b/tests/unit-tests/test-busy.c index 615cb0dc..41521107 100644 --- a/tests/unit-tests/test-busy.c +++ b/tests/unit-tests/test-busy.c @@ -90,14 +90,14 @@ static enum test_channel_hook_verdict busy_hook(void *arg, bool cp_to_pd, int out_max) { struct busy_hook_state *s = arg; - int base = 0, data_off, rc; + int base, data_off, rc; uint8_t ctrl; if (!cp_to_pd) return TEST_HOOK_PASS; -#ifndef OPT_OSDP_SKIP_MARK_BYTE - base = 1; -#endif + /* The leading mark byte depends on build options and direction; + * detect it instead of assuming (a frame proper starts at SOM) */ + base = (len > 0 && frame[0] == 0xff) ? 1 : 0; if (len < base + 6) return TEST_HOOK_PASS; ctrl = frame[base + 4]; diff --git a/tests/unit-tests/test-trs-emu.c b/tests/unit-tests/test-trs-emu.c index c7268780..0ba063a1 100644 --- a/tests/unit-tests/test-trs-emu.c +++ b/tests/unit-tests/test-trs-emu.c @@ -81,14 +81,14 @@ static enum test_channel_hook_verdict emu_busy_hook_fn(void *arg, uint8_t *out, int *out_len, int out_max) { struct emu_busy_hook *s = arg; - int base = 0, data_off, rc; + int base, data_off, rc; uint8_t ctrl; if (!cp_to_pd) return TEST_HOOK_PASS; -#ifndef OPT_OSDP_SKIP_MARK_BYTE - base = 1; -#endif + /* The leading mark byte depends on build options and direction; + * detect it instead of assuming (a frame proper starts at SOM) */ + base = (len > 0 && frame[0] == 0xff) ? 1 : 0; if (len < base + 6) return TEST_HOOK_PASS; ctrl = frame[base + 4]; diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index df09f793..d702f3e7 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -620,13 +620,13 @@ static enum test_channel_hook_verdict trs_wire_nak_hook(void *arg, uint8_t *out, int *out_len, int out_max) { struct trs_wire_nak_hook *s = arg; - int base = 0, data_off, n = 0, start, pkt_len; + int base, data_off, n = 0, start, pkt_len; uint8_t ctrl; uint16_t crc; -#ifndef OPT_OSDP_SKIP_MARK_BYTE - base = 1; -#endif + /* The leading mark byte depends on build options and direction; + * detect it instead of assuming (a frame proper starts at SOM) */ + base = (len > 0 && frame[0] == 0xff) ? 1 : 0; if (len < base + 6) return TEST_HOOK_PASS; ctrl = frame[base + 4]; From 9924a16d4dbc46c587222f0af2d013fe8c7df9ce Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Sun, 19 Jul 2026 10:22:57 +0200 Subject: [PATCH 08/22] cp: Surface out-of-session XRD card sightings as events A reader may announce a card in its field as an XRD poll response with no TRS session open (v2.2 sections 5.11, 7.26.2, 7.26.8). Forward Mode-0 card-info and Mode-1 card-present reports to the app as OSDP_EVENT_TRS instead of discarding them; every other out-of-session XRD is still shrugged off without cycling the PD offline. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- src/osdp_cp.c | 19 +++++++++++++---- tests/unit-tests/test-trs.c | 42 +++++++++++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 98eba202..bf8e6a68 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -795,10 +795,21 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) break; case REPLY_XRD: if (!trs_active(pd)) { - /* A reader left in transparent mode (failed teardown, - * CP restart) keeps sending these as poll responses; - * shrug them off rather than cycling the PD offline. */ - LOG_WRN("Ignoring REPLY_XRD outside a TRS session"); + /* A reader may announce a card in its field as an XRD + * poll response with no session open (v2.2 sections + * 5.11, 7.26.2, 7.26.8); that sighting is for the app. + * Every other out-of-session XRD (a reader left in + * transparent mode after a failed teardown or a CP + * restart) is shrugged off rather than cycling the PD + * offline. */ + ret = osdp_trs_reply_decode(pd, buf + pos, len, &event); + if (ret == OSDP_TRS_REPLY_ACTION_DISPATCH && + (event.trs.reply == OSDP_TRS_REPLY_CARD_INFO || + event.trs.reply == OSDP_TRS_REPLY_CARD_PRESENT)) { + cp_dispatch_event(pd, &event); + } else { + LOG_WRN("Ignoring REPLY_XRD outside a TRS session"); + } ret = OSDP_CP_ERR_NONE; break; } diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index d702f3e7..a7fde216 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -1138,11 +1138,13 @@ static bool test_trs_error_reply() static struct osdp_event g_unsolicited_ev; /* - * A reader left in transparent mode (failed teardown, CP restart) keeps - * sending XRD poll responses. The CP has no session to route them to; it - * must shrug them off, not cycle the PD offline. + * With no session open, a reader may still announce a card in its field as an + * XRD poll response (v2.2 sections 5.11, 7.26.8); the CP must forward that + * sighting to the app. Any other out-of-session XRD (a reader left in + * transparent mode by a failed teardown or a CP restart) must be shrugged + * off -- not surfaced, and never a reason to cycle the PD offline. */ -static bool test_trs_unsolicited_xrd_keeps_pd_online(void) +static bool test_trs_unsolicited_xrd_out_of_session(void) { uint8_t status = 0; int rc = 0; @@ -1166,6 +1168,34 @@ static bool test_trs_unsolicited_xrd_keeps_pd_online(void) return false; } + if (!wait_for_trs_event(10)) { + printf(SUB_2 "out-of-session card sighting must surface\n"); + return false; + } + if (g_trs.last_reply.reply != OSDP_TRS_REPLY_CARD_PRESENT || + g_trs.last_reply.card_present.status != OSDP_TRS_CARD_PRESENT) { + printf(SUB_2 "card sighting not intact\n"); + return false; + } + + /* Card data with no session to route it to stays ignored */ + g_trs.event_seen = false; + g_unsolicited_ev = (struct osdp_event){ + .type = OSDP_EVENT_TRS, + .trs = { + .reply = OSDP_TRS_REPLY_CARD_DATA, + .card_data = { + .reader = 0, + .status = 0, + .apdu = { .length = 2, .data = { 0x90, 0x00 } }, + }, + }, + }; + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_unsolicited_ev)) { + printf(SUB_2 "failed to submit unsolicited card data\n"); + return false; + } + /* Long enough for the poll loop to carry it across and, were the CP * still intolerant, for the link to collapse. */ while (rc++ < 30) { @@ -1178,7 +1208,7 @@ static bool test_trs_unsolicited_xrd_keeps_pd_online(void) return false; } if (g_trs.event_seen) { - printf(SUB_2 "a stray XRD must not surface as an event\n"); + printf(SUB_2 "stray card data must not surface as an event\n"); return false; } return true; @@ -1246,7 +1276,7 @@ void run_trs_tests(struct test *t) result &= test_trs_card_present(); result &= test_trs_error_reply(); result &= test_trs_session_stop(); - result &= test_trs_unsolicited_xrd_keeps_pd_online(); + result &= test_trs_unsolicited_xrd_out_of_session(); teardown_test_environment(); From 34b4cc161d2dd448a010fbec6b5ac6bc22d07571 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Sun, 19 Jul 2026 12:37:13 +0200 Subject: [PATCH 09/22] trs: Add background card presence scan Some readers (HID RPK40) stop reporting ordinary credentials while in transparent mode and give no smart-card sighting in their default mode, so neither mode alone can read both credential types. Add an opt-in, library-managed scan (osdp_cp_trs_scan_enable) that time-slices the reader: dwell in the default mode, hold transparent mode briefly watching for a sighting, repeat. A sighting is forwarded as an OSDP_EVENT_TRS event and transparent mode is held so the band the app opens in response adopts the reader without renegotiating the mode. The probe rides the existing TRS session machinery; pd->trs.probe only suppresses session notifications and band flushing. Ordinary card or keypad activity restarts the default-mode dwell so a probe never cuts into an in-progress read. A refused probe raises OSDP_TRS_SCAN_SUSPENDED and retries with exponential backoff. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 85 ++++++++++++++++++++++++ platformio/osdp_config.h | 4 ++ src/osdp_common.h | 25 ++++++- src/osdp_config.h.in | 17 +++++ src/osdp_cp.c | 131 +++++++++++++++++++++++++++++++++++-- src/osdp_trs.c | 136 +++++++++++++++++++++++++++++++++++++++ src/osdp_trs.h | 50 ++++++++++++++ 7 files changed, 440 insertions(+), 8 deletions(-) diff --git a/include/osdp.h b/include/osdp.h index ee179cf6..63a0c9f9 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1217,6 +1217,14 @@ enum osdp_cmd_e { * own before its STOP is reached -- the reader rejects transparent mode, say -- * the APDUs left in the band are completed @c OSDP_COMPLETION_FLUSHED and an * @c OSDP_TRS_SESSION_FAILED notification says why. + * + * Knowing @b when to open a band is the reader's job: a smart card entering + * the field is announced as an @c OSDP_EVENT_TRS card-info/card-present event, + * either spontaneously (some readers report cards in their default mode) or + * because a presence scan (osdp_cp_trs_scan_enable()) is briefly holding the + * reader in transparent mode. Either way, react by submitting a START; while + * the scan has the reader in transparent mode the new band adopts it directly, + * with no extra mode negotiation on the wire. */ enum osdp_trs_cmd_e { OSDP_TRS_CMD_START = 1, /**< Open a card session */ @@ -1235,6 +1243,36 @@ enum osdp_trs_session_status_e { OSDP_TRS_SESSION_CLOSED, /**< STOP honoured; reader restored */ OSDP_TRS_SESSION_FAILED, /**< PD refused transparent mode, or the * session was aborted by a link error */ + OSDP_TRS_SCAN_SUSPENDED, /**< A presence-scan probe was refused by + * the reader; probing continues with + * exponential backoff */ +}; + +/** + * @brief Cadence of the background card presence scan + * (osdp_cp_trs_scan_enable()). A zero in any field selects that parameter's + * built-in default. + */ +struct osdp_trs_scan_params { + /** + * Time spent in the reader's default mode between probes, where + * ordinary credential reads work (default 100 ms). Restarted by + * ordinary card/keypad activity so a probe never cuts into an + * in-progress read. + */ + uint16_t mode0_dwell_ms; + /** + * Time spent per probe in transparent mode watching for a smart-card + * sighting (default 100 ms). + */ + uint16_t mode1_dwell_ms; + /** + * Once a probe sights a card, transparent mode is held this long + * (default 500 ms) waiting for the app to open a band in response; + * a band submitted within the hold adopts the reader as-is, with no + * extra mode negotiation on the wire. + */ + uint16_t hold_ms; }; /** @@ -2085,6 +2123,53 @@ int osdp_cp_submit_command(osdp_t *ctx, int pd, const struct osdp_cmd *cmd); OSDP_EXPORT int osdp_cp_flush_commands(osdp_t *ctx, int pd); +/** + * @brief Enable a background card presence scan on a TRS-capable PD, for + * readers that do not announce smart cards while in their default mode. + * + * While enabled and no card session (band) is open, the library time-slices + * the reader: it stays in the default mode for @a mode0_dwell_ms (ordinary + * credential reads keep working), then holds transparent mode for + * @a mode1_dwell_ms watching for a card sighting, and repeats. A sighting is + * delivered as an @c OSDP_EVENT_TRS event and transparent mode is held for + * @a hold_ms so the band the app opens in response adopts the reader without + * renegotiating the mode. See @ref osdp_trs_scan_params. + * + * The scan pauses by itself while a band or file transfer is in progress and + * resumes after. If the reader refuses a probe, an + * @c OSDP_TRS_SCAN_SUSPENDED notification is raised and probing continues + * with exponential backoff, so a reader that is only transiently unwilling + * recovers on its own; call osdp_cp_trs_scan_disable() to stop entirely. + * + * Readers that announce cards spontaneously in their default mode do not + * need this: their sightings are forwarded as @c OSDP_EVENT_TRS events as-is. + * + * @param ctx OSDP context + * @param pd PD offset (0-indexed) of this PD in `osdp_pd_info_t *` passed to + * osdp_cp_setup() + * @param params Scan cadence; NULL (or zero fields) selects the defaults + * + * @retval 0 on success + * @retval -1 on failure (TRS support not compiled in, or bad args) + */ +OSDP_EXPORT +int osdp_cp_trs_scan_enable(osdp_t *ctx, int pd, + const struct osdp_trs_scan_params *params); + +/** + * @brief Disable the background card presence scan on a PD. If a probe is + * in flight, the reader is restored to its default mode first. + * + * @param ctx OSDP context + * @param pd PD offset (0-indexed) of this PD in `osdp_pd_info_t *` passed to + * osdp_cp_setup() + * + * @retval 0 on success + * @retval -1 on failure (TRS support not compiled in, or bad args) + */ +OSDP_EXPORT +int osdp_cp_trs_scan_disable(osdp_t *ctx, int pd); + /** * @brief Get PD ID information as reported by the PD. Calling this method * before the CP has had a the chance to get this information will return diff --git a/platformio/osdp_config.h b/platformio/osdp_config.h index e280491a..ee8170c0 100644 --- a/platformio/osdp_config.h +++ b/platformio/osdp_config.h @@ -33,6 +33,10 @@ #define OSDP_CMD_MAX_RETRIES (8) #define OSDP_ONLINE_RETRY_WAIT_MAX_MS (300 * 1000u) #define OSDP_CMD_RETRY_WAIT_MS (800) +#define OSDP_TRS_SCAN_MODE0_DWELL_MS (100) +#define OSDP_TRS_SCAN_MODE1_DWELL_MS (100) +#define OSDP_TRS_SCAN_HOLD_MS (500) +#define OSDP_TRS_SCAN_BACKOFF_MAX_MS (5 * 1000u) #define OSDP_PACKET_BUF_SIZE (256) #define OSDP_RX_RB_SIZE (512) #define OSDP_CP_CMD_POOL_SIZE (4) diff --git a/src/osdp_common.h b/src/osdp_common.h index b98ccd7a..d57cb884 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -388,10 +388,11 @@ static inline __noreturn void die() #define CP_REQ_DISABLE 0x00000008 #define CP_REQ_ENABLE 0x00000010 #ifdef OPT_BUILD_OSDP_TRS -/* Open a TRS session (a START was dequeued). Posted by the online command - * selector and consumed like every other transition request in - * state_update(). */ +/* Open a TRS session: OPEN for an app band (a START was dequeued), SCAN for a + * library-initiated presence probe. Posted by the online command selector and + * consumed like every other transition request in state_update(). */ #define CP_REQ_TRS_OPEN 0x00000020 +#define CP_REQ_TRS_SCAN 0x00000040 #endif #define TRS_MODE_00 0x00 @@ -451,6 +452,24 @@ struct osdp_trs { * only for the CMD_XWR it answered. */ bool reply_error; + /* + * The TRS session in progress is a presence-scan probe the library + * opened on its own -- no app band, no session notifications; it winds + * down by mode-set alone (there is no card transaction to terminate). + */ + bool probe; + /* Background presence scan (osdp_cp_trs_scan_enable) */ + struct { + bool enabled; + bool holding; /* card sighted; hold mode-1 for the app's START */ + uint16_t mode0_dwell_ms; + uint16_t mode1_dwell_ms; + uint16_t hold_ms; + uint32_t backoff_ms; /* probe refused; 0 = no backoff pending */ + tick_t tstamp; /* mode-0 dwell / backoff anchor */ + tick_t probe_tstamp; /* when mode-1 was actually entered */ + tick_t hold_tstamp; /* last card sighting */ + } scan; }; #endif diff --git a/src/osdp_config.h.in b/src/osdp_config.h.in index 547ec6dd..5ab356aa 100644 --- a/src/osdp_config.h.in +++ b/src/osdp_config.h.in @@ -61,6 +61,23 @@ #define OSDP_ONLINE_RETRY_WAIT_MAX_MS (300 * 1000u) #endif +/* TRS presence-scan cadence defaults (see osdp_cp_trs_scan_enable) */ +#ifndef OSDP_TRS_SCAN_MODE0_DWELL_MS +#define OSDP_TRS_SCAN_MODE0_DWELL_MS (100) +#endif + +#ifndef OSDP_TRS_SCAN_MODE1_DWELL_MS +#define OSDP_TRS_SCAN_MODE1_DWELL_MS (100) +#endif + +#ifndef OSDP_TRS_SCAN_HOLD_MS +#define OSDP_TRS_SCAN_HOLD_MS (500) +#endif + +#ifndef OSDP_TRS_SCAN_BACKOFF_MAX_MS +#define OSDP_TRS_SCAN_BACKOFF_MAX_MS (5 * 1000u) +#endif + /* Protocol Limits */ #ifndef OSDP_CMD_MAX_RETRIES #define OSDP_CMD_MAX_RETRIES (8) diff --git a/src/osdp_cp.c b/src/osdp_cp.c index bf8e6a68..bc75214f 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -664,6 +664,7 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) } memcpy(event.keypress.data, buf + pos, event.keypress.length); cp_dispatch_event(pd, &event); + osdp_trs_scan_note_activity(pd); ret = OSDP_CP_ERR_NONE; break; case REPLY_RAW: @@ -682,6 +683,7 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) } memcpy(event.cardread.data, buf + pos, t); cp_dispatch_event(pd, &event); + osdp_trs_scan_note_activity(pd); ret = OSDP_CP_ERR_NONE; break; case REPLY_FMT: @@ -1344,10 +1346,23 @@ static int cp_get_online_command(struct osdp_pd *pd) return ret; } +#ifdef OPT_BUILD_OSDP_TRS + /* Sitting after the staged engines above pauses the presence scan for + * as long as any of them has work; a queued band parks it via + * trs.band_open, which is set at submit time. */ + if (osdp_trs_scan_probe_due(pd)) { + make_request(pd, CP_REQ_TRS_SCAN); + return -1; + } +#endif + return cp_get_poll_command(pd); } #ifdef OPT_BUILD_OSDP_TRS +static void notify_trs_status(struct osdp_pd *pd, + enum osdp_trs_session_status_e status); + /* * Command selector for a TRS session (the band of commands between a START and * its STOP). Mode negotiation and teardown are library-generated straight from @@ -1355,8 +1370,13 @@ static int cp_get_online_command(struct osdp_pd *pd) * serialize a session step instead of a card command); in XMIT the band's card * commands are drained in order, and CMD_POLL keeps the link warm between them. * - * Everything the queue can hand us here is a card command or the STOP that ends - * the band; cp_band_admit() turned away anything else when it was submitted. + * During an app band the queue can only hand us a card command or the STOP that + * ends it; cp_band_admit() turned away anything else when it was submitted. + * During a scan probe there is no band, so ordinary app commands may surface + * too: they run inline (mode 01 only suppresses the reader's own card + * processing) -- except a START, which is the app answering a sighting, and + * turns the probe's transparent session into a band with no extra mode + * negotiation on the wire. */ static int cp_get_trs_command(struct osdp_pd *pd) { @@ -1365,8 +1385,21 @@ static int cp_get_trs_command(struct osdp_pd *pd) switch (pd->trs.state) { case TRS_STATE_XMIT: if (cp_cmd_dequeue(pd, &cmd)) { + if (pd->trs.probe && osdp_trs_probe_expired(pd)) { + /* Probe is done: no card transaction to + * disconnect, just restore the default mode */ + pd->trs.state = TRS_STATE_TEARDOWN; + return CMD_XWR; + } return cp_get_poll_command(pd); } + if (pd->trs.probe && cp_cmd_is_trs(cmd, OSDP_TRS_CMD_START)) { + cp_discard_cmd(pd, cmd, OSDP_COMPLETION_OK); + osdp_trs_probe_adopted(pd); + LOG_INF("TRS session opened (adopted scan probe)"); + notify_trs_status(pd, OSDP_TRS_SESSION_OPENED); + return cp_get_trs_command(pd); + } if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_STOP)) { cp_discard_cmd(pd, cmd, OSDP_COMPLETION_OK); pd->trs.stop_pending = false; @@ -1813,6 +1846,7 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) switch (next) { case OSDP_CP_STATE_INIT: osdp_phy_state_reset(pd, true); + osdp_trs_probe_reset(pd); break; case OSDP_CP_STATE_ONLINE: CLEAR_FLAG(pd, PD_FLAG_TRS_ACTIVE); @@ -1821,6 +1855,16 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) * session outcome instead of a spurious online transition. */ if (cur == OSDP_CP_STATE_TRS_SETUP || cur == OSDP_CP_STATE_TRS_RUN) { + if (pd->trs.probe) { + /* A probe has no band to flush and no session + * to report; a refusal only advances the retry + * backoff and tells the app the scan stalled. */ + if (osdp_trs_probe_close(pd)) { + LOG_WRN("TRS: scan probe refused; backing off"); + notify_trs_status(pd, OSDP_TRS_SCAN_SUSPENDED); + } + break; + } LOG_INF("TRS session %s", pd->trs.failed ? "failed" : "closed"); if (pd->trs.stop_pending) { @@ -1873,6 +1917,13 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) break; case OSDP_CP_STATE_TRS_RUN: if (cur == OSDP_CP_STATE_TRS_SETUP) { + if (pd->trs.probe) { + /* Dwell starts when mode 01 is confirmed, not + * when the probe was scheduled */ + LOG_DBG("TRS: scan probe in transparent mode"); + osdp_trs_probe_note_run(pd); + break; + } LOG_INF("TRS session opened"); notify_trs_status(pd, OSDP_TRS_SESSION_OPENED); } @@ -2078,10 +2129,13 @@ static void notify_command_status(struct osdp_pd *pd, int status) OSDP_CMD_BIOMATCH; break; case CMD_XWR: - if (status) { + if (status || !pd->active_cmd) { /* The card's answer reaches the app as an OSDP_EVENT_TRS * of its own, now or on a later poll if the PD deferred - * it; only the failure needs a notification. */ + * it; only the failure needs a notification. And only + * for an app command: a library-generated session step + * (mode-set, terminate, scan probe) reports its outcome + * through OSDP_NOTIFICATION_TRS_STATUS instead. */ return; } app_cmd = OSDP_CMD_XWR; @@ -2104,6 +2158,9 @@ static void state_update(struct osdp_pd *pd) int err; bool status; enum osdp_cp_state_e next, cur = pd->state; +#ifdef OPT_BUILD_OSDP_TRS + bool trs_probe_open = false; +#endif if (cp_phy_running(pd)) { err = cp_phy_state_update(pd); @@ -2179,10 +2236,17 @@ static void state_update(struct osdp_pd *pd) next = OSDP_CP_STATE_OFFLINE; } #ifdef OPT_BUILD_OSDP_TRS - /* A START was dequeued: open a TRS session. */ + /* A START was dequeued, or a presence probe fell due: + * open a TRS session. SCAN also carries the probe flag + * so the entry bookkeeping below can strip the app-band + * state that TRS_SETUP sets up by default. */ if (check_request(pd, CP_REQ_TRS_OPEN)) { next = OSDP_CP_STATE_TRS_SETUP; } + if (check_request(pd, CP_REQ_TRS_SCAN)) { + next = OSDP_CP_STATE_TRS_SETUP; + trs_probe_open = true; + } #endif } } @@ -2204,6 +2268,13 @@ static void state_update(struct osdp_pd *pd) if (cur != next) { cp_state_change(pd, next); } +#ifdef OPT_BUILD_OSDP_TRS + if (trs_probe_open && next == OSDP_CP_STATE_TRS_SETUP) { + /* A probe undoes the app-band bookkeeping the TRS_SETUP entry + * just set up: no band to flush, no session notifications. */ + osdp_trs_probe_note_open(pd); + } +#endif } #ifdef OPT_BUILD_OSDP_TRS @@ -2666,6 +2737,56 @@ int osdp_cp_flush_commands(osdp_t *ctx, int pd_idx) return count; } +int osdp_cp_trs_scan_enable(osdp_t *ctx, int pd_idx, + const struct osdp_trs_scan_params *params) +{ + input_check(ctx, pd_idx); +#ifdef OPT_BUILD_OSDP_TRS + struct osdp_pd *pd = osdp_to_pd(ctx, pd_idx); + struct osdp_trs *trs = &pd->trs; + + trs->scan.mode0_dwell_ms = (params && params->mode0_dwell_ms) ? + params->mode0_dwell_ms : OSDP_TRS_SCAN_MODE0_DWELL_MS; + trs->scan.mode1_dwell_ms = (params && params->mode1_dwell_ms) ? + params->mode1_dwell_ms : OSDP_TRS_SCAN_MODE1_DWELL_MS; + trs->scan.hold_ms = (params && params->hold_ms) ? + params->hold_ms : OSDP_TRS_SCAN_HOLD_MS; + trs->scan.backoff_ms = 0; + trs->scan.holding = false; + trs->scan.tstamp = osdp_millis_now(); + trs->scan.enabled = true; + /* Capability is only known once the PD has been online; before that the + * probe gate re-checks it every cycle and simply never fires. */ + if (pd->state == OSDP_CP_STATE_ONLINE && !trs_capable(pd)) { + LOG_WRN("TRS: scan enabled on a PD with no smart-card reader"); + } + return 0; +#else + ARG_UNUSED(params); + LOG_PRINT("TRS support not built in"); + return -1; +#endif +} + +int osdp_cp_trs_scan_disable(osdp_t *ctx, int pd_idx) +{ + input_check(ctx, pd_idx); +#ifdef OPT_BUILD_OSDP_TRS + struct osdp_pd *pd = osdp_to_pd(ctx, pd_idx); + + /* A probe in flight winds down on its own: with the scan off, + * osdp_trs_probe_expired() is immediately true and the next TRS tick + * restores the reader's default mode. */ + pd->trs.scan.enabled = false; + pd->trs.scan.holding = false; + pd->trs.scan.backoff_ms = 0; + return 0; +#else + LOG_PRINT("TRS support not built in"); + return -1; +#endif +} + int osdp_cp_get_pd_id(const osdp_t *ctx, int pd_idx, struct osdp_pd_id *id) { input_check(ctx, pd_idx); diff --git a/src/osdp_trs.c b/src/osdp_trs.c index afb8e576..7af910bf 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -376,6 +376,32 @@ int osdp_trs_cmd_build(struct osdp_pd *pd, const struct osdp_cmd *cmd, /* --- CP: decode incoming REPLY_XRD into an app event --- */ +/* + * A decoded card sighting feeds the presence scan: during a probe it starts + * (or, on a card-gone report, releases) the mode-1 hold that gives the app + * time to open a band; outside one it restarts the mode-0 dwell so the scan + * stays out of the way of whatever the sighting kicks off. + */ +static void trs_scan_note_card(struct osdp_pd *pd, bool present) +{ + struct osdp_trs *trs = &pd->trs; + + if (!trs->scan.enabled) { + return; + } + if (!trs->probe) { + osdp_trs_scan_note_activity(pd); + return; + } + if (!present) { + trs->scan.holding = false; /* card left; let the probe close */ + return; + } + trs->scan.holding = true; + trs->scan.hold_tstamp = osdp_millis_now(); + trs->scan.backoff_ms = 0; +} + /* * Decode a REPLY_XRD payload into *event. Returns <0 on error, else one of * enum osdp_trs_reply_action_e telling osdp_cp.c whether the reply carried @@ -447,6 +473,7 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, memcpy(event->trs.card_info.protocol_data, buf + pos, prot_data_len); pos += prot_data_len; + trs_scan_note_card(pd, true); break; case REPLY_CARD_PRESENT: event->trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; @@ -459,6 +486,7 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, * without it the reply itself is the news that a card * is there, on an unspecified interface. */ event->trs.card_present.status = OSDP_TRS_CARD_PRESENT; + trs_scan_note_card(pd, true); break; } card_status = buf[pos++]; @@ -468,6 +496,8 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, card_status); return -1; } + trs_scan_note_card(pd, event->trs.card_present.status != + OSDP_TRS_CARD_NOT_PRESENT); break; case REPLY_ERROR_MODE0: case REPLY_ERROR_MODE1: @@ -853,3 +883,109 @@ enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd) return OSDP_CP_STATE_ONLINE; } } + +/* --- CP: background presence scan --- */ + +/* + * A probe is a library-initiated TRS session with no app band behind it: it + * enters mode 01, polls for a card sighting for scan.mode1_dwell_ms (longer if + * one arrives and the hold kicks in), and restores mode 00. The ordinary + * session machinery drives it on the wire; the pd->trs.probe flag is what + * keeps it from raising session notifications or flushing anything. + */ + +bool osdp_trs_scan_probe_due(struct osdp_pd *pd) +{ + struct osdp_trs *trs = &pd->trs; + tick_t wait = trs->scan.mode0_dwell_ms; + + if (!trs->scan.enabled || !trs_capable(pd) || trs->band_open) { + return false; + } + if (trs->scan.backoff_ms > wait) { + wait = trs->scan.backoff_ms; + } + return osdp_millis_since(trs->scan.tstamp) > wait; +} + +bool osdp_trs_probe_expired(struct osdp_pd *pd) +{ + struct osdp_trs *trs = &pd->trs; + + if (!trs->scan.enabled) { + return true; + } + if (trs->scan.holding) { + return osdp_millis_since(trs->scan.hold_tstamp) > + trs->scan.hold_ms; + } + return osdp_millis_since(trs->scan.probe_tstamp) > + trs->scan.mode1_dwell_ms; +} + +/* The probe's session opened; undo the app-band bookkeeping TRS_SETUP set */ +void osdp_trs_probe_note_open(struct osdp_pd *pd) +{ + pd->trs.probe = true; + pd->trs.stop_pending = false; + pd->trs.scan.holding = false; +} + +/* Mode 01 confirmed; the probe's dwell clock starts now */ +void osdp_trs_probe_note_run(struct osdp_pd *pd) +{ + pd->trs.scan.probe_tstamp = osdp_millis_now(); +} + +/* An app band takes over the probe's transparent session as-is */ +void osdp_trs_probe_adopted(struct osdp_pd *pd) +{ + pd->trs.probe = false; + pd->trs.stop_pending = true; + pd->trs.scan.holding = false; +} + +/* + * The probe's session is over (back to ONLINE). Returns true when the reader + * refused it, in which case the retry backoff has been advanced and the + * caller should raise OSDP_TRS_SCAN_SUSPENDED. + */ +bool osdp_trs_probe_close(struct osdp_pd *pd) +{ + struct osdp_trs *trs = &pd->trs; + + trs->probe = false; + trs->scan.holding = false; + trs->scan.tstamp = osdp_millis_now(); + if (!trs->failed) { + trs->scan.backoff_ms = 0; + return false; + } + if (trs->scan.backoff_ms == 0) { + trs->scan.backoff_ms = trs->scan.mode0_dwell_ms; + } + trs->scan.backoff_ms *= 2; + if (trs->scan.backoff_ms > OSDP_TRS_SCAN_BACKOFF_MAX_MS) { + trs->scan.backoff_ms = OSDP_TRS_SCAN_BACKOFF_MAX_MS; + } + return true; +} + +/* Forget any probe in flight (PD is re-initializing) */ +void osdp_trs_probe_reset(struct osdp_pd *pd) +{ + pd->trs.probe = false; + pd->trs.scan.holding = false; + pd->trs.scan.tstamp = osdp_millis_now(); +} + +/* + * Ordinary (mode 00) card or keypad activity: restart the mode-0 dwell so a + * probe never slices into a read the user is in the middle of. + */ +void osdp_trs_scan_note_activity(struct osdp_pd *pd) +{ + if (pd->trs.scan.enabled && !pd->trs.probe) { + pd->trs.scan.tstamp = osdp_millis_now(); + } +} diff --git a/src/osdp_trs.h b/src/osdp_trs.h index 9f3dc902..4923778f 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -44,6 +44,21 @@ int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, /* Largest C-APDU the negotiated packet size can carry for this PD */ int osdp_trs_max_apdu_len(struct osdp_pd *pd); +/* + * Background presence scan (osdp_cp_trs_scan_enable): probe scheduling and + * bookkeeping. The probe rides the ordinary TRS session machinery; these + * helpers only decide when one opens/closes and what it must not do (raise + * session notifications, flush the band). + */ +bool osdp_trs_scan_probe_due(struct osdp_pd *pd); +bool osdp_trs_probe_expired(struct osdp_pd *pd); +void osdp_trs_probe_note_open(struct osdp_pd *pd); +void osdp_trs_probe_note_run(struct osdp_pd *pd); +void osdp_trs_probe_adopted(struct osdp_pd *pd); +bool osdp_trs_probe_close(struct osdp_pd *pd); +void osdp_trs_probe_reset(struct osdp_pd *pd); +void osdp_trs_scan_note_activity(struct osdp_pd *pd); + #else /* OPT_BUILD_OSDP_TRS */ static inline int osdp_trs_cmd_build(struct osdp_pd *pd, @@ -81,6 +96,41 @@ static inline int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, ARG_UNUSED(len); return -1; } +static inline bool osdp_trs_scan_probe_due(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); + return false; +} +static inline bool osdp_trs_probe_expired(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); + return true; +} +static inline void osdp_trs_probe_note_open(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} +static inline void osdp_trs_probe_note_run(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} +static inline void osdp_trs_probe_adopted(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} +static inline bool osdp_trs_probe_close(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); + return false; +} +static inline void osdp_trs_probe_reset(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} +static inline void osdp_trs_scan_note_activity(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} #endif /* OPT_BUILD_OSDP_TRS */ From 05cbc3ed783726c73d0b01f8c95c110bd1c14fa5 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Sun, 19 Jul 2026 13:19:52 +0200 Subject: [PATCH 10/22] trs: Restore mode 00 even when the mode-set reply is lost A failed mode-set does not prove the reader stayed in its default mode: it may have entered transparent mode and had its reply lost or mangled on the wire, and a reader stuck in mode 01 stops reporting ordinary card reads. Mode-set(00) is idempotent, so always spend one teardown round trip instead of assuming there is nothing to undo. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- src/osdp_trs.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 7af910bf..65c072e1 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -869,6 +869,15 @@ enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd) pd->trs.failed = true; switch (pd->trs.state) { + case TRS_STATE_SET_MODE: + /* + * The reader gave no usable ACK, but that does not prove it + * stayed in mode 00 -- it may have entered transparent mode + * and had its reply lost or mangled, and a reader stuck in + * mode 01 stops reporting ordinary card reads. Mode-set(00) + * is idempotent, so pay one extra round trip to be sure. + */ + __fallthrough; case TRS_STATE_XMIT: case TRS_STATE_DISCONNECT_CARD: LOG_WRN("TRS: session error; restoring transparent mode off"); @@ -877,7 +886,6 @@ enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd) case TRS_STATE_TEARDOWN: LOG_ERR("TRS: failed to restore transparent mode off"); __fallthrough; - case TRS_STATE_SET_MODE: /* mode never took effect; nothing to undo */ default: pd->trs.state = TRS_STATE_DONE; return OSDP_CP_STATE_ONLINE; From 2903063064e4e0dabb6c6579cfd22a64718a68ae Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Tue, 21 Jul 2026 22:34:25 +0200 Subject: [PATCH 11/22] tests: Cover the TRS presence scan end to end Scan cadence (silent mode flips, no notification/completion spam), mode-0 activity deferring probes, disable/flush mid-probe, sighting hold with band adoption (exactly one mode-set(01) on the wire from enable to band close), refused-probe backoff with recovery, and an emulated RPK40 workflow: standard read, smart-card sighting during a probe, full chained PIV certificate read over the adopted session, standard read again. Related-to: #22 Signed-off-by: Siddharth Chandrasekaran --- tests/unit-tests/test-trs-emu.c | 135 +++++++++++ tests/unit-tests/test-trs.c | 406 ++++++++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) diff --git a/tests/unit-tests/test-trs-emu.c b/tests/unit-tests/test-trs-emu.c index 0ba063a1..26dec535 100644 --- a/tests/unit-tests/test-trs-emu.c +++ b/tests/unit-tests/test-trs-emu.c @@ -42,6 +42,7 @@ struct trs_emu_ctx { enum osdp_trs_session_status_e last_status; bool completion_seen; enum osdp_completion_status last_completion; + bool cardread_seen; }; static struct trs_emu_ctx g_emu; @@ -118,6 +119,9 @@ static int emu_cp_event_callback(void *arg, int pd, struct osdp_event *ev) ctx->event_seen = true; memcpy(&ctx->last_reply, &ev->trs, sizeof(ctx->last_reply)); } + if (ev->type == OSDP_EVENT_CARDREAD) { + ctx->cardread_seen = true; + } if (ev->type == OSDP_EVENT_NOTIFICATION && ev->notif.type == OSDP_NOTIFICATION_TRS_STATUS) { ctx->status_seen = true; @@ -530,6 +534,136 @@ static bool test_emu_card_removed_mid_band(void) return emu_session(OSDP_TRS_CMD_STOP, OSDP_TRS_SESSION_CLOSED); } +/* Sample the emulated reader's mode every 10ms until it reads @want */ +static bool emu_wait_mode(uint8_t want, int timeout_ms) +{ + struct osdp_pd *pd = osdp_to_pd(g_emu.pd_ctx, 0); + int waited = 0; + + while (waited < timeout_ms) { + if (pd->trs.mode == want) { + return true; + } + usleep(10 * 1000); + waited += 10; + } + return false; +} + +/* Event submitted by reference; must outlive the callback that ships it */ +static struct osdp_event g_rpk40_ev; + +/* An RPK40 reports ordinary credentials only in mode 00: submit one standard + * read the next time the reader is in its default mode, and confirm the CP + * app received it */ +static bool emu_rpk40_standard_read(void) +{ + int rc = 0; + + g_emu.cardread_seen = false; + if (!emu_wait_mode(TRS_MODE_00, 5000)) { + printf(SUB_2 "reader never in default mode\n"); + return false; + } + g_rpk40_ev = (struct osdp_event){ + .type = OSDP_EVENT_CARDREAD, + .cardread = { + .reader_no = 0, + .format = OSDP_CARD_FMT_RAW_WIEGAND, + .length = 32, /* raw formats count bits, not bytes */ + .data = { 0xde, 0xad, 0xbe, 0xef }, + }, + }; + if (osdp_pd_submit_event(g_emu.pd_ctx, &g_rpk40_ev)) { + printf(SUB_2 "failed to submit standard read\n"); + return false; + } + while (rc++ < 30 && !g_emu.cardread_seen) { + usleep(100 * 1000); + } + if (!g_emu.cardread_seen) { + printf(SUB_2 "standard read never reached the app\n"); + return false; + } + return true; +} + +/* + * The RPK40 workflow from issue #22: the reader reports ordinary credentials + * only in its default mode and gives no smart-card indication there, so the + * presence scan is the only way to serve both credential types. A standard + * read works; a smart card is sighted during a probe; the full chained PIV + * certificate read runs over the adopted session; a standard read works + * again once the scan resumes. + */ +static bool test_emu_rpk40_time_slice(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 100, + .mode1_dwell_ms = 300, + .hold_ms = 3000, + }; + + printf(SUB_2 "testing emu RPK40 mode time-slicing\n"); + + emu_card_set_present(&g_emu.card, false); + if (osdp_cp_trs_scan_enable(g_emu.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + + /* A standard credential, between probes */ + if (!emu_rpk40_standard_read()) { + return false; + } + + /* A smart card arrives; only a probe can see it */ + g_emu.event_seen = false; + emu_card_set_present(&g_emu.card, true); + if (!emu_wait_mode(TRS_MODE_01, 5000)) { + printf(SUB_2 "no probe to sight the smart card in\n"); + return false; + } + g_rpk40_ev = (struct osdp_event){ + .type = OSDP_EVENT_TRS, + .trs = { + .reply = OSDP_TRS_REPLY_CARD_PRESENT, + .card_present = { + .reader = 0, + .status = OSDP_TRS_CARD_PRESENT_CONTACTLESS, + }, + }, + }; + if (osdp_pd_submit_event(g_emu.pd_ctx, &g_rpk40_ev)) { + printf(SUB_2 "failed to submit sighting\n"); + return false; + } + int rc = 0; + while (rc++ < 30 && !g_emu.event_seen) { + usleep(100 * 1000); + } + if (!g_emu.event_seen || + g_emu.last_reply.reply != OSDP_TRS_REPLY_CARD_PRESENT) { + printf(SUB_2 "sighting never reached the app\n"); + return false; + } + + /* The app answers with a band: the whole PIV workflow, adopted */ + if (!test_emu_piv_certificate_read()) { + printf(SUB_2 "PIV read over the adopted session failed\n"); + return false; + } + + /* The card leaves; ordinary credentials must keep working */ + emu_card_set_present(&g_emu.card, false); + if (!emu_rpk40_standard_read()) { + return false; + } + + osdp_cp_trs_scan_disable(g_emu.cp_ctx, 0); + return emu_wait_mode(TRS_MODE_00, 3000); +} + void run_trs_emu_tests(struct test *t) { bool result = true; @@ -546,6 +680,7 @@ void run_trs_emu_tests(struct test *t) result &= test_emu_session_survives_busy(); result &= test_emu_permanent_busy_fails_softly(); result &= test_emu_card_removed_mid_band(); + result &= test_emu_rpk40_time_slice(); emu_teardown(); diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index a7fde216..f03cd1b9 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -257,6 +257,26 @@ static bool wait_for_trs_completion(int timeout_sec) return false; } +static uint8_t pd_trs_mode(void) +{ + return osdp_to_pd(g_trs.pd_ctx, 0)->trs.mode; +} + +/* Sample the PD's reader mode every 10ms until it reads @want */ +static bool wait_for_pd_mode(uint8_t want, int timeout_ms) +{ + int waited = 0; + + while (waited < timeout_ms) { + if (pd_trs_mode() == want) { + return true; + } + usleep(10 * 1000); + waited += 10; + } + return false; +} + /* * An APDU that cannot fit the negotiated packet size must be turned away at * submit time, not fail mysteriously somewhere mid-band. @@ -718,6 +738,205 @@ static bool test_trs_card_scan_wire_nak(void) return test_trs_apdu_exchange(); } +/* Count mode-set commands on the wire (plaintext link only: the payload of a + * secure frame is encrypted, so there is nothing to parse there) */ +struct trs_mode_set_counter { + int set_mode1; + int set_mode0; +}; + +static enum test_channel_hook_verdict trs_mode_set_count_hook(void *arg, + bool cp_to_pd, const uint8_t *frame, int len, + uint8_t *out, int *out_len, int out_max) +{ + struct trs_mode_set_counter *s = arg; + int base, data_off; + uint8_t ctrl; + + ARG_UNUSED(out); + ARG_UNUSED(out_len); + ARG_UNUSED(out_max); + + if (!cp_to_pd) + return TEST_HOOK_PASS; + base = (len > 0 && frame[0] == 0xff) ? 1 : 0; + if (len < base + 6) + return TEST_HOOK_PASS; + ctrl = frame[base + 4]; + data_off = base + 5; + if (ctrl & 0x08) /* security control block */ + data_off += frame[base + 5]; + /* CMD_XWR carrying [XRW_MODE=00, PCMND=02 (mode-set), target mode] */ + if (data_off + 3 >= len || frame[data_off] != CMD_XWR || + frame[data_off + 1] != 0x00 || frame[data_off + 2] != 0x02) + return TEST_HOOK_PASS; + if (frame[data_off + 3] == TRS_MODE_01) + s->set_mode1++; + else + s->set_mode0++; + return TEST_HOOK_PASS; +} + +/* + * The point of the hold: a sighting during a probe keeps the reader in + * transparent mode long enough for the app's START to take the session over + * as-is. The wire must show exactly one mode-set(01) -- the probe's -- from + * enable to band close: adoption renegotiates nothing. + */ +static bool test_trs_scan_hold_and_adopt(void) +{ + struct trs_mode_set_counter s = { 0 }; + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 300, + .hold_ms = 3000, + }; + struct osdp_cmd start, stop; + + printf(SUB_2 "testing TRS sighting hold and band adoption\n"); + + g_trs.event_seen = false; + g_trs.status_seen = false; + + test_set_channel_hook(trs_mode_set_count_hook, &s); + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to enable scan\n"); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_01, 3000)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "no probe to sight a card in\n"); + return false; + } + + /* The reader sights a card during the probe */ + memset(&g_trs.resp_event, 0, sizeof(g_trs.resp_event)); + g_trs.resp_event.type = OSDP_EVENT_TRS; + g_trs.resp_event.trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + g_trs.resp_event.trs.card_present.reader = 0; + g_trs.resp_event.trs.card_present.status = + OSDP_TRS_CARD_PRESENT_CONTACTLESS; + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_trs.resp_event)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to submit sighting\n"); + return false; + } + if (!wait_for_trs_event(10) || + g_trs.last_reply.reply != OSDP_TRS_REPLY_CARD_PRESENT) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "sighting not delivered to the app\n"); + return false; + } + + /* The app answers with a band */ + if (submit_trs_cmd(&start, OSDP_TRS_CMD_START) || + !wait_for_trs_status(OSDP_TRS_SESSION_OPENED, 10)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "band did not open on the sighting\n"); + return false; + } + if (!test_trs_apdu_exchange()) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "adopted session unusable\n"); + return false; + } + /* Stop scanning before the band closes so no fresh probe muddies the + * mode-set count between the CLOSED notification and the checks */ + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + g_trs.status_seen = false; + if (submit_trs_cmd(&stop, OSDP_TRS_CMD_STOP) || + !wait_for_trs_status(OSDP_TRS_SESSION_CLOSED, 10)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "band did not close\n"); + return false; + } + test_set_channel_hook(NULL, NULL); + + if (s.set_mode1 != 1) { + printf(SUB_2 "adoption must not renegotiate the mode; " + "saw %d mode-set(01)\n", s.set_mode1); + return false; + } + if (s.set_mode0 < 1) { + printf(SUB_2 "band close must restore the default mode\n"); + return false; + } + return true; +} + +/* + * A reader that refuses probes (NAK to every XWR) must cost the app one + * SCAN_SUSPENDED notification per attempt and progressively fewer attempts + * (exponential backoff) -- never the link. Removing the fault heals the scan. + */ +static bool test_trs_scan_probe_failure_backoff(void) +{ + struct trs_wire_nak_hook s = { 0 }; + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 100, + }; + uint8_t status = 0; + int early_count, late_count; + + printf(SUB_2 "testing TRS scan probe failure backoff\n"); + + g_trs.status_seen = false; + + test_set_channel_hook(trs_wire_nak_hook, &s); + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to enable scan\n"); + return false; + } + + /* Give the first refused probes time to happen and back off */ + usleep(2000 * 1000); + early_count = s.xwr_tx_count; + if (early_count < 2) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "refused probes never retried (%d)\n", early_count); + return false; + } + if (!g_trs.status_seen || + g_trs.last_status != OSDP_TRS_SCAN_SUSPENDED) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "refused probe must raise SCAN_SUSPENDED\n"); + return false; + } + + /* Backed off: the next window must see far fewer transmissions than + * the ~25 probes an 80ms cadence would fire. A refused probe costs two + * XWRs -- the mode-set and the just-in-case teardown behind it. */ + usleep(3000 * 1000); + late_count = s.xwr_tx_count - early_count; + if (late_count > 4) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "no backoff: %d transmissions in 3s\n", late_count); + return false; + } + osdp_get_status_mask(g_trs.cp_ctx, &status); + if (!(status & 1)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "refused probes must not cost the link\n"); + return false; + } + + /* Fault gone: probing must recover on its own */ + test_set_channel_hook(NULL, NULL); + if (!wait_for_pd_mode(TRS_MODE_01, 8000)) { + printf(SUB_2 "scan did not recover after the fault cleared\n"); + return false; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + if (!wait_for_pd_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "reader stuck in mode 01 after recovery\n"); + return false; + } + return true; +} + /* --- wire-level unit tests: assert exact on-wire bytes vs SIA OSDP 2.2 --- */ /* @@ -1214,6 +1433,188 @@ static bool test_trs_unsolicited_xrd_out_of_session(void) return true; } +/* --- presence scan (osdp_cp_trs_scan_enable) --- */ + +/* + * With the scan on and nothing else going on, the reader must keep flipping + * default mode -> transparent -> default, silently: probes are not sessions + * (no OPENED/CLOSED notifications) and not app commands (no completions). + */ +static bool test_trs_scan_cadence(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 150, + }; + uint8_t status = 0; + int flips; + + printf(SUB_2 "testing TRS presence-scan cadence\n"); + + g_trs.status_seen = false; + g_trs.completion_seen = false; + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + for (flips = 0; flips < 3; flips++) { + if (!wait_for_pd_mode(TRS_MODE_01, 3000)) { + printf(SUB_2 "probe %d never reached the reader\n", + flips); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "probe %d never wound down\n", flips); + return false; + } + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + + if (g_trs.status_seen) { + printf(SUB_2 "probes must not raise session notifications\n"); + return false; + } + if (g_trs.completion_seen) { + printf(SUB_2 "probes must not complete app commands\n"); + return false; + } + osdp_get_status_mask(g_trs.cp_ctx, &status); + if (!(status & 1)) { + printf(SUB_2 "scanning must not cost the link\n"); + return false; + } + return true; +} + +/* Event submitted by reference; must outlive the callback that ships it */ +static struct osdp_event g_activity_ev; + +/* + * Ordinary keypad/card traffic restarts the default-mode dwell: as long as + * the user is interacting with the reader, no probe may cut in. + */ +static bool test_trs_scan_mode0_activity_defers_probe(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 500, + .mode1_dwell_ms = 100, + }; + int i; + + printf(SUB_2 "testing TRS scan defers to mode-0 activity\n"); + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + for (i = 0; i < 15; i++) { + g_activity_ev = (struct osdp_event){ + .type = OSDP_EVENT_KEYPRESS, + .keypress = { + .reader_no = 0, + .length = 1, + .data = { 0x30 }, + }, + }; + if (osdp_pd_submit_event(g_trs.pd_ctx, &g_activity_ev)) { + printf(SUB_2 "failed to submit keypress %d\n", i); + return false; + } + if (pd_trs_mode() != TRS_MODE_00) { + printf(SUB_2 "probe sliced into keypad activity\n"); + return false; + } + usleep(100 * 1000); + } + /* activity stops; the next dwell must end in a probe */ + if (!wait_for_pd_mode(TRS_MODE_01, 3000)) { + printf(SUB_2 "scan never resumed after activity\n"); + return false; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + if (!wait_for_pd_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "reader stuck in mode 01 after disable\n"); + return false; + } + return true; +} + +/* + * Disabling the scan mid-probe must restore the reader's default mode, and + * flushing the (empty) queue mid-probe must leave both the scan and a + * subsequent band working. + */ +static bool test_trs_scan_disable_and_flush_mid_probe(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 3000, /* long probe: room to interrupt it */ + }; + struct osdp_cmd start, stop; + + printf(SUB_2 "testing TRS scan disable/flush mid-probe\n"); + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_01, 3000)) { + printf(SUB_2 "no probe to disable\n"); + return false; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + if (!wait_for_pd_mode(TRS_MODE_00, 2000)) { + printf(SUB_2 "disable must restore the default mode\n"); + return false; + } + usleep(500 * 1000); + if (pd_trs_mode() != TRS_MODE_00) { + printf(SUB_2 "a disabled scan kept probing\n"); + return false; + } + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to re-enable scan\n"); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_01, 3000)) { + printf(SUB_2 "scan did not resume\n"); + return false; + } + osdp_cp_flush_commands(g_trs.cp_ctx, 0); + + /* A band right through the probe: adoption, exchange, orderly close */ + g_trs.status_seen = false; + if (submit_trs_cmd(&start, OSDP_TRS_CMD_START)) { + printf(SUB_2 "failed to submit START\n"); + return false; + } + if (!wait_for_trs_status(OSDP_TRS_SESSION_OPENED, 10)) { + printf(SUB_2 "band did not open after flush\n"); + return false; + } + if (!test_trs_apdu_exchange()) { + printf(SUB_2 "band unusable after flush\n"); + return false; + } + g_trs.status_seen = false; + if (submit_trs_cmd(&stop, OSDP_TRS_CMD_STOP)) { + printf(SUB_2 "failed to submit STOP\n"); + return false; + } + if (!wait_for_trs_status(OSDP_TRS_SESSION_CLOSED, 10)) { + printf(SUB_2 "band did not close\n"); + return false; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + if (!wait_for_pd_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "reader stuck in mode 01 after band close\n"); + return false; + } + return true; +} + static bool test_trs_session_stop() { printf(SUB_2 "testing TRS session stop\n"); @@ -1277,6 +1678,9 @@ void run_trs_tests(struct test *t) result &= test_trs_error_reply(); result &= test_trs_session_stop(); result &= test_trs_unsolicited_xrd_out_of_session(); + result &= test_trs_scan_cadence(); + result &= test_trs_scan_mode0_activity_defers_probe(); + result &= test_trs_scan_disable_and_flush_mid_probe(); teardown_test_environment(); @@ -1294,6 +1698,8 @@ void run_trs_tests(struct test *t) result &= test_trs_session_start(); result &= test_trs_card_scan_wire_nak(); result &= test_trs_session_stop(); + result &= test_trs_scan_hold_and_adopt(); + result &= test_trs_scan_probe_failure_backoff(); teardown_test_environment(); From ef9212a1feedad57493b7982eef2b41068e2bb70 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 09:47:04 +0200 Subject: [PATCH 12/22] trs: Cap the sighting hold at a fixed budget hold_ms is documented as the window a probe keeps transparent mode open for the app to answer a sighting with a START, but every sighting also refreshed the deadline it is measured from. A reader is free to report a card on every poll (v2.2 section 7.26.8), so a card left sitting in the field renewed the hold indefinitely and kept the reader in mode 01, which is precisely the state that stops ordinary credential reads. Stamp the deadline on the first sighting only. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_trs.c | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 65c072e1..fca89cb7 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -397,8 +397,18 @@ static void trs_scan_note_card(struct osdp_pd *pd, bool present) trs->scan.holding = false; /* card left; let the probe close */ return; } + if (!trs->scan.holding) { + /* + * The hold is a fixed budget from the first sighting for the + * app to answer with a START, not a while-present latch: a + * reader may report a card on every poll (v2.2 section 7.26.8), + * and sliding the deadline forward on each one would keep a + * card parked in the field in transparent mode indefinitely -- + * exactly the state that stops ordinary credential reads. + */ + trs->scan.hold_tstamp = osdp_millis_now(); + } trs->scan.holding = true; - trs->scan.hold_tstamp = osdp_millis_now(); trs->scan.backoff_ms = 0; } From b8b906168c26fe1e728998700485516fa406ba31 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 10:17:14 +0200 Subject: [PATCH 13/22] trs: Ask the reader whether the card is still there The presence scan only ever waited for a reader to volunteer a card report on a poll. Readers are free to do that, but they are equally free to latch a sighting for the rest of a transparent session, and several do -- so a card lifted out of the field was never reported as gone and an application had no way to find out short of sending card traffic of its own. The spec already has the question we were not asking: the mode-01 card scan (v2.2 section 6.27.8) answers with a card-present report whose status distinguishes present from not present (Table 73). Send it during a probe, once when transparent mode comes up and once per mode-1 dwell after that, so a removal is noticed within one scan cycle. Nothing in PDCAP says whether a reader implements it, and one that does not refuses in whatever way it likes, so treat any answer other than a card-present report as a verdict: latch it and go back to poll-only for the rest of the connection. Silence is not a verdict -- that is the link failing, and it takes the usual session error path. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_common.h | 25 +++++++++- src/osdp_cp.c | 15 +++++- src/osdp_trs.c | 114 +++++++++++++++++++++++++++++++++++++++++++++- src/osdp_trs.h | 11 +++++ 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/osdp_common.h b/src/osdp_common.h index d57cb884..229849b1 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -434,6 +434,19 @@ enum trs_state_e { TRS_STATE_DONE, /* session finished; return to ONLINE */ }; +/* + * Does this reader answer the mode-01 card scan (v2.2 section 6.27.8)? Nothing + * in PDCAP says so -- the smart-card capability only advertises transparent + * mode itself -- so it can only be learned by asking, and readers that do not + * implement it refuse in whatever way they fancy. One refusal is enough to + * stop asking for the rest of this connection. + */ +enum trs_scan_support_e { + TRS_SCAN_SUPPORT_UNKNOWN = 0, + TRS_SCAN_SUPPORT_YES, + TRS_SCAN_SUPPORT_NO, +}; + struct osdp_trs { enum trs_state_e state; /* current TRS session sub-state */ uint8_t mode; /* negotiated TRS mode (TRS_MODE_00/01) */ @@ -462,13 +475,23 @@ struct osdp_trs { struct { bool enabled; bool holding; /* card sighted; hold mode-1 for the app's START */ + /* + * A card scan the library asked for is on the wire right now. + * Kept apart from state so enum trs_state_e stays a description + * of the session alone: the scan's question is not a step of + * the session, it just rides inside one. + */ + bool asking; + bool asked; /* this probe has asked at least once */ + enum trs_scan_support_e support; uint16_t mode0_dwell_ms; uint16_t mode1_dwell_ms; uint16_t hold_ms; uint32_t backoff_ms; /* probe refused; 0 = no backoff pending */ tick_t tstamp; /* mode-0 dwell / backoff anchor */ tick_t probe_tstamp; /* when mode-1 was actually entered */ - tick_t hold_tstamp; /* last card sighting */ + tick_t hold_tstamp; /* first sighting of the card being held */ + tick_t ask_tstamp; /* when this probe last asked */ } scan; }; #endif diff --git a/src/osdp_cp.c b/src/osdp_cp.c index bc75214f..dad33dda 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -1391,6 +1391,13 @@ static int cp_get_trs_command(struct osdp_pd *pd) pd->trs.state = TRS_STATE_TEARDOWN; return CMD_XWR; } + if (osdp_trs_scan_ask_due(pd)) { + /* The scan's own question: nobody queued it, + * so there is no app command behind it. */ + pd->active_cmd = NULL; + osdp_trs_scan_note_ask(pd); + return CMD_XWR; + } return cp_get_poll_command(pd); } if (pd->trs.probe && cp_cmd_is_trs(cmd, OSDP_TRS_CMD_START)) { @@ -1950,7 +1957,13 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) static bool cp_trs_cmd_is_app_owned(struct osdp_pd *pd) { #ifdef OPT_BUILD_OSDP_TRS - return pd->trs.state == TRS_STATE_XMIT; + /* + * The presence scan's card scan runs in TRS_STATE_XMIT too, but it is + * the library's own question -- nobody queued it and nobody is waiting + * on it, and a reader refusing it must reach the error path that latches + * that fact rather than being written off as one failed app command. + */ + return pd->trs.state == TRS_STATE_XMIT && !pd->trs.scan.asking; #else ARG_UNUSED(pd); return false; diff --git a/src/osdp_trs.c b/src/osdp_trs.c index fca89cb7..6d7de2fa 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -186,6 +186,20 @@ static int trs_local_cmd_build(struct osdp_pd *pd, uint8_t *buf, int max_len) { int len = 0; + /* + * The presence scan asking whether a card is there (v2.2 section + * 6.27.8). It rides inside a session but is not one of its steps, so it + * is selected by the scan's own in-flight marker rather than by state. + */ + if (pd->trs.state == TRS_STATE_XMIT && pd->trs.scan.asking) { + if (max_len < 3) { + return -1; + } + bwrite_u16_be(CMD_CARD_SCAN, buf, &len); + buf[len++] = 0; /* reader (always 0) */ + return len; + } + switch (pd->trs.state) { case TRS_STATE_SET_MODE: case TRS_STATE_TEARDOWN: @@ -412,6 +426,44 @@ static void trs_scan_note_card(struct osdp_pd *pd, bool present) trs->scan.backoff_ms = 0; } +/* + * The reader answered the question the probe asked, so it implements the card + * scan. Only an answer to our own question proves that: a card-present report + * volunteered on a poll says nothing about whether the reader takes the command. + */ +static void trs_scan_note_answered(struct osdp_pd *pd) +{ + if (!pd->trs.scan.asking || + pd->trs.scan.support == TRS_SCAN_SUPPORT_YES) { + return; + } + LOG_INF("TRS: reader answers card-scan; card removal will be reported"); + pd->trs.scan.support = TRS_SCAN_SUPPORT_YES; +} + +/* + * The probe's card scan came back refused. Latch it only when the reader + * actually answered -- a NAK, a mode-1 error report, or a bare ACK all say + * "not this command", and readers in the field do each of them. Silence leaves + * pd->reply_id at REPLY_INVALID and proves nothing about the command, so it + * must fall through to the ordinary session error path instead. + */ +static bool trs_scan_note_refused(struct osdp_pd *pd) +{ + if (!pd->trs.scan.asking) { + return false; + } + pd->trs.scan.asking = false; + if (pd->reply_id != REPLY_NAK && pd->reply_id != REPLY_XRD && + pd->reply_id != REPLY_ACK) { + return false; + } + LOG_INF("TRS: reader declined card-scan; presence scan falls back to " + "reports volunteered on poll"); + pd->trs.scan.support = TRS_SCAN_SUPPORT_NO; + return true; +} + /* * Decode a REPLY_XRD payload into *event. Returns <0 on error, else one of * enum osdp_trs_reply_action_e telling osdp_cp.c whether the reply carried @@ -496,6 +548,7 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, * without it the reply itself is the news that a card * is there, on an unspecified interface. */ event->trs.card_present.status = OSDP_TRS_CARD_PRESENT; + trs_scan_note_answered(pd); trs_scan_note_card(pd, true); break; } @@ -506,6 +559,7 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, card_status); return -1; } + trs_scan_note_answered(pd); trs_scan_note_card(pd, event->trs.card_present.status != OSDP_TRS_CARD_NOT_PRESENT); break; @@ -853,6 +907,7 @@ enum osdp_cp_state_e osdp_trs_state_update(struct osdp_pd *pd) pd->trs.state = TRS_STATE_XMIT; return OSDP_CP_STATE_TRS_RUN; case TRS_STATE_XMIT: + pd->trs.scan.asking = false; return OSDP_CP_STATE_TRS_RUN; case TRS_STATE_DISCONNECT_CARD: pd->trs.state = TRS_STATE_TEARDOWN; @@ -876,6 +931,16 @@ enum osdp_cp_state_e osdp_trs_state_update(struct osdp_pd *pd) */ enum osdp_cp_state_e osdp_trs_state_update_err(struct osdp_pd *pd) { + /* + * A reader refusing the probe's card scan is not a session error: it + * has told us it does not implement that command, which is a fact + * about the reader, not about the link. Keep the probe running on + * volunteered reports alone. + */ + if (trs_scan_note_refused(pd)) { + return OSDP_CP_STATE_TRS_RUN; + } + pd->trs.failed = true; switch (pd->trs.state) { @@ -941,12 +1006,50 @@ bool osdp_trs_probe_expired(struct osdp_pd *pd) trs->scan.mode1_dwell_ms; } +/* + * Should the probe ask the reader whether a card is there? Once as soon as mode + * 01 is up, then once per mode-1 dwell for as long as a hold keeps the probe + * open; polls fill the gaps at their own pace. Never inside an app band: the + * band's APDUs are the app's card transaction and the library has no business + * interleaving traffic of its own into it. + */ +bool osdp_trs_scan_ask_due(struct osdp_pd *pd) +{ + struct osdp_trs *trs = &pd->trs; + + if (!trs->scan.enabled || !trs->probe || trs->scan.asking || + trs->scan.support == TRS_SCAN_SUPPORT_NO) { + return false; + } + if (!trs->scan.asked) { + return true; + } + return osdp_millis_since(trs->scan.ask_tstamp) > + trs->scan.mode1_dwell_ms; +} + +/* A card scan is going on the wire now */ +void osdp_trs_scan_note_ask(struct osdp_pd *pd) +{ + pd->trs.scan.asking = true; + pd->trs.scan.asked = true; + pd->trs.scan.ask_tstamp = osdp_millis_now(); +} + +/* Forget what this probe asked; the next one starts the cadence over */ +static void trs_scan_forget_ask(struct osdp_pd *pd) +{ + pd->trs.scan.asking = false; + pd->trs.scan.asked = false; +} + /* The probe's session opened; undo the app-band bookkeeping TRS_SETUP set */ void osdp_trs_probe_note_open(struct osdp_pd *pd) { pd->trs.probe = true; pd->trs.stop_pending = false; pd->trs.scan.holding = false; + trs_scan_forget_ask(pd); } /* Mode 01 confirmed; the probe's dwell clock starts now */ @@ -961,6 +1064,7 @@ void osdp_trs_probe_adopted(struct osdp_pd *pd) pd->trs.probe = false; pd->trs.stop_pending = true; pd->trs.scan.holding = false; + trs_scan_forget_ask(pd); } /* @@ -975,6 +1079,7 @@ bool osdp_trs_probe_close(struct osdp_pd *pd) trs->probe = false; trs->scan.holding = false; trs->scan.tstamp = osdp_millis_now(); + trs_scan_forget_ask(pd); if (!trs->failed) { trs->scan.backoff_ms = 0; return false; @@ -989,12 +1094,19 @@ bool osdp_trs_probe_close(struct osdp_pd *pd) return true; } -/* Forget any probe in flight (PD is re-initializing) */ +/* + * Forget any probe in flight (PD is re-initializing). The card-scan verdict + * goes with it: the reader that answered may not be the one that comes back at + * this address, and it is re-learned in a single command anyway -- the same + * reasoning that has the CP re-collect pd->cap[] on every connection. + */ void osdp_trs_probe_reset(struct osdp_pd *pd) { pd->trs.probe = false; pd->trs.scan.holding = false; pd->trs.scan.tstamp = osdp_millis_now(); + pd->trs.scan.support = TRS_SCAN_SUPPORT_UNKNOWN; + trs_scan_forget_ask(pd); } /* diff --git a/src/osdp_trs.h b/src/osdp_trs.h index 4923778f..0670407d 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -58,6 +58,8 @@ void osdp_trs_probe_adopted(struct osdp_pd *pd); bool osdp_trs_probe_close(struct osdp_pd *pd); void osdp_trs_probe_reset(struct osdp_pd *pd); void osdp_trs_scan_note_activity(struct osdp_pd *pd); +bool osdp_trs_scan_ask_due(struct osdp_pd *pd); +void osdp_trs_scan_note_ask(struct osdp_pd *pd); #else /* OPT_BUILD_OSDP_TRS */ @@ -131,6 +133,15 @@ static inline void osdp_trs_scan_note_activity(struct osdp_pd *pd) { ARG_UNUSED(pd); } +static inline bool osdp_trs_scan_ask_due(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); + return false; +} +static inline void osdp_trs_scan_note_ask(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} #endif /* OPT_BUILD_OSDP_TRS */ From 3e6f653e0d48fe8ddede2cd3a5429a2f09c0fb09 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 10:29:41 +0200 Subject: [PATCH 14/22] trs: Name the zero value of the card-status enum The presence statuses started at 1, leaving 0 unnamed. A card-present event that never had a status decoded into it -- a zeroed struct, or an error path that returned before the status byte was read -- therefore held a value that is neither "present" nor "not present", and testing for absence as "!= OSDP_TRS_CARD_NOT_PRESENT" read it as a real sighting. Name it, and say in the header that presence must be tested against the named values. The existing values keep their numbering: renumbering them would silently change the meaning of an already-compiled application. Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/include/osdp.h b/include/osdp.h index 63a0c9f9..f91ed8c9 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1391,6 +1391,12 @@ struct osdp_trs_card_info { /** @brief Smart-card presence (and interface) reported in a TRS card-present reply */ enum osdp_trs_card_status_e { + /** + * No presence has been reported. This is what a zero-initialized + * struct holds, so a status must be compared against the named values + * below rather than tested for "not @a OSDP_TRS_CARD_NOT_PRESENT". + */ + OSDP_TRS_CARD_STATUS_UNKNOWN = 0, OSDP_TRS_CARD_NOT_PRESENT = 1, /**< No card detected */ OSDP_TRS_CARD_PRESENT, /**< Card present; interface not specified */ OSDP_TRS_CARD_PRESENT_CONTACTLESS, /**< Card present on the contactless (ISO 14443) interface */ From 0ac5124e1317ade7a42822713b859c853aa3067c Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 10:58:19 +0200 Subject: [PATCH 15/22] trs: Report presence changes, not presence Now that the scan asks on a cadence, a card left sitting in the field answers every probe the same way, and each answer was delivered to the application as a fresh event. That is a stream of notifications saying nothing has changed. Report only what changed. Reports the reader volunteered on a poll are left alone: those are the reader's to repeat, and an application that missed the first one still needs to see the next. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_common.h | 6 ++++++ src/osdp_cp.c | 3 +++ src/osdp_trs.c | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/osdp_common.h b/src/osdp_common.h index 229849b1..84ba37ee 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -484,6 +484,12 @@ struct osdp_trs { bool asking; bool asked; /* this probe has asked at least once */ enum trs_scan_support_e support; + /* + * Last presence the scan reported to the app. Spans probes on + * purpose: what the app knows does not reset when a probe ends, + * only when the scan itself is restarted or the PD reconnects. + */ + enum osdp_trs_card_status_e last_status; uint16_t mode0_dwell_ms; uint16_t mode1_dwell_ms; uint16_t hold_ms; diff --git a/src/osdp_cp.c b/src/osdp_cp.c index dad33dda..0573269d 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -2767,6 +2767,9 @@ int osdp_cp_trs_scan_enable(osdp_t *ctx, int pd_idx, trs->scan.backoff_ms = 0; trs->scan.holding = false; trs->scan.tstamp = osdp_millis_now(); + /* A fresh scan reports the first presence it sees, whatever the + * previous one had already told the app. */ + trs->scan.last_status = OSDP_TRS_CARD_STATUS_UNKNOWN; trs->scan.enabled = true; /* Capability is only known once the PD has been online; before that the * probe gate re-checks it every cycle and simply never fires. */ diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 6d7de2fa..5be10691 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -426,6 +426,26 @@ static void trs_scan_note_card(struct osdp_pd *pd, bool present) trs->scan.backoff_ms = 0; } +/* + * Is this presence report news? A card left sitting in the field answers every + * scan the same way, and repeating that to the app once per probe would turn a + * background scan into a stream of events saying nothing changed. Only applies + * to answers the library asked for: a report the reader volunteered is the + * reader's to repeat, and squashing it would hide a sighting the app wants. + */ +static bool trs_scan_presence_is_news(struct osdp_pd *pd, + enum osdp_trs_card_status_e status) +{ + if (!pd->trs.scan.asking) { + return true; + } + if (pd->trs.scan.last_status == status) { + return false; + } + pd->trs.scan.last_status = status; + return true; +} + /* * The reader answered the question the probe asked, so it implements the card * scan. Only an answer to our own question proves that: a card-present report @@ -549,6 +569,8 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, * is there, on an unspecified interface. */ event->trs.card_present.status = OSDP_TRS_CARD_PRESENT; trs_scan_note_answered(pd); + dispatch = trs_scan_presence_is_news( + pd, event->trs.card_present.status); trs_scan_note_card(pd, true); break; } @@ -560,6 +582,8 @@ int osdp_trs_reply_decode(struct osdp_pd *pd, uint8_t *buf, int len, return -1; } trs_scan_note_answered(pd); + dispatch = trs_scan_presence_is_news( + pd, event->trs.card_present.status); trs_scan_note_card(pd, event->trs.card_present.status != OSDP_TRS_CARD_NOT_PRESENT); break; @@ -1106,6 +1130,7 @@ void osdp_trs_probe_reset(struct osdp_pd *pd) pd->trs.scan.holding = false; pd->trs.scan.tstamp = osdp_millis_now(); pd->trs.scan.support = TRS_SCAN_SUPPORT_UNKNOWN; + pd->trs.scan.last_status = OSDP_TRS_CARD_STATUS_UNKNOWN; trs_scan_forget_ask(pd); } From 4f9d101bdbdc1e4d60e9009cb1c8c1e26b01a07e Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 11:21:29 +0200 Subject: [PATCH 16/22] trs: Report that the reader declined the card scan A reader that refuses the mode-01 card scan leaves the presence scan unable to ask whether a card is still there, so removal is never reported. That is a permanent property of the reader and it changes what an application has to do -- find out for itself by sending a card command and reading its failure as the card being gone -- but nothing told it so. Say it on the notification that already carries presence-scan news, once per connection, for readers that refuse. Nothing in PDCAP covers this, so it cannot go through the capability report: the smart-card capability advertises transparent mode itself and has no bit for the individual mode-01 commands. Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 13 +++++++++++++ src/osdp_common.h | 1 + src/osdp_cp.c | 3 +++ src/osdp_trs.c | 15 +++++++++++++++ src/osdp_trs.h | 6 ++++++ 5 files changed, 38 insertions(+) diff --git a/include/osdp.h b/include/osdp.h index f91ed8c9..f62657be 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1246,6 +1246,19 @@ enum osdp_trs_session_status_e { OSDP_TRS_SCAN_SUSPENDED, /**< A presence-scan probe was refused by * the reader; probing continues with * exponential backoff */ + /** + * The reader does not implement the mode-01 card scan, so the presence + * scan cannot ask whether a card is still there and falls back to + * whatever the reader volunteers on a poll. Raised at most once per + * connection, and only for readers that refuse the command. + * + * The practical consequence is that **card removal will not be + * reported**: many such readers hold a sighting for the rest of the + * transparent session. An application that needs to know when the card + * left must find out for itself, by sending a card command inside a + * session and treating its failure as the card being gone. + */ + OSDP_TRS_SCAN_NO_DEPARTURE, }; /** diff --git a/src/osdp_common.h b/src/osdp_common.h index 84ba37ee..7f5ca494 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -483,6 +483,7 @@ struct osdp_trs { */ bool asking; bool asked; /* this probe has asked at least once */ + bool declined; /* refusal not yet reported to the app */ enum trs_scan_support_e support; /* * Last presence the scan reported to the app. Spans probes on diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 0573269d..ba77beff 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -2233,6 +2233,9 @@ static void state_update(struct osdp_pd *pd) * pass, right where the next protocol state is decided. */ next = (err == OSDP_CP_ERR_NONE) ? osdp_trs_state_update(pd) : osdp_trs_state_update_err(pd); + if (osdp_trs_scan_declined(pd)) { + notify_trs_status(pd, OSDP_TRS_SCAN_NO_DEPARTURE); + } #endif } else { next = get_next_state(pd, err); diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 5be10691..034df8b4 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -481,6 +481,21 @@ static bool trs_scan_note_refused(struct osdp_pd *pd) LOG_INF("TRS: reader declined card-scan; presence scan falls back to " "reports volunteered on poll"); pd->trs.scan.support = TRS_SCAN_SUPPORT_NO; + pd->trs.scan.declined = true; + return true; +} + +/* + * Has the reader just declined the card scan? Test-and-clear, so the CP raises + * OSDP_TRS_SCAN_NO_DEPARTURE once for the connection rather than on every probe + * that goes on without asking. + */ +bool osdp_trs_scan_declined(struct osdp_pd *pd) +{ + if (!pd->trs.scan.declined) { + return false; + } + pd->trs.scan.declined = false; return true; } diff --git a/src/osdp_trs.h b/src/osdp_trs.h index 0670407d..acfd144f 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -60,6 +60,7 @@ void osdp_trs_probe_reset(struct osdp_pd *pd); void osdp_trs_scan_note_activity(struct osdp_pd *pd); bool osdp_trs_scan_ask_due(struct osdp_pd *pd); void osdp_trs_scan_note_ask(struct osdp_pd *pd); +bool osdp_trs_scan_declined(struct osdp_pd *pd); #else /* OPT_BUILD_OSDP_TRS */ @@ -142,6 +143,11 @@ static inline void osdp_trs_scan_note_ask(struct osdp_pd *pd) { ARG_UNUSED(pd); } +static inline bool osdp_trs_scan_declined(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); + return false; +} #endif /* OPT_BUILD_OSDP_TRS */ From 34461e7793ac1690124d185aca0657bd10073025 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 11:36:51 +0200 Subject: [PATCH 17/22] cp: Expose the effective max C-APDU length OSDP_TRS_APDU_MAX_LEN sizes the buffer in struct osdp_trs_apdu, but what fits on the wire is bounded by the packet size in use and the framing the APDU travels inside, which at the default packet size is appreciably less. An application had no way to ask, so sizing payloads against the constant -- the only number the header offered -- produced commands the CP turned away at submit time. Report the real limit. Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 29 +++++++++++++++++++++++++++++ src/osdp_cp.c | 11 +++++++++++ 2 files changed, 40 insertions(+) diff --git a/include/osdp.h b/include/osdp.h index f62657be..45966708 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -2189,6 +2189,35 @@ int osdp_cp_trs_scan_enable(osdp_t *ctx, int pd, OSDP_EXPORT int osdp_cp_trs_scan_disable(osdp_t *ctx, int pd); +/** + * @brief Get the largest C-APDU that can be sent to a PD in a single + * @c OSDP_TRS_CMD_SEND_APDU. + * + * This is **not** @c OSDP_TRS_APDU_MAX_LEN. That constant only sizes the + * buffer in @a struct osdp_trs_apdu; what actually fits on the wire is bounded + * by the packet size in use, which is the smaller of this build's + * @c OSDP_PACKET_BUF_SIZE and the receive buffer the PD advertised, less the + * framing the APDU travels inside. With the default packet size the limit is + * appreciably lower than @c OSDP_TRS_APDU_MAX_LEN, so an application that sizes + * its payloads against that constant will have commands rejected. + * + * The value is only final once the PD has been online at least once: the PD's + * receive-buffer capability is not known before then, and this returns the + * build's own limit until it is. + * + * An @c OSDP_TRS_CMD_ENTER_PIN carries its APDU behind a fixed parameter + * block, so its APDU has correspondingly less room than this. + * + * @param ctx OSDP context + * @param pd PD offset (0-indexed) of this PD in `osdp_pd_info_t *` passed to + * osdp_cp_setup() + * + * @retval >0 Maximum C-APDU length in bytes + * @retval -1 on failure (TRS support not compiled in, or bad args) + */ +OSDP_EXPORT +int osdp_cp_trs_get_max_apdu_len(const osdp_t *ctx, int pd); + /** * @brief Get PD ID information as reported by the PD. Calling this method * before the CP has had a the chance to get this information will return diff --git a/src/osdp_cp.c b/src/osdp_cp.c index ba77beff..e6be2f85 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -2806,6 +2806,17 @@ int osdp_cp_trs_scan_disable(osdp_t *ctx, int pd_idx) #endif } +int osdp_cp_trs_get_max_apdu_len(const osdp_t *ctx, int pd_idx) +{ + input_check(ctx, pd_idx); +#ifdef OPT_BUILD_OSDP_TRS + return osdp_trs_max_apdu_len(osdp_to_pd(ctx, pd_idx)); +#else + LOG_PRINT("TRS support not built in"); + return -1; +#endif +} + int osdp_cp_get_pd_id(const osdp_t *ctx, int pd_idx, struct osdp_pd_id *id) { input_check(ctx, pd_idx); From a46a8de2b83456709f5c7af3ead8806f4526b854 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 12:08:01 +0200 Subject: [PATCH 18/22] cp: Admit ENTER_PIN's APDU against the packet size A send-APDU command was checked against the packet size when the application submitted it, but a secure PIN entry carrying the same APDU was not. Its APDU sits behind the PIN parameter block, so it has less room, not more -- and an oversized one was only caught when the frame was built, part way through a running band, where the application can no longer do anything about it. Check both, against the room each command actually leaves. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_cp.c | 23 ++++++++++++++++++----- src/osdp_trs.c | 17 +++++++++++++++++ src/osdp_trs.h | 2 ++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/osdp_cp.c b/src/osdp_cp.c index e6be2f85..2f9fcc39 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -2322,11 +2322,24 @@ static int cp_band_admit(struct osdp_pd *pd, const struct osdp_cmd *cmd) LOG_ERR("TRS command outside a session; queue a START first"); return -1; } - if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_SEND_APDU) && - (int)cmd->trs.apdu.length > osdp_trs_max_apdu_len(pd)) { - LOG_ERR("C-APDU of %u bytes cannot fit the %d-byte packet", - cmd->trs.apdu.length, get_tx_buf_size(pd)); - return -1; + /* + * Both carriers of a C-APDU are checked here so an oversized one is + * turned away while the app can still act on it; the frame builder + * would otherwise catch it only once the band is already running. + */ + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_SEND_APDU) || + cp_cmd_is_trs(cmd, OSDP_TRS_CMD_ENTER_PIN)) { + bool is_pin = cmd->trs.command == OSDP_TRS_CMD_ENTER_PIN; + int apdu_len = is_pin ? cmd->trs.pin_entry.apdu.length : + cmd->trs.apdu.length; + int capacity = osdp_trs_apdu_capacity(pd, cmd->trs.command); + + if (apdu_len > capacity) { + LOG_ERR("C-APDU of %d bytes cannot fit the %d-byte " + "packet (max %d for this command)", + apdu_len, get_tx_buf_size(pd), capacity); + return -1; + } } if (cmd->id != OSDP_CMD_XWR && pd->trs.band_open) { /* diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 034df8b4..7c957bbd 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -259,6 +259,23 @@ int osdp_trs_max_apdu_len(struct osdp_pd *pd) return max_len; } +/* + * Wire bytes a secure PIN entry spends ahead of its APDU: the PIN parameter + * block (v2.2 Table 41) and the APDU length field that follows it. + */ +#define TRS_PIN_ENTRY_OVERHEAD (17 + 2) + +/* Largest C-APDU this TRS command can carry within the negotiated packet */ +int osdp_trs_apdu_capacity(struct osdp_pd *pd, enum osdp_trs_cmd_e command) +{ + int max_len = osdp_trs_max_apdu_len(pd); + + if (command == OSDP_TRS_CMD_ENTER_PIN) { + max_len -= TRS_PIN_ENTRY_OVERHEAD; + } + return max_len; +} + int osdp_trs_cmd_build(struct osdp_pd *pd, const struct osdp_cmd *cmd, uint8_t *buf, int max_len) { diff --git a/src/osdp_trs.h b/src/osdp_trs.h index acfd144f..0d82487c 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -43,6 +43,8 @@ int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, int len); /* Largest C-APDU the negotiated packet size can carry for this PD */ int osdp_trs_max_apdu_len(struct osdp_pd *pd); +/* ... and how much of that is left once @command's own fields are accounted */ +int osdp_trs_apdu_capacity(struct osdp_pd *pd, enum osdp_trs_cmd_e command); /* * Background presence scan (osdp_cp_trs_scan_enable): probe scheduling and From b973e827ba3e61867fdff0a0a4ea739f820894f0 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 14:18:43 +0200 Subject: [PATCH 19/22] cp: Warn about a missing smart-card reader when capabilities arrive Enabling the presence scan on a PD that has no smart-card reader is a configuration mistake worth reporting, and the check for it only ran if the PD happened to be online at that moment. Applications enable the scan once during setup, before any PD has been brought up, so it never ran for anyone. Check when the capability report actually arrives, and again on every reconnect that re-collects it, keeping it to one warning per enable. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_common.h | 1 + src/osdp_cp.c | 14 ++++++++++---- src/osdp_trs.c | 16 ++++++++++++++++ src/osdp_trs.h | 5 +++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/osdp_common.h b/src/osdp_common.h index 7f5ca494..208bd91c 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -484,6 +484,7 @@ struct osdp_trs { bool asking; bool asked; /* this probe has asked at least once */ bool declined; /* refusal not yet reported to the app */ + bool warned; /* no-smart-card warning already raised */ enum trs_scan_support_e support; /* * Last presence the scan reported to the app. Spans probes on diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 2f9fcc39..2bba4109 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -582,6 +582,7 @@ static int cp_decode_response(struct osdp_pd *pd, uint8_t *buf, int len) } else { CLEAR_FLAG(pd, PD_FLAG_TRS_CAPABLE); } + osdp_trs_scan_note_capability(pd); ret = OSDP_CP_ERR_NONE; break; @@ -2786,11 +2787,16 @@ int osdp_cp_trs_scan_enable(osdp_t *ctx, int pd_idx, /* A fresh scan reports the first presence it sees, whatever the * previous one had already told the app. */ trs->scan.last_status = OSDP_TRS_CARD_STATUS_UNKNOWN; + trs->scan.warned = false; trs->scan.enabled = true; - /* Capability is only known once the PD has been online; before that the - * probe gate re-checks it every cycle and simply never fires. */ - if (pd->state == OSDP_CP_STATE_ONLINE && !trs_capable(pd)) { - LOG_WRN("TRS: scan enabled on a PD with no smart-card reader"); + /* + * Whether this PD has a reader to scan is only known once its + * capabilities have been collected, and a scan is normally enabled at + * startup, before any PD is online. Warn now if the answer is already + * in; otherwise the capability report raises it when it arrives. + */ + if (pd->state == OSDP_CP_STATE_ONLINE) { + osdp_trs_scan_note_capability(pd); } return 0; #else diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 7c957bbd..370854e1 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -1166,6 +1166,22 @@ void osdp_trs_probe_reset(struct osdp_pd *pd) trs_scan_forget_ask(pd); } +/* + * The PD's capabilities have arrived. This is the first moment the CP can tell + * whether the reader has a smart-card interface at all, and therefore the only + * useful moment to say that a scan someone enabled will never do anything: + * applications enable it once at startup, long before any PD is online. + */ +void osdp_trs_scan_note_capability(struct osdp_pd *pd) +{ + if (!pd->trs.scan.enabled || pd->trs.scan.warned || trs_capable(pd)) { + return; + } + pd->trs.scan.warned = true; + LOG_WRN("TRS: presence scan is enabled but this PD reports no " + "smart-card reader; it will never probe"); +} + /* * Ordinary (mode 00) card or keypad activity: restart the mode-0 dwell so a * probe never slices into a read the user is in the middle of. diff --git a/src/osdp_trs.h b/src/osdp_trs.h index 0d82487c..9fdcfbec 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -60,6 +60,7 @@ void osdp_trs_probe_adopted(struct osdp_pd *pd); bool osdp_trs_probe_close(struct osdp_pd *pd); void osdp_trs_probe_reset(struct osdp_pd *pd); void osdp_trs_scan_note_activity(struct osdp_pd *pd); +void osdp_trs_scan_note_capability(struct osdp_pd *pd); bool osdp_trs_scan_ask_due(struct osdp_pd *pd); void osdp_trs_scan_note_ask(struct osdp_pd *pd); bool osdp_trs_scan_declined(struct osdp_pd *pd); @@ -136,6 +137,10 @@ static inline void osdp_trs_scan_note_activity(struct osdp_pd *pd) { ARG_UNUSED(pd); } +static inline void osdp_trs_scan_note_capability(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} static inline bool osdp_trs_scan_ask_due(struct osdp_pd *pd) { ARG_UNUSED(pd); From 088cacb6a777b90ee1a4e48dc459e372462b57a0 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 14:39:28 +0200 Subject: [PATCH 20/22] pd: Restore transparent mode off when the CP link restarts Transparent mode is the one piece of state a PD carries between commands, and nothing dropped it when the CP restarted communication. A PD that was left in mode 01 stays there, which is the same "reader stops reporting ordinary card reads" condition the CP spends a round trip avoiding on its own side -- reproduced by our own PD role. Drop it on the sequence-number restart, and correct the comment there that said the PD holds no state between commands. Deliberately not on the PD's offline timeout: that measures silence, and a CP working through a slow card transaction is silent for the same reason it is still mid-session. Resetting there ends a session that is merely slow, which the emulated-card busy test catches. Signed-off-by: Siddharth Chandrasekaran --- src/osdp_phy.c | 11 ++++++----- src/osdp_trs.c | 21 +++++++++++++++++++++ src/osdp_trs.h | 7 +++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/osdp_phy.c b/src/osdp_phy.c index 04b74474..87ba49b2 100644 --- a/src/osdp_phy.c +++ b/src/osdp_phy.c @@ -7,6 +7,7 @@ #include "osdp_common.h" #include "osdp_diag.h" #include "osdp_metrics.h" +#include "osdp_trs.h" #define OSDP_PKT_MARK 0xFF #define OSDP_PKT_SOM 0x53 @@ -609,15 +610,15 @@ static int phy_check_packet(struct osdp_pd *pd, uint8_t *buf, int pkt_len) if (comp == 0) { /** * CP is trying to restart communication by sending a 0. - * The current PD implementation does not hold any state - * between commands so we can just set seq_number to -1 - * (so it gets incremented to 0 with a call to - * phy_get_seq_number()) and invalidate any established - * secure channels. + * Set seq_number to -1 (so it gets incremented to 0 with + * a call to phy_get_seq_number()), invalidate any + * established secure channels, and drop the little state + * the PD does carry between commands. */ phy_reset_seq_number(pd); pd->last_tx_len = 0; sc_deactivate(pd); + osdp_trs_pd_reset(pd); } else if (comp == pd->seq_number) { /** * CP re-sent the same command without incrementing the diff --git a/src/osdp_trs.c b/src/osdp_trs.c index 370854e1..dcac2ab3 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -933,6 +933,27 @@ int osdp_trs_cmd_decode(struct osdp_pd *pd, struct osdp_cmd *cmd, uint8_t *buf, return OSDP_TRS_DECODE_TO_APP; } +/* --- PD: role state --- */ + +/* + * The CP restarted communication. Transparent mode is the one piece of state a + * PD carries between commands, and leaving it set means a reader that stops + * reporting ordinary card reads with nothing left to turn it off -- the same + * condition the CP pays a round trip to avoid on its own side. + * + * Only an explicit restart is grounds for this. The PD's own offline timeout is + * not: it measures silence, and a CP working through a slow card transaction is + * silent for exactly the same reason it is still mid-session. + */ +void osdp_trs_pd_reset(struct osdp_pd *pd) +{ + if (pd->trs.mode == TRS_MODE_00) { + return; + } + LOG_INF("TRS: CP link lost; leaving transparent mode"); + pd->trs.mode = TRS_MODE_00; +} + /* --- CP: library-driven session state machine --- */ /* diff --git a/src/osdp_trs.h b/src/osdp_trs.h index 9fdcfbec..34060691 100644 --- a/src/osdp_trs.h +++ b/src/osdp_trs.h @@ -65,6 +65,9 @@ bool osdp_trs_scan_ask_due(struct osdp_pd *pd); void osdp_trs_scan_note_ask(struct osdp_pd *pd); bool osdp_trs_scan_declined(struct osdp_pd *pd); +/* PD role: drop transparent mode when the CP link goes away */ +void osdp_trs_pd_reset(struct osdp_pd *pd); + #else /* OPT_BUILD_OSDP_TRS */ static inline int osdp_trs_cmd_build(struct osdp_pd *pd, @@ -155,6 +158,10 @@ static inline bool osdp_trs_scan_declined(struct osdp_pd *pd) ARG_UNUSED(pd); return false; } +static inline void osdp_trs_pd_reset(struct osdp_pd *pd) +{ + ARG_UNUSED(pd); +} #endif /* OPT_BUILD_OSDP_TRS */ From c8df7ebd5c3ef35e9a300f3f03b398265f7a7aa2 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 15:41:32 +0200 Subject: [PATCH 21/22] trs: Raise the presence-scan dwell defaults The defaults were picked to make the scan responsive without field data behind them. Running it on real hardware showed the cost lands on ordinary credential reads: at 38400 baud a 75 ms default-mode dwell made a reader miss card reads outright, and 200 ms was the value that worked. Take the value that worked, and say in the header what makes it hard to choose -- that shortening the default-mode dwell makes the scan more aggressive rather than less, and that how short is too short depends on the baud rate, because a probe costs round trips rather than time. Signed-off-by: Siddharth Chandrasekaran --- include/osdp.h | 14 ++++++++++++-- platformio/osdp_config.h | 4 ++-- src/osdp_config.h.in | 4 ++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/include/osdp.h b/include/osdp.h index 45966708..3e2dd593 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1269,14 +1269,24 @@ enum osdp_trs_session_status_e { struct osdp_trs_scan_params { /** * Time spent in the reader's default mode between probes, where - * ordinary credential reads work (default 100 ms). Restarted by + * ordinary credential reads work (default 200 ms). Restarted by * ordinary card/keypad activity so a probe never cuts into an * in-progress read. + * + * @note Shortening this makes the scan **more** aggressive, not less: + * the reader spends proportionally less of each cycle where ordinary + * credentials can be read. How short is too short depends on the baud + * rate, since a probe costs a fixed number of round trips rather than a + * fixed time -- at 38400 baud, 75 ms was enough to make a reader miss + * ordinary card reads outright. */ uint16_t mode0_dwell_ms; /** * Time spent per probe in transparent mode watching for a smart-card - * sighting (default 100 ms). + * sighting (default 200 ms). Readers that answer the card scan report + * presence in a single round trip and need little more than that; + * readers that do not are watched for this long in the hope they + * volunteer a report of their own. */ uint16_t mode1_dwell_ms; /** diff --git a/platformio/osdp_config.h b/platformio/osdp_config.h index ee8170c0..f8c2d71c 100644 --- a/platformio/osdp_config.h +++ b/platformio/osdp_config.h @@ -33,8 +33,8 @@ #define OSDP_CMD_MAX_RETRIES (8) #define OSDP_ONLINE_RETRY_WAIT_MAX_MS (300 * 1000u) #define OSDP_CMD_RETRY_WAIT_MS (800) -#define OSDP_TRS_SCAN_MODE0_DWELL_MS (100) -#define OSDP_TRS_SCAN_MODE1_DWELL_MS (100) +#define OSDP_TRS_SCAN_MODE0_DWELL_MS (200) +#define OSDP_TRS_SCAN_MODE1_DWELL_MS (200) #define OSDP_TRS_SCAN_HOLD_MS (500) #define OSDP_TRS_SCAN_BACKOFF_MAX_MS (5 * 1000u) #define OSDP_PACKET_BUF_SIZE (256) diff --git a/src/osdp_config.h.in b/src/osdp_config.h.in index 5ab356aa..7695cff4 100644 --- a/src/osdp_config.h.in +++ b/src/osdp_config.h.in @@ -63,11 +63,11 @@ /* TRS presence-scan cadence defaults (see osdp_cp_trs_scan_enable) */ #ifndef OSDP_TRS_SCAN_MODE0_DWELL_MS -#define OSDP_TRS_SCAN_MODE0_DWELL_MS (100) +#define OSDP_TRS_SCAN_MODE0_DWELL_MS (200) #endif #ifndef OSDP_TRS_SCAN_MODE1_DWELL_MS -#define OSDP_TRS_SCAN_MODE1_DWELL_MS (100) +#define OSDP_TRS_SCAN_MODE1_DWELL_MS (200) #endif #ifndef OSDP_TRS_SCAN_HOLD_MS From 7a44e120a1877f3c46a2316c3aa31977b7a27597 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Wed, 29 Jul 2026 15:53:08 +0200 Subject: [PATCH 22/22] tests: Cover the card-scan presence probe Teach both PD fixtures to answer the mode-1 card scan -- the emulated card already knows whether it is in the field, so it answers from that -- and extend the wire-NAK hook to refuse or swallow only the card scan, leaving every other transparent command alone. The refusal and the silence cases must run in that order: a refusal latches "no card scan" for the rest of the connection, so silence, which must leave the verdict open, has to be proven while a verdict is still reachable. Signed-off-by: Siddharth Chandrasekaran --- tests/unit-tests/test-trs-emu.c | 93 ++++++- tests/unit-tests/test-trs.c | 452 +++++++++++++++++++++++++++++++- 2 files changed, 541 insertions(+), 4 deletions(-) diff --git a/tests/unit-tests/test-trs-emu.c b/tests/unit-tests/test-trs-emu.c index 26dec535..5645917b 100644 --- a/tests/unit-tests/test-trs-emu.c +++ b/tests/unit-tests/test-trs-emu.c @@ -43,6 +43,10 @@ struct trs_emu_ctx { bool completion_seen; enum osdp_completion_status last_completion; bool cardread_seen; + + int card_scan_count; /* card scans the PD app has answered */ + int present_events; /* CP-side card-present events, by status */ + int absent_events; }; static struct trs_emu_ctx g_emu; @@ -118,6 +122,14 @@ static int emu_cp_event_callback(void *arg, int pd, struct osdp_event *ev) if (ev->type == OSDP_EVENT_TRS) { ctx->event_seen = true; memcpy(&ctx->last_reply, &ev->trs, sizeof(ctx->last_reply)); + if (ev->trs.reply == OSDP_TRS_REPLY_CARD_PRESENT) { + if (ev->trs.card_present.status == + OSDP_TRS_CARD_NOT_PRESENT) { + ctx->absent_events++; + } else { + ctx->present_events++; + } + } } if (ev->type == OSDP_EVENT_CARDREAD) { ctx->cardread_seen = true; @@ -150,8 +162,26 @@ static int emu_pd_command_callback(void *arg, struct osdp_cmd *cmd) struct osdp_event *resp = &ctx->resp_event; int rlen; - if (cmd->id != OSDP_CMD_XWR || - cmd->trs.command != OSDP_TRS_CMD_SEND_APDU) { + if (cmd->id != OSDP_CMD_XWR) { + return 0; + } + + /* This reader implements the mode-1 card scan and answers it from + * whether a card is actually in its field */ + if (cmd->trs.command == OSDP_TRS_CMD_CARD_SCAN) { + ctx->card_scan_count++; + memset(resp, 0, sizeof(*resp)); + resp->type = OSDP_EVENT_TRS; + resp->trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + resp->trs.card_present.reader = 0; + resp->trs.card_present.status = + ctx->card.present ? OSDP_TRS_CARD_PRESENT_CONTACTLESS : + OSDP_TRS_CARD_NOT_PRESENT; + osdp_pd_submit_event(ctx->pd_ctx, resp); + return 0; + } + + if (cmd->trs.command != OSDP_TRS_CMD_SEND_APDU) { return 0; } @@ -664,6 +694,64 @@ static bool test_emu_rpk40_time_slice(void) return emu_wait_mode(TRS_MODE_00, 3000); } +/* + * The whole point of asking the reader: a card put into the field and then + * taken away is reported both times, with the application having submitted + * nothing at all -- no session, no APDU, no sighting event of its own. Then + * ordinary credentials still work, because the reader was handed back. + */ +static bool test_emu_card_departure_detected(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 100, + .mode1_dwell_ms = 200, + }; + int waited = 0; + + printf(SUB_2 "testing emu card departure is detected\n"); + + emu_card_set_present(&g_emu.card, false); + g_emu.present_events = 0; + g_emu.absent_events = 0; + g_emu.card_scan_count = 0; + + if (osdp_cp_trs_scan_enable(g_emu.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + + emu_card_set_present(&g_emu.card, true); + while (waited < 6000 && g_emu.present_events < 1) { + usleep(50 * 1000); + waited += 50; + } + if (g_emu.present_events < 1) { + osdp_cp_trs_scan_disable(g_emu.cp_ctx, 0); + printf(SUB_2 "card arrival never reported (%d scans)\n", + g_emu.card_scan_count); + return false; + } + + emu_card_set_present(&g_emu.card, false); + waited = 0; + while (waited < 6000 && g_emu.absent_events < 1) { + usleep(50 * 1000); + waited += 50; + } + osdp_cp_trs_scan_disable(g_emu.cp_ctx, 0); + + if (g_emu.absent_events < 1) { + printf(SUB_2 "card departure never reported (%d scans)\n", + g_emu.card_scan_count); + return false; + } + if (!emu_wait_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "reader left in transparent mode\n"); + return false; + } + return emu_rpk40_standard_read(); +} + void run_trs_emu_tests(struct test *t) { bool result = true; @@ -681,6 +769,7 @@ void run_trs_emu_tests(struct test *t) result &= test_emu_permanent_busy_fails_softly(); result &= test_emu_card_removed_mid_band(); result &= test_emu_rpk40_time_slice(); + result &= test_emu_card_departure_detected(); emu_teardown(); diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c index f03cd1b9..f738eeee 100644 --- a/tests/unit-tests/test-trs.c +++ b/tests/unit-tests/test-trs.c @@ -58,6 +58,18 @@ struct test_trs_ctx { /* CP side: last command completion seen */ bool completion_seen; enum osdp_completion_status last_completion; + + /* + * How the PD app answers a card scan. OSDP_TRS_CARD_STATUS_UNKNOWN + * means "say nothing", which the CP reads as the reader declining the + * command -- the RPK40's behaviour, and the default here. + */ + enum osdp_trs_card_status_e card_status; + int card_scan_count; /* card scans the PD app has been handed */ + + /* CP side: card-present events counted by status */ + int present_events; + int absent_events; }; static struct test_trs_ctx g_trs = { 0 }; @@ -95,6 +107,14 @@ static int trs_cp_event_callback(void *arg, int pd, struct osdp_event *ev) if (ev->type == OSDP_EVENT_TRS) { ctx->event_seen = true; memcpy(&ctx->last_reply, &ev->trs, sizeof(ctx->last_reply)); + if (ev->trs.reply == OSDP_TRS_REPLY_CARD_PRESENT) { + if (ev->trs.card_present.status == + OSDP_TRS_CARD_NOT_PRESENT) { + ctx->absent_events++; + } else { + ctx->present_events++; + } + } } if (ev->type == OSDP_EVENT_NOTIFICATION && ev->notif.type == OSDP_NOTIFICATION_TRS_STATUS) { @@ -162,6 +182,21 @@ static int trs_pd_command_callback(void *arg, struct osdp_cmd *cmd) return -OSDP_PD_NAK_RECORD; } + if (cmd->trs.command == OSDP_TRS_CMD_CARD_SCAN) { + ctx->card_scan_count++; + if (ctx->card_status == OSDP_TRS_CARD_STATUS_UNKNOWN) { + /* Reader has no answer for this command */ + return 0; + } + memset(&ctx->resp_event, 0, sizeof(ctx->resp_event)); + ctx->resp_event.type = OSDP_EVENT_TRS; + ctx->resp_event.trs.reply = OSDP_TRS_REPLY_CARD_PRESENT; + ctx->resp_event.trs.card_present.reader = 0; + ctx->resp_event.trs.card_present.status = ctx->card_status; + osdp_pd_submit_event(ctx->pd_ctx, &ctx->resp_event); + return 0; + } + /* Fast card: answer the send-APDU synchronously so the R-APDU rides * back in the immediate REPLY_XRD. In deferred mode the app submits the * response later (see test_trs_deferred_apdu), so the PD replies ACK @@ -307,6 +342,68 @@ static bool test_trs_apdu_too_big_for_packet(void) return true; } +/* + * The reported capacity is the real admission boundary: exactly that many bytes + * go through, one more does not. An app that sizes payloads against + * OSDP_TRS_APDU_MAX_LEN instead is sizing against the buffer, not the wire. + */ +static bool test_trs_max_apdu_len_accessor(void) +{ + static struct osdp_cmd cmd; + int max_len; + + printf(SUB_2 "testing TRS max-APDU accessor\n"); + + max_len = osdp_cp_trs_get_max_apdu_len(g_trs.cp_ctx, 0); + if (max_len <= 0 || max_len > OSDP_TRS_APDU_MAX_LEN) { + printf(SUB_2 "implausible max APDU length %d\n", max_len); + return false; + } + + memset(&cmd, 0, sizeof(cmd)); + cmd.id = OSDP_CMD_XWR; + cmd.trs.command = OSDP_TRS_CMD_SEND_APDU; + cmd.trs.apdu.length = (uint16_t)(max_len + 1); + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd) == 0) { + printf(SUB_2 "APDU of max+1 bytes must be rejected\n"); + return false; + } + return true; +} + +/* + * A PIN entry carries its APDU behind the PIN parameter block, so it has less + * room than a send-APDU -- and must be turned away at submit time too, not part + * way through a band where the app can no longer act on it. + */ +static bool test_trs_pin_entry_too_big_for_packet(void) +{ + static struct osdp_cmd cmd; + int max_len; + + printf(SUB_2 "testing TRS PIN-entry APDU beyond packet capacity\n"); + + max_len = osdp_cp_trs_get_max_apdu_len(g_trs.cp_ctx, 0); + if (max_len <= 0) { + printf(SUB_2 "no max APDU length to size against\n"); + return false; + } + + memset(&cmd, 0, sizeof(cmd)); + cmd.id = OSDP_CMD_XWR; + cmd.trs.command = OSDP_TRS_CMD_ENTER_PIN; + /* Fits a send-APDU exactly, so only the PIN block's own overhead can + * be what rejects it. */ + cmd.trs.pin_entry.apdu.length = (uint16_t)max_len; + + if (osdp_cp_submit_command(g_trs.cp_ctx, 0, &cmd) == 0) { + printf(SUB_2 "PIN APDU with no room for its block must be " + "rejected at submit\n"); + return false; + } + return true; +} + /* A card command with no START must be turned away at submit, not queued */ static bool test_trs_apdu_outside_band() { @@ -627,6 +724,12 @@ extern uint16_t test_osdp_compute_crc16(const uint8_t *buf, size_t len); struct trs_wire_nak_hook { int xwr_tx_count; bool nak_next_reply; + /* Refuse only the mode-1 card scan, leaving every other XWR alone -- + * a reader that implements transparent mode but not that one command */ + bool card_scan_only; + /* Swallow the answer instead of refusing it: silence, not a verdict */ + bool drop_reply; + int card_scan_tx_count; }; /* @@ -656,13 +759,28 @@ static enum test_channel_hook_verdict trs_wire_nak_hook(void *arg, if (ctrl & 0x08) /* security control block */ data_off += frame[base + 5]; if (data_off < len && frame[data_off] == CMD_XWR) { + bool is_scan = data_off + 2 < len && + frame[data_off + 1] == 0x01 && + frame[data_off + 2] == 0x04; + s->xwr_tx_count++; - s->nak_next_reply = true; + if (is_scan) { + s->card_scan_tx_count++; + } + if (!s->card_scan_only || is_scan) { + s->nak_next_reply = true; + } } return TEST_HOOK_PASS; } - if (!s->nak_next_reply || out_max < 11) + if (!s->nak_next_reply) + return TEST_HOOK_PASS; + if (s->drop_reply) { + s->nak_next_reply = false; + return TEST_HOOK_DROP; + } + if (out_max < 11) return TEST_HOOK_PASS; s->nak_next_reply = false; @@ -937,6 +1055,133 @@ static bool test_trs_scan_probe_failure_backoff(void) return true; } +/* + * A reader that refuses the card scan but honours everything else is a verdict + * about one command, not a broken link: ask once, take the answer, tell the app + * that removal will not be reported, and carry on probing without it. + */ +static bool test_trs_scan_card_scan_unsupported_latches(void) +{ + struct trs_wire_nak_hook s = { .card_scan_only = true }; + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 150, + }; + int waited = 0, asked_at_verdict; + uint8_t status = 0; + + printf(SUB_2 "testing TRS card-scan refusal latches\n"); + + g_trs.status_seen = false; + g_trs.completion_seen = false; + g_trs.card_status = OSDP_TRS_CARD_STATUS_UNKNOWN; + + test_set_channel_hook(trs_wire_nak_hook, &s); + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to enable scan\n"); + return false; + } + while (waited < 5000 && + !(g_trs.status_seen && + g_trs.last_status == OSDP_TRS_SCAN_NO_DEPARTURE)) { + usleep(50 * 1000); + waited += 50; + } + if (!g_trs.status_seen || + g_trs.last_status != OSDP_TRS_SCAN_NO_DEPARTURE) { + test_set_channel_hook(NULL, NULL); + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + printf(SUB_2 "refusal never reported to the app\n"); + return false; + } + asked_at_verdict = s.card_scan_tx_count; + + /* Having heard the answer, the scan must stop asking */ + g_trs.status_seen = false; + usleep(2000 * 1000); + test_set_channel_hook(NULL, NULL); + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + + if (s.card_scan_tx_count != asked_at_verdict) { + printf(SUB_2 "kept asking after the refusal (%d -> %d)\n", + asked_at_verdict, s.card_scan_tx_count); + return false; + } + if (g_trs.status_seen && + g_trs.last_status == OSDP_TRS_SCAN_NO_DEPARTURE) { + printf(SUB_2 "refusal reported more than once\n"); + return false; + } + if (g_trs.status_seen && + g_trs.last_status == OSDP_TRS_SCAN_SUSPENDED) { + printf(SUB_2 "a refused card scan must not suspend the scan\n"); + return false; + } + if (g_trs.completion_seen) { + printf(SUB_2 "library's own card scan reported a completion\n"); + return false; + } + osdp_get_status_mask(g_trs.cp_ctx, &status); + if (!(status & 1)) { + printf(SUB_2 "PD dropped offline over a refused card scan\n"); + return false; + } + return true; +} + +/* + * Silence is not a refusal. A card scan that never comes back says nothing + * about whether the reader implements it -- that is the link failing, and it + * must take the ordinary session-error path rather than latching a verdict + * that would outlive the fault. + */ +static bool test_trs_scan_card_scan_timeout_is_not_unsupported(void) +{ + struct trs_wire_nak_hook s = { .card_scan_only = true, + .drop_reply = true }; + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 150, + }; + int waited = 0, asked_at_settle; + + printf(SUB_2 "testing TRS card-scan silence is not a refusal\n"); + + g_trs.status_seen = false; + g_trs.card_status = OSDP_TRS_CARD_STATUS_UNKNOWN; + + test_set_channel_hook(trs_wire_nak_hook, &s); + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + test_set_channel_hook(NULL, NULL); + printf(SUB_2 "failed to enable scan\n"); + return false; + } + while (waited < 6000 && s.card_scan_tx_count < 2) { + usleep(50 * 1000); + waited += 50; + } + asked_at_settle = s.card_scan_tx_count; + test_set_channel_hook(NULL, NULL); + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + + if (asked_at_settle < 2) { + printf(SUB_2 "stopped asking after silence (%d attempts)\n", + asked_at_settle); + return false; + } + if (g_trs.status_seen && + g_trs.last_status == OSDP_TRS_SCAN_NO_DEPARTURE) { + printf(SUB_2 "silence was mistaken for a refusal\n"); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_00, 5000)) { + printf(SUB_2 "reader left in transparent mode after the fault\n"); + return false; + } + return true; +} + /* --- wire-level unit tests: assert exact on-wire bytes vs SIA OSDP 2.2 --- */ /* @@ -973,6 +1218,73 @@ static bool test_trs_wire_mode_report(void) return true; } +/* + * Table 44: the presence scan's own card scan is selected by the scan's + * in-flight marker, not by the session sub-state -- so it must be emitted while + * the marker is set, and must not shadow a lifecycle step when it is not. + */ +static bool test_trs_wire_probe_card_scan_build(void) +{ + struct osdp ctx; + struct osdp_pd pd; + uint8_t buf[8]; + int len; + + printf(SUB_2 "testing TRS wire: probe card-scan layout\n"); + + trs_wire_pd_init(&ctx, &pd, TRS_MODE_01); + pd.trs.state = TRS_STATE_XMIT; + pd.trs.scan.asking = true; + len = osdp_trs_cmd_build(&pd, NULL, buf, sizeof(buf)); + if (len != 3 || buf[0] != 0x01 || buf[1] != 0x04 || buf[2] != 0x00) { + printf(SUB_2 "want [01 04 00] len 3; got len %d\n", len); + return false; + } + + /* Not asking: a lifecycle step must still win */ + pd.trs.scan.asking = false; + pd.trs.state = TRS_STATE_TEARDOWN; + len = osdp_trs_cmd_build(&pd, NULL, buf, sizeof(buf)); + if (len != 4 || buf[0] != 0x00 || buf[1] != 0x02 || + buf[2] != TRS_MODE_00) { + printf(SUB_2 "card-scan marker shadowed the mode-set\n"); + return false; + } + return true; +} + +/* + * The no-smart-card warning is raised once per enable, from wherever the + * capability first becomes known -- not once per capability report, which a + * reconnecting PD sends afresh every time. + */ +static bool test_trs_wire_capability_warning_is_once(void) +{ + struct osdp ctx; + struct osdp_pd pd; + + printf(SUB_2 "testing TRS wire: capability warning fires once\n"); + + trs_wire_pd_init(&ctx, &pd, TRS_MODE_00); + CLEAR_FLAG(&pd, PD_FLAG_TRS_CAPABLE); + pd.trs.scan.enabled = true; + pd.trs.scan.warned = false; + + osdp_trs_scan_note_capability(&pd); + if (!pd.trs.scan.warned) { + printf(SUB_2 "no warning for a PD with no smart-card reader\n"); + return false; + } + pd.trs.scan.warned = false; + SET_FLAG(&pd, PD_FLAG_TRS_CAPABLE); + osdp_trs_scan_note_capability(&pd); + if (pd.trs.scan.warned) { + printf(SUB_2 "warned about a PD that has a reader\n"); + return false; + } + return true; +} + /* Table 75: Card Present reply must carry both Reader and Status */ static bool test_trs_wire_card_present_build(void) { @@ -1488,6 +1800,129 @@ static bool test_trs_scan_cadence(void) } /* Event submitted by reference; must outlive the callback that ships it */ +/* + * A reader that answers the card scan is asked, on a cadence, and the answer + * is the app's first news of a card -- with no session, no APDU and no command + * completion of its own, because nobody submitted it. + */ +static bool test_trs_scan_probe_asks_card_scan(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 150, + }; + int waited = 0; + + printf(SUB_2 "testing TRS scan asks the reader for a card scan\n"); + + g_trs.card_scan_count = 0; + g_trs.present_events = 0; + g_trs.absent_events = 0; + g_trs.completion_seen = false; + g_trs.status_seen = false; + g_trs.card_status = OSDP_TRS_CARD_PRESENT_CONTACTLESS; + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + while (waited < 4000 && g_trs.present_events < 1) { + usleep(50 * 1000); + waited += 50; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + g_trs.card_status = OSDP_TRS_CARD_STATUS_UNKNOWN; + + if (g_trs.card_scan_count < 1) { + printf(SUB_2 "the probe never asked\n"); + return false; + } + if (g_trs.present_events < 1) { + printf(SUB_2 "sighting never reached the app\n"); + return false; + } + /* One question per probe cycle, not one per refresh tick */ + if (g_trs.card_scan_count > waited / 100) { + printf(SUB_2 "asked %d times in %d ms; too chatty\n", + g_trs.card_scan_count, waited); + return false; + } + if (g_trs.completion_seen) { + printf(SUB_2 "library's own card scan reported a completion\n"); + return false; + } + if (g_trs.status_seen) { + printf(SUB_2 "probe raised a session notification (%d)\n", + g_trs.last_status); + return false; + } + return true; +} + +/* + * The point of asking: a card lifted out of the field is reported gone, with + * the app having sent nothing. Presence is reported as it changes, so a card + * left sitting there is announced once, not once per probe. + */ +static bool test_trs_scan_card_departure_event(void) +{ + struct osdp_trs_scan_params p = { + .mode0_dwell_ms = 80, + .mode1_dwell_ms = 150, + }; + int waited = 0, present_after_settle; + + printf(SUB_2 "testing TRS scan reports card departure\n"); + + g_trs.present_events = 0; + g_trs.absent_events = 0; + g_trs.card_status = OSDP_TRS_CARD_PRESENT_CONTACTLESS; + + if (osdp_cp_trs_scan_enable(g_trs.cp_ctx, 0, &p)) { + printf(SUB_2 "failed to enable scan\n"); + return false; + } + while (waited < 4000 && g_trs.present_events < 1) { + usleep(50 * 1000); + waited += 50; + } + if (g_trs.present_events < 1) { + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + printf(SUB_2 "card never sighted\n"); + return false; + } + + /* The card stays put across several probes: still one sighting */ + usleep(1500 * 1000); + present_after_settle = g_trs.present_events; + if (present_after_settle != 1) { + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + printf(SUB_2 "a parked card was announced %d times\n", + present_after_settle); + return false; + } + + /* Now it is lifted away */ + g_trs.card_status = OSDP_TRS_CARD_NOT_PRESENT; + waited = 0; + while (waited < 4000 && g_trs.absent_events < 1) { + usleep(50 * 1000); + waited += 50; + } + osdp_cp_trs_scan_disable(g_trs.cp_ctx, 0); + g_trs.card_status = OSDP_TRS_CARD_STATUS_UNKNOWN; + + if (g_trs.absent_events < 1) { + printf(SUB_2 "card removal was never reported\n"); + return false; + } + if (!wait_for_pd_mode(TRS_MODE_00, 3000)) { + printf(SUB_2 "reader left in transparent mode\n"); + return false; + } + return true; +} + static struct osdp_event g_activity_ev; /* @@ -1658,6 +2093,8 @@ void run_trs_tests(struct test *t) result &= test_trs_wire_oversized_apdu_reject(); result &= test_trs_wire_piv_class_apdu_roundtrip(); result &= test_trs_wire_pin_entry_roundtrip(); + result &= test_trs_wire_probe_card_scan_build(); + result &= test_trs_wire_capability_warning_is_once(); if (setup_test_environment(t) != 0) { printf(SUB_1 "Failed to setup test environment\n"); @@ -1670,6 +2107,8 @@ void run_trs_tests(struct test *t) result &= test_trs_session_start(); result &= test_trs_non_trs_cmd_in_band(); result &= test_trs_apdu_too_big_for_packet(); + result &= test_trs_max_apdu_len_accessor(); + result &= test_trs_pin_entry_too_big_for_packet(); result &= test_trs_apdu_exchange(); result &= test_trs_deferred_apdu(); result &= test_trs_apdu_nak_keeps_session(); @@ -1679,6 +2118,8 @@ void run_trs_tests(struct test *t) result &= test_trs_session_stop(); result &= test_trs_unsolicited_xrd_out_of_session(); result &= test_trs_scan_cadence(); + result &= test_trs_scan_probe_asks_card_scan(); + result &= test_trs_scan_card_departure_event(); result &= test_trs_scan_mode0_activity_defers_probe(); result &= test_trs_scan_disable_and_flush_mid_probe(); @@ -1699,6 +2140,13 @@ void run_trs_tests(struct test *t) result &= test_trs_card_scan_wire_nak(); result &= test_trs_session_stop(); result &= test_trs_scan_hold_and_adopt(); + /* + * Order matters: the refusal latches "no card scan" for the rest of the + * connection, so the silence case -- which must leave the verdict open + * -- has to run while there is still a verdict to reach. + */ + result &= test_trs_scan_card_scan_timeout_is_not_unsupported(); + result &= test_trs_scan_card_scan_unsupported_latches(); result &= test_trs_scan_probe_failure_backoff(); teardown_test_environment();