From 0235de3f8ac4e71eafc416e383bcef6c429107a4 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Sat, 11 Jul 2026 23:48:43 +0200 Subject: [PATCH 1/7] 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 | 11 + include/osdp.h | 219 +++++++ library.json | 1 + python/setup.py | 11 + src/CMakeLists.txt | 10 + src/osdp_common.c | 2 +- src/osdp_common.h | 93 +++ src/osdp_cp.c | 391 +++++++++++- src/osdp_pd.c | 57 ++ src/osdp_trs.c | 824 ++++++++++++++++++++++++ src/osdp_trs.h | 85 +++ tests/unit-tests/CMakeLists.txt | 1 + tests/unit-tests/test-trs.c | 997 ++++++++++++++++++++++++++++++ tests/unit-tests/test.c | 17 + tests/unit-tests/test.h | 2 + 17 files changed, 2701 insertions(+), 28 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 6ebbc4ba..2f868463 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -66,12 +66,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 fa04457a..08648be0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,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 6b9a2270..4dd3aa53 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) @@ -214,6 +220,10 @@ fi TARGETS="cp_app pd_app" +if [[ ! -z "${ENABLE_TRS}" ]]; then + LIBOSDP_SOURCES+=" src/osdp_trs.c" +fi + TEST_SOURCES="tests/unit-tests/test.c" TEST_SOURCES+=" tests/unit-tests/test-cp-phy.c" TEST_SOURCES+=" tests/unit-tests/test-pd-phy.c" @@ -229,6 +239,7 @@ TEST_SOURCES+=" tests/unit-tests/test-sc-sia-vectors.c" TEST_SOURCES+=" tests/unit-tests/test-notifications.c" TEST_SOURCES+=" tests/unit-tests/test-codec-fuzz.c" TEST_SOURCES+=" tests/unit-tests/test-bio.c" +TEST_SOURCES+=" tests/unit-tests/test-trs.c" TEST_SOURCES+=" ${LIBOSDP_SOURCES} ${UTILS_SOURCES}" if [[ ! -z "${LIB_ONLY}" ]]; then diff --git a/include/osdp.h b/include/osdp.h index cf1d21b0..8f81db3d 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1044,6 +1044,16 @@ enum osdp_notification_type { * local abort, or a negative status reported by the PD). */ OSDP_NOTIFICATION_FILE_TX_DONE, + /** + * TRS card session state change (CP only). + * + * arg0: status -- see enum osdp_trs_session_status_e + * + * 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, }; /** @@ -1089,9 +1099,215 @@ enum osdp_cmd_e { OSDP_CMD_BIOREAD, /**< Scan and send biometric data command */ OSDP_CMD_BIOMATCH, /**< Scan and match biometric template command */ OSDP_CMD_TDSET, /**< Time and date set 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 as @a arg0 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 When set (`struct osdp_cmd::flags`), the command is sent out with the * OSDP packet broadcast flag to the PD. @@ -1136,6 +1352,7 @@ struct osdp_cmd { struct osdp_cmd_bioread bioread; /**< Biometric read command structure */ struct osdp_cmd_biomatch biomatch; /**< Biometric match command structure */ struct osdp_cmd_tdset tdset; /**< Time and date set command structure */ + struct osdp_trs_cmd trs; /**< Transparent mode command structure */ }; }; @@ -1365,6 +1582,7 @@ enum osdp_event_type { OSDP_EVENT_MFGERRR, /**< Manufacturer specific error reply event */ OSDP_EVENT_BIOREADR, /**< Scan and send biometric data event */ OSDP_EVENT_BIOMATCHR, /**< Scan and match biometric template event */ + OSDP_EVENT_TRS, /**< Transparent mode response event */ OSDP_EVENT_SENTINEL /**< Max event value */ }; @@ -1386,6 +1604,7 @@ struct osdp_event { struct osdp_event_biomatchr biomatchr; /**< Biometric match 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 14016901..eab77cb0 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 d1ef6c5d..cbad2864 100644 --- a/python/setup.py +++ b/python/setup.py @@ -155,6 +155,7 @@ def try_vendor_sources(src_dir, src_files, vendor_dir): "src/osdp_common.h", "src/osdp_file.h", "src/osdp_metrics.h", + "src/osdp_trs.h", "src/crypto/tinyaes_src.h", ] @@ -179,6 +180,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 @@ -187,6 +191,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", ] @@ -206,6 +212,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 c75501c7..22bedccd 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) @@ -93,6 +97,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.c b/src/osdp_common.c index 16eda986..36efaa32 100644 --- a/src/osdp_common.c +++ b/src/osdp_common.c @@ -438,7 +438,7 @@ void osdp_get_status_mask(const osdp_t *ctx, uint8_t *bitmask) *mask = 0; } pd = osdp_to_pd(ctx, i); - if (pd->state == OSDP_CP_STATE_ONLINE) { + if (cp_is_online(pd)) { *mask |= 1 << pos; } } diff --git a/src/osdp_common.h b/src/osdp_common.h index 56c2ff3f..ac565445 100644 --- a/src/osdp_common.h +++ b/src/osdp_common.h @@ -358,6 +358,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 */ @@ -374,6 +376,11 @@ static inline __noreturn void die() #define CP_REQ_DISABLE 0x00000008 #define CP_REQ_ENABLE 0x00000010 +#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, OSDP_CP_PHY_STATE_SEND_CMD, @@ -395,9 +402,41 @@ enum osdp_cp_state_e { OSDP_CP_STATE_PROBE, 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, /** @@ -553,6 +592,9 @@ struct osdp_pd { struct osdp_secure_channel sc; /* Secure Channel session context */ struct osdp_file *file; /* File transfer 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; @@ -588,6 +630,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); @@ -837,6 +885,51 @@ static inline void pd_set_offline(struct osdp_pd *pd) 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 +} + +/* + * 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; +} static inline bool check_request(struct osdp_pd *pd, uint32_t req) { if (pd->request & req) { diff --git a/src/osdp_cp.c b/src/osdp_cp.c index 990228c4..11e004c2 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -10,6 +10,14 @@ #include "osdp_file.h" #include "osdp_diag.h" #include "osdp_metrics.h" +#include "osdp_trs.h" + +/* + * Pseudo command id returned by a command selector to ask state_update() for a + * state change instead of a wire command. Kept clear of the OSDP command code + * range (a byte) so it can never be mistaken for one. + */ +#define CP_CMD_TRS_BAND_OPEN 0x100 enum osdp_cp_error_e { OSDP_CP_ERR_NONE = 0, @@ -23,8 +31,7 @@ enum osdp_cp_error_e { OSDP_CP_ERR_APP = -8, /* Application layer error */ }; -static void cp_dispatch_event(struct osdp_pd *pd, - const struct osdp_event *event) +static void cp_dispatch_event(struct osdp_pd *pd, const struct osdp_event *event) { struct osdp *ctx = pd_to_osdp(pd); @@ -63,6 +70,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) @@ -306,6 +321,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!"); @@ -504,6 +526,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: @@ -691,6 +722,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?"); @@ -848,6 +895,7 @@ static int cp_translate_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd) case OSDP_CMD_MFG: return CMD_MFG; case OSDP_CMD_BIOREAD: 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: return CMD_ISTAT; @@ -1057,29 +1105,95 @@ static const char *state_get_name(enum osdp_cp_state_e state) case OSDP_CP_STATE_ONLINE: return "Online"; case OSDP_CP_STATE_OFFLINE: 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(); } } +/* + * Set a dequeued command up as the active command and return the wire cmd_id to + * send for it, or -1 if it could not be translated (then it is completed FAILED). + */ +static int cp_setup_active_cmd(struct osdp_pd *pd, const struct osdp_cmd *cmd) +{ + int ret; + + ret = cp_translate_cmd(pd, cmd); + if (cmd->flags & OSDP_CMD_FLAG_BROADCAST) { + SET_FLAG(pd, PD_FLAG_PKT_BROADCAST); + } + if (ret < 0) { + cp_complete_cmd(pd, cmd, OSDP_COMPLETION_FAILED); + pd->active_cmd = NULL; + } else { + pd->active_cmd = cmd; + } + cp_cmd_free(pd, 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) +{ + if (osdp_millis_since(pd->tstamp) > OSDP_PD_POLL_TIMEOUT_MS) { + pd->tstamp = osdp_millis_now(); + return CMD_POLL; + } + + return -1; +} + static int cp_get_online_command(struct osdp_pd *pd) { const struct osdp_cmd *cmd; int ret; if (cp_cmd_dequeue(pd, &cmd) == 0) { - ret = cp_translate_cmd(pd, cmd); - if (cmd->flags & OSDP_CMD_FLAG_BROADCAST) { - SET_FLAG(pd, PD_FLAG_PKT_BROADCAST); +#ifdef OPT_BUILD_OSDP_TRS + if (cp_cmd_is_trs(cmd, OSDP_TRS_CMD_START)) { + cp_discard_cmd(pd, cmd, OSDP_COMPLETION_OK); + return CP_CMD_TRS_BAND_OPEN; } - if (ret < 0) { - cp_complete_cmd(pd, cmd, OSDP_COMPLETION_FAILED); - pd->active_cmd = NULL; - } else { - pd->active_cmd = cmd; - } - cp_cmd_free(pd, cmd); - return ret; +#endif + return cp_setup_active_cmd(pd, cmd); } ret = osdp_file_tx_get_command(pd); @@ -1087,13 +1201,46 @@ static int cp_get_online_command(struct osdp_pd *pd) return ret; } - if (osdp_millis_since(pd->tstamp) > OSDP_PD_POLL_TIMEOUT_MS) { - pd->tstamp = osdp_millis_now(); - return CMD_POLL; - } + return cp_get_poll_command(pd); +} - return -1; +#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) { @@ -1111,6 +1258,25 @@ static void notify_pd_status(struct osdp_pd *pd, bool is_online) 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.arg0 = 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); @@ -1161,7 +1327,7 @@ static void cp_keyset_complete(struct osdp_pd *pd) } sc_deactivate(pd); notify_sc_status(pd); - if (pd->state == OSDP_CP_STATE_ONLINE) { + if (cp_is_online(pd)) { make_request(pd, CP_REQ_RESTART_SC); LOG_INF("SCBK set; restarting SC to verify new SCBK"); } @@ -1200,7 +1366,8 @@ static bool cp_check_online_response(struct osdp_pd *pd) pd->reply_id == REPLY_BIOREADR || pd->reply_id == REPLY_BIOMATCHR || 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); @@ -1230,6 +1397,11 @@ static bool cp_check_online_response(struct osdp_pd *pd) case CMD_OSTAT: return pd->reply_id == REPLY_OSTATR; case CMD_OUT: 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, @@ -1249,6 +1421,11 @@ static inline int state_get_cmd(struct osdp_pd *pd) case OSDP_CP_STATE_SC_SCRYPT: return CMD_SCRYPT; case OSDP_CP_STATE_SET_SCBK: 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; } } @@ -1264,6 +1441,11 @@ static inline bool state_check_reply(struct osdp_pd *pd) case OSDP_CP_STATE_SC_SCRYPT: return pd->reply_id == REPLY_RMAC_I; case OSDP_CP_STATE_SET_SCBK: 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; } } @@ -1314,6 +1496,11 @@ static enum osdp_cp_state_e get_next_ok_state(struct osdp_pd *pd) return OSDP_CP_STATE_OFFLINE; case OSDP_CP_STATE_DISABLED: return OSDP_CP_STATE_DISABLED; +#ifdef OPT_BUILD_OSDP_TRS + case OSDP_CP_STATE_TRS_SETUP: + case OSDP_CP_STATE_TRS_RUN: + return osdp_trs_state_update(pd); +#endif default: BUG(); } } @@ -1367,6 +1554,11 @@ static enum osdp_cp_state_e get_next_err_state(struct osdp_pd *pd) return OSDP_CP_STATE_OFFLINE; case OSDP_CP_STATE_DISABLED: return OSDP_CP_STATE_DISABLED; +#ifdef OPT_BUILD_OSDP_TRS + case OSDP_CP_STATE_TRS_SETUP: + case OSDP_CP_STATE_TRS_RUN: + return osdp_trs_state_update_err(pd); +#endif default: BUG(); } } @@ -1385,6 +1577,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; @@ -1411,6 +1620,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; } @@ -1424,9 +1648,28 @@ static void cp_state_change(struct osdp_pd *pd, enum osdp_cp_state_e next) pd->state = next; } -static bool cp_cmd_is_app_owned(int cmd_id) +/* + * 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 (cmd_id) { + switch (pd->cmd_id) { + case CMD_XWR: + return cp_trs_cmd_is_app_owned(pd); case CMD_OUT: case CMD_LED: case CMD_BUZ: @@ -1470,16 +1713,19 @@ 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. */ static bool cp_cmd_failure_is_soft(struct osdp_pd *pd) { - if (pd->state != OSDP_CP_STATE_ONLINE || + if (!cp_is_online(pd) || pd->phy_state != OSDP_CP_PHY_STATE_DONE || - !cp_cmd_is_app_owned(pd->cmd_id)) { + !cp_cmd_is_app_owned(pd)) { return false; } @@ -1496,6 +1742,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); } @@ -1548,6 +1801,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; } @@ -1578,6 +1840,14 @@ static int state_update(struct osdp_pd *pd) switch (pd->phy_state) { case OSDP_CP_PHY_STATE_IDLE: pd->cmd_id = state_get_cmd(pd); +#ifdef OPT_BUILD_OSDP_TRS + if (pd->cmd_id == CP_CMD_TRS_BAND_OPEN) { + /* A START was consumed: open the session and let the TRS + * state machine pick its first wire command (mode-set) */ + cp_state_change(pd, OSDP_CP_STATE_TRS_SETUP); + pd->cmd_id = state_get_cmd(pd); + } +#endif if (pd->cmd_id > 0 && cp_phy_kick(pd)) { return OSDP_CP_ERR_DEFER; } @@ -1633,6 +1903,48 @@ static int state_update(struct osdp_pd *pd) return OSDP_CP_ERR_DEFER; } +#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 = ( @@ -1644,7 +1956,7 @@ static int cp_submit_command(struct osdp_pd *pd, const struct osdp_cmd *cmd) return -1; } - if (pd->state != OSDP_CP_STATE_ONLINE) { + if (!cp_is_online(pd)) { LOG_ERR("PD is not online"); return -1; } @@ -1683,6 +1995,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); } @@ -2007,6 +2337,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 3d588f88..b05eff80 100644 --- a/src/osdp_pd.c +++ b/src/osdp_pd.c @@ -8,6 +8,7 @@ #include "osdp_file.h" #include "osdp_diag.h" #include "osdp_metrics.h" +#include "osdp_trs.h" #ifndef OPT_OSDP_STATIC #include @@ -176,6 +177,12 @@ static int pd_translate_event(struct osdp_pd *pd, const struct osdp_event *event case OSDP_EVENT_BIOMATCHR: reply_code = REPLY_BIOMATCHR; 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(); @@ -969,6 +976,48 @@ static int pd_decode_command(struct osdp_pd *pd, uint8_t *buf, int len) break; } 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; @@ -1295,6 +1344,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..8c000266 --- /dev/null +++ b/src/osdp_trs.c @@ -0,0 +1,824 @@ +/* + * 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..60af30ab --- /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/CMakeLists.txt b/tests/unit-tests/CMakeLists.txt index 2f8e8696..f6638135 100644 --- a/tests/unit-tests/CMakeLists.txt +++ b/tests/unit-tests/CMakeLists.txt @@ -35,6 +35,7 @@ list(APPEND OSDP_UNIT_TEST_SRC test-sc.c test-sc-sia-vectors.c test-pd-zc.c + test-trs.c ) add_executable(${OSDP_UNIT_TEST} EXCLUDE_FROM_ALL ${OSDP_UNIT_TEST_SRC}) diff --git a/tests/unit-tests/test-trs.c b/tests/unit-tests/test-trs.c new file mode 100644 index 00000000..8a86ad05 --- /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.arg0; + } + 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 49aeaeac..12724074 100644 --- a/tests/unit-tests/test.c +++ b/tests/unit-tests/test.c @@ -899,6 +899,7 @@ int test_setup_devices_ext(struct test *t, osdp_t **cp, osdp_t **pd, { OSDP_PD_CAP_CONTACT_STATUS_MONITORING, 1, 8 }, { OSDP_PD_CAP_BIOMETRICS, 1, 1 }, { OSDP_PD_CAP_TIME_KEEPING, 1, 0 }, + { OSDP_PD_CAP_SMART_CARD_SUPPORT, 1, 1 }, { -1, -1, -1 } }; @@ -943,6 +944,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)); @@ -1128,6 +1144,7 @@ int main(int argc, char *argv[]) { "codec_fuzz", run_codec_fuzz_tests }, { "sc", run_sc_tests }, { "vectors", run_vector_tests }, + { "trs", run_trs_tests }, }; ARG_UNUSED(argc); diff --git a/tests/unit-tests/test.h b/tests/unit-tests/test.h index 1f08eb22..0002b627 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -118,6 +118,7 @@ void test_suite_end(struct test *t); 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 @@ -166,6 +167,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 3e7df4f14b14549e0e6f606c9753214f54b69327 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Tue, 14 Jul 2026 18:39:44 +0200 Subject: [PATCH 2/7] 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 8c000266..0cb960a4 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -631,11 +631,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 8a86ad05..7f4a3185 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 cf1a5d8615c6ce94317ccc91ff8bdfa861676594 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:09:11 +0200 Subject: [PATCH 3/7] 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 11e004c2..60a195b0 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -444,7 +444,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"); @@ -1749,6 +1754,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 7f4a3185..c7f7cbcb 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 12724074..780c6642 100644 --- a/tests/unit-tests/test.c +++ b/tests/unit-tests/test.c @@ -841,8 +841,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); @@ -882,7 +883,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); @@ -939,6 +940,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 0002b627..b79da0e7 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -118,6 +118,8 @@ void test_suite_end(struct test *t); 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 900edda3eb95ca56fa6a76b452ef397460dbdb5d Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:27:43 +0200 Subject: [PATCH 4/7] 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 60a195b0..6e4680e1 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -729,8 +729,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) { @@ -1372,7 +1376,10 @@ static bool cp_check_online_response(struct osdp_pd *pd) pd->reply_id == REPLY_BIOMATCHR || 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 c7f7cbcb..9d10c167 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 d50f3350e76ec82dd5b1547ad24091bf3685f558 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 15:48:43 +0200 Subject: [PATCH 5/7] 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 8f81db3d..5adf1ab8 100644 --- a/include/osdp.h +++ b/include/osdp.h @@ -1148,8 +1148,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 6e4680e1..bdad8020 100644 --- a/src/osdp_cp.c +++ b/src/osdp_cp.c @@ -1955,6 +1955,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 0cb960a4..e22b7611 100644 --- a/src/osdp_trs.c +++ b/src/osdp_trs.c @@ -221,6 +221,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 60af30ab..29325e47 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 9d10c167..cfd01b70 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 32659afc220d1bba25be339377ffdbbccaac7bde Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 16:31:32 +0200 Subject: [PATCH 6/7] 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 --- configure.sh | 2 + tests/unit-tests/CMakeLists.txt | 2 + tests/unit-tests/emu-card.c | 157 +++++++++ tests/unit-tests/emu-card.h | 45 +++ tests/unit-tests/test-trs-emu.c | 564 ++++++++++++++++++++++++++++++++ tests/unit-tests/test.c | 1 + tests/unit-tests/test.h | 1 + 7 files changed, 772 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/configure.sh b/configure.sh index 4dd3aa53..3c009a95 100755 --- a/configure.sh +++ b/configure.sh @@ -240,6 +240,8 @@ TEST_SOURCES+=" tests/unit-tests/test-notifications.c" TEST_SOURCES+=" tests/unit-tests/test-codec-fuzz.c" TEST_SOURCES+=" tests/unit-tests/test-bio.c" TEST_SOURCES+=" tests/unit-tests/test-trs.c" +TEST_SOURCES+=" tests/unit-tests/test-trs-emu.c" +TEST_SOURCES+=" tests/unit-tests/emu-card.c" TEST_SOURCES+=" ${LIBOSDP_SOURCES} ${UTILS_SOURCES}" if [[ ! -z "${LIB_ONLY}" ]]; then diff --git a/tests/unit-tests/CMakeLists.txt b/tests/unit-tests/CMakeLists.txt index f6638135..f0d5f08b 100644 --- a/tests/unit-tests/CMakeLists.txt +++ b/tests/unit-tests/CMakeLists.txt @@ -36,6 +36,8 @@ list(APPEND OSDP_UNIT_TEST_SRC test-sc-sia-vectors.c test-pd-zc.c test-trs.c + test-trs-emu.c + emu-card.c ) add_executable(${OSDP_UNIT_TEST} EXCLUDE_FROM_ALL ${OSDP_UNIT_TEST_SRC}) 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-trs-emu.c b/tests/unit-tests/test-trs-emu.c new file mode 100644 index 00000000..fadb332e --- /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.arg0; + } + 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.c b/tests/unit-tests/test.c index 780c6642..59fbcf43 100644 --- a/tests/unit-tests/test.c +++ b/tests/unit-tests/test.c @@ -1160,6 +1160,7 @@ int main(int argc, char *argv[]) { "sc", run_sc_tests }, { "vectors", run_vector_tests }, { "trs", run_trs_tests }, + { "trs_emu", run_trs_emu_tests }, }; ARG_UNUSED(argc); diff --git a/tests/unit-tests/test.h b/tests/unit-tests/test.h index b79da0e7..061443e5 100644 --- a/tests/unit-tests/test.h +++ b/tests/unit-tests/test.h @@ -170,6 +170,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 3da491c9f8e6a366091e6fafa1a6f57f71d64214 Mon Sep 17 00:00:00 2001 From: Siddharth Chandrasekaran Date: Fri, 17 Jul 2026 16:46:19 +0200 Subject: [PATCH 7/7] 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 --- configure.sh | 1 + tests/unit-tests/test-busy.c | 8 ++++---- tests/unit-tests/test-trs-emu.c | 8 ++++---- tests/unit-tests/test-trs.c | 8 ++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/configure.sh b/configure.sh index 3c009a95..6ba5a60d 100755 --- a/configure.sh +++ b/configure.sh @@ -239,6 +239,7 @@ TEST_SOURCES+=" tests/unit-tests/test-sc-sia-vectors.c" TEST_SOURCES+=" tests/unit-tests/test-notifications.c" TEST_SOURCES+=" tests/unit-tests/test-codec-fuzz.c" TEST_SOURCES+=" tests/unit-tests/test-bio.c" +TEST_SOURCES+=" tests/unit-tests/test-pd-zc.c" TEST_SOURCES+=" tests/unit-tests/test-trs.c" TEST_SOURCES+=" tests/unit-tests/test-trs-emu.c" TEST_SOURCES+=" tests/unit-tests/emu-card.c" diff --git a/tests/unit-tests/test-busy.c b/tests/unit-tests/test-busy.c index d75b00e5..7d3f4057 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 fadb332e..66884034 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 cfd01b70..21576fe7 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];