From dff942c90002e7ed6b42d74451814f866bc9c05e Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 6 May 2026 10:19:59 +0100 Subject: [PATCH] Add TROPIC01 simulator --- .github/workflows/tropic01-sdk-test.yml | 30 + .github/workflows/tropic01-test-suite.yml | 26 + .github/workflows/tropic01-wolfcrypt-test.yml | 30 + README.md | 11 + STSAFEA120Sim/.gitignore | 1 + TROPIC01Sim/.gitignore | 6 + TROPIC01Sim/Dockerfile | 21 + TROPIC01Sim/Dockerfile.sdk-test | 53 ++ TROPIC01Sim/Dockerfile.wolfcrypt | 94 +++ TROPIC01Sim/LICENSE | 674 ++++++++++++++++++ TROPIC01Sim/README.md | 92 +++ TROPIC01Sim/sdk-test/CMakeLists.txt | 56 ++ TROPIC01Sim/sdk-test/run_test.sh | 54 ++ TROPIC01Sim/sdk-test/test_tropic01.c | 208 ++++++ TROPIC01Sim/tropic01-sim/Cargo.toml | 25 + .../tropic01-sim/src/bin/tcp_server.rs | 175 +++++ TROPIC01Sim/tropic01-sim/src/crc.rs | 72 ++ TROPIC01Sim/tropic01-sim/src/dispatch.rs | 158 ++++ TROPIC01Sim/tropic01-sim/src/frame.rs | 210 ++++++ .../tropic01-sim/src/handlers/get_info.rs | 143 ++++ TROPIC01Sim/tropic01-sim/src/handlers/l3.rs | 513 +++++++++++++ TROPIC01Sim/tropic01-sim/src/handlers/mod.rs | 23 + TROPIC01Sim/tropic01-sim/src/lib.rs | 36 + .../tropic01-sim/src/object_store/mod.rs | 354 +++++++++ .../tropic01-sim/src/object_store/types.rs | 124 ++++ TROPIC01Sim/tropic01-sim/src/session.rs | 402 +++++++++++ TROPIC01Sim/tropic01-sim/src/spi.rs | 304 ++++++++ TROPIC01Sim/tropic01-sim/src/tcp_proto.rs | 176 +++++ TROPIC01Sim/tropic01-sim/tests/tcp.rs | 139 ++++ TROPIC01Sim/wolfcrypt-test/run_test.sh | 55 ++ 30 files changed, 4265 insertions(+) create mode 100644 .github/workflows/tropic01-sdk-test.yml create mode 100644 .github/workflows/tropic01-test-suite.yml create mode 100644 .github/workflows/tropic01-wolfcrypt-test.yml create mode 100644 TROPIC01Sim/.gitignore create mode 100644 TROPIC01Sim/Dockerfile create mode 100644 TROPIC01Sim/Dockerfile.sdk-test create mode 100644 TROPIC01Sim/Dockerfile.wolfcrypt create mode 100644 TROPIC01Sim/LICENSE create mode 100644 TROPIC01Sim/README.md create mode 100644 TROPIC01Sim/sdk-test/CMakeLists.txt create mode 100644 TROPIC01Sim/sdk-test/run_test.sh create mode 100644 TROPIC01Sim/sdk-test/test_tropic01.c create mode 100644 TROPIC01Sim/tropic01-sim/Cargo.toml create mode 100644 TROPIC01Sim/tropic01-sim/src/bin/tcp_server.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/crc.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/dispatch.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/frame.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/handlers/get_info.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/handlers/l3.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/handlers/mod.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/lib.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/object_store/mod.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/object_store/types.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/session.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/spi.rs create mode 100644 TROPIC01Sim/tropic01-sim/src/tcp_proto.rs create mode 100644 TROPIC01Sim/tropic01-sim/tests/tcp.rs create mode 100644 TROPIC01Sim/wolfcrypt-test/run_test.sh diff --git a/.github/workflows/tropic01-sdk-test.yml b/.github/workflows/tropic01-sdk-test.yml new file mode 100644 index 0000000..1007af6 --- /dev/null +++ b/.github/workflows/tropic01-sdk-test.yml @@ -0,0 +1,30 @@ +name: TROPIC01 SDK test + +on: + push: + branches: [main] + pull_request: + branches: ['**'] + workflow_dispatch: + +jobs: + sdk-test: + name: libtropic-driven integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build sdk-test image + uses: docker/build-push-action@v6 + with: + context: TROPIC01Sim + file: TROPIC01Sim/Dockerfile.sdk-test + tags: tropic01-sdk-test:ci + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run sdk-test suite + run: docker run --rm tropic01-sdk-test:ci diff --git a/.github/workflows/tropic01-test-suite.yml b/.github/workflows/tropic01-test-suite.yml new file mode 100644 index 0000000..e6cdf46 --- /dev/null +++ b/.github/workflows/tropic01-test-suite.yml @@ -0,0 +1,26 @@ +name: TROPIC01 test suite + +on: + push: + branches: [main] + pull_request: + branches: ['**'] + workflow_dispatch: + +jobs: + cargo-test: + name: cargo test (unit + integration) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: TROPIC01Sim/tropic01-sim + + - name: cargo test + run: | + cargo test --manifest-path TROPIC01Sim/tropic01-sim/Cargo.toml \ + -- --test-threads=1 diff --git a/.github/workflows/tropic01-wolfcrypt-test.yml b/.github/workflows/tropic01-wolfcrypt-test.yml new file mode 100644 index 0000000..0c148d8 --- /dev/null +++ b/.github/workflows/tropic01-wolfcrypt-test.yml @@ -0,0 +1,30 @@ +name: TROPIC01 wolfCrypt test + +on: + push: + branches: [main] + pull_request: + branches: ['**'] + workflow_dispatch: + +jobs: + wolfcrypt-test: + name: wolfCrypt + libtropic integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build wolfcrypt-test image + uses: docker/build-push-action@v6 + with: + context: TROPIC01Sim + file: TROPIC01Sim/Dockerfile.wolfcrypt + tags: tropic01-wolfcrypt-test:ci + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run wolfCrypt test suite + run: docker run --rm tropic01-wolfcrypt-test:ci diff --git a/README.md b/README.md index 7c3f1cc..80249e0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ ECDSA, ECDH, RNG, and a slot/zone store with a default device certificate. It plugs into ST's open-source STSELib middleware via a custom Linux PAL that pipes the I2C transport over TCP. +## TROPIC01Sim + +The [TROPIC01Sim](TROPIC01Sim/) is a simulator for the Tropic Square TROPIC01 +secure element. It speaks libtropic's "TROPIC01 Model" wire protocol over +TCP and performs the full Noise_KK1_25519_AESGCM_SHA256 secure-channel +handshake, then answers the L3 commands the wolfSSL TROPIC01 port exercises: +RNG, ECC keygen/read for P-256 + Ed25519, R-memory read/write, and the +pairing-key surface. The simulator is consumed unmodified by libtropic via +its `hal/posix/tcp/` HAL. + ## STM32Sim The [STM32Sim](STM32Sim/) is a Unicorn-Engine-based simulator for STM32 @@ -31,3 +41,4 @@ microcontrollers focused on the on-chip cryptographic accelerators the Renode-based CI flow for wolfSSL on STM32 targets and to close the gaps Renode has in hardware-crypto modelling (HASH peripheral, full AES mode set, PKA). + diff --git a/STSAFEA120Sim/.gitignore b/STSAFEA120Sim/.gitignore index 2f5b223..3fe6116 100644 --- a/STSAFEA120Sim/.gitignore +++ b/STSAFEA120Sim/.gitignore @@ -3,3 +3,4 @@ target/ *.a *.so stsafe_a120_store.json +Cargo.lock diff --git a/TROPIC01Sim/.gitignore b/TROPIC01Sim/.gitignore new file mode 100644 index 0000000..1a111d8 --- /dev/null +++ b/TROPIC01Sim/.gitignore @@ -0,0 +1,6 @@ +target/ +*.o +*.a +*.so +tropic01_store.json +Cargo.lock diff --git a/TROPIC01Sim/Dockerfile b/TROPIC01Sim/Dockerfile new file mode 100644 index 0000000..579b3a5 --- /dev/null +++ b/TROPIC01Sim/Dockerfile @@ -0,0 +1,21 @@ +# Dockerfile +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# Rust unit + TCP integration tests. +FROM rust:1.85-bookworm + +WORKDIR /app + +COPY tropic01-sim/ /app/tropic01-sim/ + +RUN cd /app/tropic01-sim && cargo build 2>&1 + +CMD ["cargo", "test", "--manifest-path", "/app/tropic01-sim/Cargo.toml", "--", "--test-threads=1", "--nocapture"] diff --git a/TROPIC01Sim/Dockerfile.sdk-test b/TROPIC01Sim/Dockerfile.sdk-test new file mode 100644 index 0000000..f39fb0e --- /dev/null +++ b/TROPIC01Sim/Dockerfile.sdk-test @@ -0,0 +1,53 @@ +# Dockerfile.sdk-test +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# Stage 1: build the Rust simulator TCP server. +FROM rust:1.85-bookworm AS sim-builder + +WORKDIR /app +COPY tropic01-sim/ /app/tropic01-sim/ +RUN cd /app/tropic01-sim && cargo build --release --bin tcp_server 2>&1 + +# ============================================================================= +# Stage 2: build libtropic (with mbedTLS v4 CAL + posix/tcp HAL) and our +# integration test binary that drives it against the simulator. +# ============================================================================= +FROM debian:bookworm + +RUN apt-get update && apt-get install -y \ + build-essential cmake git ca-certificates pkg-config python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=sim-builder /app/tropic01-sim/target/release/tcp_server /app/tcp_server + +# ---- Clone libtropic at a pinned commit ---- +# Pinned to an explicit commit so the simulator's L2/L3 wire-protocol +# expectations stay reproducible. Bump deliberately if the upstream +# protocol changes (CHANGELOG.md tracks renames and structural shifts). +ARG LIBTROPIC_REF=51044cdc2e0aabff42305130b344c5db3136f158 +RUN git clone https://github.com/tropicsquare/libtropic.git /app/libtropic && \ + git -C /app/libtropic checkout ${LIBTROPIC_REF} + +# ---- Drop in our test source as a libtropic example tree ---- +# Easiest way to inherit libtropic's CMake setup (mbedTLS v4 fetch, CAL +# wiring, posix/tcp HAL) is to live inside `examples/model/`. We borrow +# the hello_world CMakeLists.txt as a template and swap in our own main. +COPY sdk-test/ /app/libtropic/examples/model/sim_test/ +WORKDIR /app/libtropic/examples/model/sim_test/build +RUN cmake .. && make -j$(nproc) + +COPY sdk-test/run_test.sh /app/run_test.sh +RUN chmod +x /app/run_test.sh + +ENV TROPIC01_SIM_HOST=127.0.0.1 +ENV TROPIC01_SIM_PORT=28992 + +CMD ["/app/run_test.sh"] diff --git a/TROPIC01Sim/Dockerfile.wolfcrypt b/TROPIC01Sim/Dockerfile.wolfcrypt new file mode 100644 index 0000000..edae2bf --- /dev/null +++ b/TROPIC01Sim/Dockerfile.wolfcrypt @@ -0,0 +1,94 @@ +# Dockerfile.wolfcrypt +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# Stage 1: build the Rust simulator TCP server. +FROM rust:1.85-bookworm AS sim-builder + +WORKDIR /app +COPY tropic01-sim/ /app/tropic01-sim/ +RUN cd /app/tropic01-sim && cargo build --release --bin tcp_server 2>&1 + +# ============================================================================= +# Stage 2: build libtropic v0.1.0, build wolfSSL --with-tropic01, build +# Tropic Square's wolfssl-test app, run it against the simulator. +# +# Why v0.1.0: wolfSSL's `wolfcrypt/src/port/tropicsquare/tropic01.c` calls +# `lt_random_get`, `verify_chip_and_start_secure_session`, `CURVE_ED25519`, +# and `lt_r_mem_data_read(h, slot, buf, size)` (4-arg form). All of these +# were renamed in libtropic v1.0.0. The wolfSSL port has not been updated +# upstream, so we pin to the last release that matches its API. +# ============================================================================= +FROM debian:bookworm + +RUN apt-get update && apt-get install -y \ + build-essential cmake git autoconf automake libtool pkg-config \ + ca-certificates wget python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=sim-builder /app/tropic01-sim/target/release/tcp_server /app/tcp_server + +# ---- Clone libtropic at the v0.1.0 tag matching wolfSSL's port ---- +ARG LIBTROPIC_REF=v0.1.0 +RUN git clone --branch ${LIBTROPIC_REF} --depth 1 \ + https://github.com/tropicsquare/libtropic.git /app/libtropic + +# ---- Build libtropic with trezor_crypto + the v0.1.0 unix_tcp HAL. +# The HAL bypass: v0.1.0's hal/port/unix/lt_port_unix_tcp.c is exactly +# the protocol our simulator speaks (TAG_E_SPI_* over TCP at port 28992), +# so no custom shim is needed. We just point it at our simulator. +WORKDIR /app/libtropic/build +RUN cmake -DLT_USE_TREZOR_CRYPTO=1 -DLT_BUILD_TESTS=0 .. && make -j$(nproc) tropic + +# ---- Clone + build wolfSSL master with --with-tropic01 ---- +ARG WOLFSSL_REF=master +RUN git clone --branch ${WOLFSSL_REF} --depth 1 \ + https://github.com/wolfSSL/wolfssl.git /app/wolfssl +WORKDIR /app/wolfssl +# Patch the upstream wolfSSL TROPIC01 port for two known issues: +# - It calls `ForceZero` (a non-existent symbol). The library has +# `wc_ForceZero`. Both with -Werror=nested-externs the implicit +# declaration is fatal, so we sed-fix it. +# - It expects libtropic v0.x's `LT_SEPARATE_L3_BUFF` macro to be +# defined. We set it to 0 via CFLAGS below. +RUN sed -i 's/\bForceZero\b/wc_ForceZero/g' \ + /app/wolfssl/wolfcrypt/src/port/tropicsquare/tropic01.c + +RUN ./autogen.sh && \ + ./configure \ + --with-tropic01=/app/libtropic \ + --enable-cryptocb \ + --enable-ed25519 \ + --enable-static --disable-shared \ + --disable-crypttests --disable-examples \ + CFLAGS="-DWOLFSSL_TROPIC01 -DLT_SEPARATE_L3_BUFF=0" && \ + make -j$(nproc) && \ + make install + +# ---- Clone Tropic Square's upstream wolfSSL test ---- +ARG TROPIC_TEST_REF=main +RUN git clone --branch ${TROPIC_TEST_REF} --depth 1 \ + https://github.com/tropicsquare/tropic01-wolfssl-test.git /app/tropic01-wolfssl-test + +# ---- Patch the test's Makefile to use the v0.1.0 unix_tcp HAL instead +# of the USB dongle HAL (we don't have a USB dongle in CI, just TCP) ---- +WORKDIR /app/tropic01-wolfssl-test +RUN sed -i 's|lt_port_unix_usb_dongle.c|lt_port_unix_tcp.c|' Makefile && \ + sed -i 's|^LIBTROPIC_DIR =.*|LIBTROPIC_DIR = /app/libtropic|' Makefile && \ + make 2>&1 + +COPY wolfcrypt-test/run_test.sh /app/run_test.sh +RUN chmod +x /app/run_test.sh + +ENV LD_LIBRARY_PATH=/usr/local/lib +ENV TROPIC01_SIM_HOST=127.0.0.1 +ENV TROPIC01_SIM_PORT=28992 + +CMD ["/app/run_test.sh"] diff --git a/TROPIC01Sim/LICENSE b/TROPIC01Sim/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/TROPIC01Sim/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/TROPIC01Sim/README.md b/TROPIC01Sim/README.md new file mode 100644 index 0000000..e816ff2 --- /dev/null +++ b/TROPIC01Sim/README.md @@ -0,0 +1,92 @@ +# TROPIC01 Simulator + +A software simulator for the Tropic Square TROPIC01 secure element, written in Rust. Speaks libtropic's "TROPIC01 Model" wire protocol over TCP, performs the full Noise_KK1_25519_AESGCM_SHA256 secure-channel handshake, and answers the L3 command surface that libtropic and wolfSSL exercise -- so wolfSSL's TROPIC01 port can be regression-tested without physical hardware. + +## Features + +### Wire protocol layers +- **L1 SPI byte exchange** (the simulator emulates the chip's SPI side, including CHIP_STATUS polling and the `0xAA` GET_RESPONSE convention from `lt_l1.c`) +- **L2 frames**: `[REQ_ID(1B)][REQ_LEN(1B)][DATA][CRC(2B BE)]` with the libtropic-specific CRC-16 (poly `0x8005`, init `0x0000`, byte-swapped output) -- matches `lt_crc16.c` exactly +- **L3 secure channel**: full Noise_KK1 handshake (X25519 ECDH triple + custom HKDF chain + AES-GCM auth tag) and AES-256-GCM tunnel with per-direction nonce counters +- **TCP framing**: libtropic's `hal/posix/tcp/` `[tag(1B)][len(2B LE)][payload]` framing, matching `lt_posix_tcp_tag_t` exactly (`SPI_DRIVE_CSN_LOW/HIGH`, `SPI_SEND`, `WAIT`, `RESET_TARGET`, etc.) + +### L3 commands +- `PING` (0x01) -- echo loopback +- `RANDOM_VALUE_GET` (0x50) -- 1..255 random bytes from `rand::OsRng` +- `ECC_KEY_GENERATE` / `STORE` / `READ` / `ERASE` (0x60-0x63) -- NIST P-256 + Ed25519 +- `R_MEM_DATA_READ` / `WRITE` (0x40, 0x41) -- arbitrary host data slots +- `PAIRING_KEY_WRITE` / `READ` / `INVALIDATE` (0x10, 0x11, 0x12) + +### L2 (plain) commands +- `GET_INFO` (0x01) -- chip ID, FW versions, X.509 certificate store (4-cert chunked read) +- `HANDSHAKE` (0x02) -- opens the Secure Channel +- `STARTUP` (0xB3), `SLEEP` (0x20), `SESSION_ABORT` (0x08), `RESEND` (0x10) + +### Device state +- Random 12-byte chip ID (returned by `GET_INFO(CHIP_ID)`) +- Static X25519 keypair (STPRIV/STPUB) generated at first boot +- 4-cert "cert store" containing a DER device certificate carrying STPUB at the libtropic-recognisable X25519 SPKI offset +- Pairing slot 0 pre-provisioned with libtropic's `sh0pub_eng_sample` so the engineering-sample SHIPRIV/SHIPUB pair authenticates without extra setup +- R-memory slots 0-3 pre-provisioned to match the wolfSSL TROPIC01 port's hardcoded slot map (AES key, AES IV, Ed25519 pub, Ed25519 priv) +- JSON-persisted object store + +## Quick start + +All three Docker tiers are run from inside `TROPIC01Sim/`: + +```bash +# 1. Rust unit + integration tests (CRC, framing, SPI emulator, handshake math, all L3 commands) +docker build -t tropic01-sim . +docker run --rm tropic01-sim + +# 2. libtropic-driven SDK test (mbedTLS v4 CAL + posix/tcp HAL, exercises the same surface wolfSSL hits) +docker build -f Dockerfile.sdk-test -t tropic01-sdk-test . +docker run --rm tropic01-sdk-test + +# 3. wolfSSL --with-tropic01 + Tropic Square's upstream wolfssl-test app (RNG, AES, Ed25519 keygen/sign/verify) +docker build -f Dockerfile.wolfcrypt -t tropic01-wolfcrypt . +docker run --rm tropic01-wolfcrypt +``` + +## Native development + +```bash +# Build +cargo build --manifest-path tropic01-sim/Cargo.toml + +# Unit + integration tests +cargo test --manifest-path tropic01-sim/Cargo.toml -- --test-threads=1 + +# Run the TCP server (listens on 127.0.0.1:28992 to match libtropic's posix/tcp HAL default) +cargo run --manifest-path tropic01-sim/Cargo.toml --release --bin tcp_server +``` + +Environment variables for the TCP server: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `TROPIC01_SIM_BIND` | `127.0.0.1` | Listen address | +| `TROPIC01_SIM_PORT` | `28992` | Listen port (matches `LIBTROPIC_PORT_POSIX_TCP`'s default) | +| `TROPIC01_SIM_STORE` | `tropic01_store.json` | On-disk persistence path | +| `TROPIC01_SIM_FRESH` | (unset) | If set, ignore the on-disk store and reprovision from defaults | + +## Pinned upstream versions + +| Tier | Dependency | Pin | Why | +| --- | --- | --- | --- | +| Tier 2 (sdk-test) | libtropic | commit `51044cd` | Latest libtropic master at the time of writing -- targets the modern `lt_*` API + posix/tcp HAL | +| Tier 2 | mbedTLS | `4.0.0` | Matches libtropic's hello_world example for the PSA crypto CAL | +| Tier 3 (wolfcrypt) | libtropic | `v0.1.0` | wolfSSL's port (`wolfcrypt/src/port/tropicsquare/tropic01.c`) calls `lt_random_get`, `verify_chip_and_start_secure_session`, `CURVE_ED25519`, and 4-arg `lt_r_mem_data_read` -- all renamed in libtropic v1.0.0. Pinning to v0.1.0 keeps the upstream port unchanged. | +| Tier 3 | wolfSSL | `master` | Tracks the latest port; the build sed-fixes a `ForceZero` -> `wc_ForceZero` typo in `tropic01.c`. | +| Tier 3 | tropic01-wolfssl-test | `main` | Tropic Square's upstream test app; the build sed-swaps its USB-dongle HAL for libtropic v0.1.0's `lt_port_unix_tcp.c`. | + +## Not implemented + +- **Application-FW commands beyond the wolfSSL surface.** Config-object read/write (R_CONFIG, I_CONFIG), MAC-and-Destroy, MCounter, monotonic counters, certificate-store mutation, and FW update commands are all stubbed -- they return `INVALID_CMD` (`0x02`) at the L3 layer. +- **Maintenance / startup mode.** The simulator always reports `LT_TR01_APPLICATION` mode. `lt_reboot(MAINTENANCE)` is acknowledged but does not change the chip's behaviour. +- **Alarm states.** The `TR01_L1_CHIP_MODE_ALARM_bit` is never set -- the chip stays in the "ready, application mode" state for the entire test run. +- **Real X.509 chain validation.** The device certificate in the cert store is a minimal DER blob carrying STPUB at the X25519 SPKI offset libtropic's parser looks for. It is *not* signed by a Tropic Square root and would fail real attestation. wolfSSL's smoke tests do not validate the cert chain. + +## License + +GPL-3.0-or-later. See `LICENSE`. diff --git a/TROPIC01Sim/sdk-test/CMakeLists.txt b/TROPIC01Sim/sdk-test/CMakeLists.txt new file mode 100644 index 0000000..21de7e9 --- /dev/null +++ b/TROPIC01Sim/sdk-test/CMakeLists.txt @@ -0,0 +1,56 @@ +# CMakeLists.txt +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# Builds `tropic01_sim_test` as a libtropic example: links the libtropic +# core library + the mbedTLS v4 CAL + the posix/tcp HAL. Mirrors +# libtropic/examples/model/hello_world/CMakeLists.txt so libtropic's +# CMake-driven mbedTLS v4 fetch + CAL/HAL wiring is reused unchanged. + +cmake_minimum_required(VERSION 3.21.0) +include(FetchContent) + +project(tropic01_sim_test + DESCRIPTION "TROPIC01 simulator integration test driver." + LANGUAGES C) + +set(PATH_LIBTROPIC ../../../) + +if(NOT UNIX) + message(FATAL_ERROR "Model is currently compatible with UNIX-like systems only.") +endif() + +# Add libtropic core. +add_subdirectory(${PATH_LIBTROPIC} "libtropic") +target_compile_options(tropic PRIVATE -ffunction-sections -fdata-sections) + +# MbedTLS v4.0.0 (matches the version libtropic's hello_world example pulls). +set(ENABLE_TESTING OFF CACHE BOOL "Disable mbedtls_v4 test building.") +set(ENABLE_PROGRAMS OFF CACHE BOOL "Disable mbedtls_v4 examples building.") +FetchContent_Declare( + mbedtls_v4 + URL https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-4.0.0/mbedtls-4.0.0.tar.bz2 + URL_HASH SHA256=2f3a47f7b3a541ddef450e4867eeecb7ce2ef7776093f3a11d6d43ead6bf2827 +) +FetchContent_MakeAvailable(mbedtls_v4) +target_link_libraries(tropic PUBLIC mbedtls) + +# MbedTLS v4 crypto abstraction layer. +add_subdirectory("${PATH_LIBTROPIC}/cal/mbedtls_v4" "mbedtls_v4_cal") +target_sources(tropic PRIVATE ${LT_CAL_SRCS}) +target_include_directories(tropic PUBLIC ${LT_CAL_INC_DIRS}) + +# POSIX TCP HAL -- talks to our simulator's TCP server. +add_subdirectory("${PATH_LIBTROPIC}/hal/posix/tcp" "posix_tcp_hal") +target_sources(tropic PRIVATE ${LT_HAL_SRCS}) +target_include_directories(tropic PUBLIC ${LT_HAL_INC_DIRS}) + +add_executable(${CMAKE_PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/test_tropic01.c) +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE tropic) diff --git a/TROPIC01Sim/sdk-test/run_test.sh b/TROPIC01Sim/sdk-test/run_test.sh new file mode 100644 index 0000000..b321781 --- /dev/null +++ b/TROPIC01Sim/sdk-test/run_test.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# run_test.sh +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# bash (not /bin/sh) is required for the /dev/tcp readiness probe. + +set -eu + +SIM_BIN="${SIM_BIN:-/app/tcp_server}" +TEST_BIN="${TEST_BIN:-/app/libtropic/examples/model/sim_test/build/tropic01_sim_test}" +SIM_PORT="${TROPIC01_SIM_PORT:-28992}" +SIM_HOST="${TROPIC01_SIM_HOST:-127.0.0.1}" + +export TROPIC01_SIM_BIND="${TROPIC01_SIM_BIND:-127.0.0.1}" +export TROPIC01_SIM_PORT="${SIM_PORT}" +export TROPIC01_SIM_HOST="${SIM_HOST}" +export TROPIC01_SIM_FRESH=1 + +cleanup() { + if [ -n "${SIM_PID:-}" ] && kill -0 "${SIM_PID}" 2>/dev/null; then + kill "${SIM_PID}" 2>/dev/null || true + wait "${SIM_PID}" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +"${SIM_BIN}" & +SIM_PID=$! + +# Wait up to 5s for the listener to come up. +SIM_READY=0 +for i in $(seq 1 50); do + if (echo > /dev/tcp/"${SIM_HOST}"/"${SIM_PORT}") 2>/dev/null; then + SIM_READY=1 + break + fi + sleep 0.1 +done +if [ "${SIM_READY}" -ne 1 ]; then + echo "ERROR: tropic01 simulator did not start listening on ${SIM_HOST}:${SIM_PORT} within 5s" >&2 + exit 1 +fi + +"${TEST_BIN}" +RC=$? +exit $RC diff --git a/TROPIC01Sim/sdk-test/test_tropic01.c b/TROPIC01Sim/sdk-test/test_tropic01.c new file mode 100644 index 0000000..d50d333 --- /dev/null +++ b/TROPIC01Sim/sdk-test/test_tropic01.c @@ -0,0 +1,208 @@ +/* test_tropic01.c + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/* + * TROPIC01 simulator integration smoke test. + * + * Drives libtropic's high-level API (the same one wolfSSL's TROPIC01 + * crypto callback hits) against the simulator over the posix/tcp HAL. + * This validates the full stack end-to-end: TCP framing, SPI byte + * exchange, L2 frame parse/CRC, Noise_KK1 handshake, AES-GCM L3 tunnel, + * and every L3 command the simulator implements. + * + * Each test prints PASS / FAIL and the program exits non-zero on the + * first failure so the run-test.sh wrapper surfaces it to CI. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "libtropic.h" +#include "libtropic_common.h" +#include "libtropic_mbedtls_v4.h" +#include "libtropic_port_posix_tcp.h" +#include "psa/crypto.h" + +#define PASS_OR_DIE(expr, label) \ + do { \ + lt_ret_t _ret = (expr); \ + if (_ret != LT_OK) { \ + fprintf(stderr, "FAIL %s: %s (ret=%d)\n", (label), lt_ret_verbose(_ret), (int)_ret); \ + return -1; \ + } \ + fprintf(stdout, "PASS %s\n", (label)); \ + } while (0) + +#define DEFAULT_HOST "127.0.0.1" +#define DEFAULT_PORT 28992 + +static int connect_handle(lt_handle_t *h, lt_dev_posix_tcp_t *dev, + lt_ctx_mbedtls_v4_t *crypto_ctx) { + const char *host = getenv("TROPIC01_SIM_HOST"); + if (!host) host = DEFAULT_HOST; + const char *port_s = getenv("TROPIC01_SIM_PORT"); + int port = port_s ? atoi(port_s) : DEFAULT_PORT; + + memset(dev, 0, sizeof(*dev)); + dev->addr = inet_addr(host); + dev->port = (in_port_t)port; + h->l2.device = dev; + h->l3.crypto_ctx = crypto_ctx; + return 0; +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + fprintf(stdout, "=== TROPIC01 simulator integration test ===\n"); + + if (psa_crypto_init() != PSA_SUCCESS) { + fprintf(stderr, "FAIL psa_crypto_init\n"); + return -1; + } + + /* PRNG seed for libtropic's host-side random_bytes (model HAL uses rand()). */ + unsigned int seed; + if (getentropy(&seed, sizeof(seed)) != 0) { + fprintf(stderr, "FAIL getentropy: %s\n", strerror(errno)); + return -1; + } + srand(seed); + + lt_handle_t lth = {0}; + lt_dev_posix_tcp_t dev; + lt_ctx_mbedtls_v4_t crypto_ctx; + if (connect_handle(<h, &dev, &crypto_ctx) != 0) return -1; + + PASS_OR_DIE(lt_init(<h), "lt_init"); + PASS_OR_DIE(lt_reboot(<h, TR01_REBOOT), "lt_reboot"); + + /* The simulator pre-provisions slot 0 with the engineering-sample + * pairing key, so libtropic's exported sh0priv_eng_sample / + * sh0pub_eng_sample bytes authenticate cleanly. */ + PASS_OR_DIE(lt_verify_chip_and_start_secure_session( + <h, sh0priv_eng_sample, sh0pub_eng_sample, + TR01_PAIRING_KEY_SLOT_INDEX_0), + "lt_verify_chip_and_start_secure_session"); + + /* PING with a small payload. */ + { + const uint8_t msg[] = "hello tropic sim"; + uint8_t reply[sizeof(msg)] = {0}; + PASS_OR_DIE(lt_ping(<h, msg, reply, (uint16_t)sizeof(msg)), "lt_ping"); + if (memcmp(msg, reply, sizeof(msg)) != 0) { + fprintf(stderr, "FAIL lt_ping: payload mismatch\n"); + return -1; + } + fprintf(stdout, "PASS lt_ping payload round-trip\n"); + } + + /* TRNG: pull 32 bytes, sanity-check non-zero. */ + { + uint8_t random_buf[32] = {0}; + PASS_OR_DIE(lt_random_value_get(<h, random_buf, sizeof(random_buf)), + "lt_random_value_get(32)"); + bool any = false; + for (size_t i = 0; i < sizeof(random_buf); i++) { + if (random_buf[i] != 0) { + any = true; + break; + } + } + if (!any) { + fprintf(stderr, "FAIL lt_random_get: all zero\n"); + return -1; + } + fprintf(stdout, "PASS lt_random_get non-zero\n"); + } + + /* ECC keygen + read for both curves the simulator supports. */ + { + uint8_t pub[64] = {0}; + lt_ecc_curve_type_t curve; + lt_ecc_key_origin_t origin; + + PASS_OR_DIE(lt_ecc_key_erase(<h, TR01_ECC_SLOT_1), "ecc_erase pre-clean (P256)"); + PASS_OR_DIE(lt_ecc_key_generate(<h, TR01_ECC_SLOT_1, TR01_CURVE_P256), + "ecc_generate P256 slot1"); + PASS_OR_DIE(lt_ecc_key_read(<h, TR01_ECC_SLOT_1, pub, sizeof(pub), &curve, &origin), + "ecc_read P256 slot1"); + if (curve != TR01_CURVE_P256) { + fprintf(stderr, "FAIL ecc_read: curve mismatch (%d)\n", (int)curve); + return -1; + } + fprintf(stdout, "PASS ECC P-256 keygen+read curve=%d origin=%d\n", (int)curve, (int)origin); + + memset(pub, 0, sizeof(pub)); + PASS_OR_DIE(lt_ecc_key_erase(<h, TR01_ECC_SLOT_2), "ecc_erase pre-clean (Ed25519)"); + PASS_OR_DIE(lt_ecc_key_generate(<h, TR01_ECC_SLOT_2, TR01_CURVE_ED25519), + "ecc_generate Ed25519 slot2"); + PASS_OR_DIE(lt_ecc_key_read(<h, TR01_ECC_SLOT_2, pub, 32, &curve, &origin), + "ecc_read Ed25519 slot2"); + if (curve != TR01_CURVE_ED25519) { + fprintf(stderr, "FAIL ecc_read: curve mismatch Ed25519 (%d)\n", (int)curve); + return -1; + } + fprintf(stdout, "PASS ECC Ed25519 keygen+read\n"); + } + + /* R-memory write + read round-trip into a free slot. */ + { + const uint8_t payload[] = "TROPIC01 simulator R_MEM round-trip payload"; + const uint16_t slot = 7; + uint8_t out[sizeof(payload)] = {0}; + uint16_t out_len = sizeof(out); + PASS_OR_DIE(lt_r_mem_data_write(<h, slot, payload, (uint16_t)sizeof(payload)), + "r_mem_data_write slot 7"); + PASS_OR_DIE(lt_r_mem_data_read(<h, slot, out, (uint16_t)sizeof(out), &out_len), + "r_mem_data_read slot 7"); + if (out_len != sizeof(payload) || memcmp(out, payload, sizeof(payload)) != 0) { + fprintf(stderr, "FAIL r_mem round-trip: out_len=%u\n", (unsigned)out_len); + return -1; + } + fprintf(stdout, "PASS R_MEM data round-trip (%u bytes)\n", (unsigned)out_len); + } + + /* Pairing-key read of slot 0 should match the host engineering key. */ + { + uint8_t shipub_read[32] = {0}; + PASS_OR_DIE(lt_pairing_key_read(<h, shipub_read, TR01_PAIRING_KEY_SLOT_INDEX_0), + "pairing_key_read slot 0"); + if (memcmp(shipub_read, sh0pub_eng_sample, sizeof(shipub_read)) != 0) { + fprintf(stderr, "FAIL pairing_key_read: SHIPUB mismatch\n"); + return -1; + } + fprintf(stdout, "PASS pairing_key_read returns engineering SHIPUB\n"); + } + + PASS_OR_DIE(lt_session_abort(<h), "lt_session_abort"); + PASS_OR_DIE(lt_deinit(<h), "lt_deinit"); + mbedtls_psa_crypto_free(); + + fprintf(stdout, "\nALL TESTS PASSED\n"); + return 0; +} diff --git a/TROPIC01Sim/tropic01-sim/Cargo.toml b/TROPIC01Sim/tropic01-sim/Cargo.toml new file mode 100644 index 0000000..f62abdc --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "tropic01-sim" +version = "0.1.0" +edition = "2021" +description = "Software simulator for the Tropic Square TROPIC01 secure element (libtropic-compatible TCP model)" +license = "GPL-3.0-or-later" + +[dependencies] +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } +p256 = { version = "0.13", features = ["ecdsa", "arithmetic", "pem"] } +aes-gcm = "0.10" +sha2 = "0.10" +hmac = "0.12" +rand = "0.8" +rand_core = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "tcp_server" +path = "src/bin/tcp_server.rs" diff --git a/TROPIC01Sim/tropic01-sim/src/bin/tcp_server.rs b/TROPIC01Sim/tropic01-sim/src/bin/tcp_server.rs new file mode 100644 index 0000000..0a1241c --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/bin/tcp_server.rs @@ -0,0 +1,175 @@ +/* tcp_server.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// TROPIC01 simulator TCP server. Speaks the "TROPIC01 Model" wire +/// protocol that libtropic's `hal/posix/tcp/` HAL talks to: +/// +/// [tag (1B)] [len (2B little-endian)] [payload (len B)] +/// +/// Tags (`lt_posix_tcp_tag_t`): +/// 0x01 SPI_DRIVE_CSN_LOW - empty payload, ack with same tag +/// 0x02 SPI_DRIVE_CSN_HIGH - empty payload, ack with same tag +/// 0x03 SPI_SEND - payload is MOSI bytes; reply payload is MISO +/// 0x04 POWER_ON - empty payload +/// 0x05 POWER_OFF - empty payload +/// 0x06 WAIT - 4B little-endian ms; ack +/// 0x10 RESET_TARGET - empty payload; ack +/// 0xFD INVALID - server didn't recognise the tag +/// 0xFE UNSUPPORTED - tag known but not implemented +/// +/// Per-connection state: an `SpiEmulator` (CSN + SPI byte cursor) plus a +/// Noise_KK1 `Session`. The persistent `Store` is shared across all +/// connections via `Arc>` -- that mirrors real silicon, where +/// the chip has a single object store. +use std::env; +use std::io::{self, BufReader, BufWriter, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +use tropic01_sim::dispatch::Dispatcher; +use tropic01_sim::object_store::Store; +use tropic01_sim::session::Session; +use tropic01_sim::spi::{SpiEmulator, SpiOutcome}; +use tropic01_sim::tcp_proto::{TcpFrame, TcpTag}; + +const DEFAULT_BIND: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 28992; +const DEFAULT_STORE_PATH: &str = "tropic01_store.json"; + +fn main() -> io::Result<()> { + let bind_addr = env::var("TROPIC01_SIM_BIND").unwrap_or_else(|_| DEFAULT_BIND.to_string()); + let port: u16 = env::var("TROPIC01_SIM_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_PORT); + let store_path = env::var("TROPIC01_SIM_STORE") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_STORE_PATH)); + + let store = if env::var_os("TROPIC01_SIM_FRESH").is_some() { + Store::fresh() + } else { + Store::load_or_init(&store_path)? + }; + let store = Arc::new(Mutex::new(store)); + + let listener = TcpListener::bind((bind_addr.as_str(), port))?; + eprintln!("[tropic01-sim] listening on {bind_addr}:{port}"); + + for conn in listener.incoming() { + let stream = conn?; + let store = Arc::clone(&store); + thread::spawn(move || { + if let Err(e) = handle_connection(stream, store) { + eprintln!("[tropic01-sim] connection error: {e}"); + } + }); + } + Ok(()) +} + +fn handle_connection(stream: TcpStream, store: Arc>) -> io::Result<()> { + let peer = stream.peer_addr().ok(); + eprintln!("[tropic01-sim] connection from {peer:?}"); + stream.set_nodelay(true).ok(); + + // Buffer in/out independently so the borrow checker doesn't fight us. + let stream_for_writer = stream.try_clone()?; + let mut reader = BufReader::new(stream); + let mut writer = BufWriter::new(stream_for_writer); + + let mut spi = SpiEmulator::new(); + let mut session = Session::new(); + + while let Some(frame) = TcpFrame::read_from(&mut reader)? { + let reply = handle_tcp_frame(&store, &mut spi, &mut session, frame)?; + reply.write_to(&mut writer)?; + writer.flush()?; + } + + eprintln!("[tropic01-sim] connection closed by {peer:?}"); + Ok(()) +} + +fn handle_tcp_frame( + store: &Arc>, + spi: &mut SpiEmulator, + session: &mut Session, + frame: TcpFrame, +) -> io::Result { + let tag = TcpTag::from_u8(frame.tag); + match tag { + TcpTag::SpiDriveCsnLow => { + spi.csn_low(); + Ok(TcpFrame::new(TcpTag::SpiDriveCsnLow, vec![])) + } + TcpTag::SpiDriveCsnHigh => { + spi.csn_high(); + Ok(TcpFrame::new(TcpTag::SpiDriveCsnHigh, vec![])) + } + TcpTag::SpiSend => { + let (miso, outcome) = spi.spi_transfer(&frame.payload); + if matches!(outcome, SpiOutcome::RequestComplete) { + let raw = spi.take_request(); + let mut store_lock = store.lock().unwrap(); + let response = Dispatcher::dispatch(&mut store_lock, session, &raw); + store_lock.persist().map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("failed to persist store: {e}"), + ) + })?; + drop(store_lock); + spi.stage_response(Some(response)); + } + Ok(TcpFrame::new(TcpTag::SpiSend, miso)) + } + TcpTag::PowerOn | TcpTag::PowerOff => { + // Cold boot from libtropic's perspective. Reset volatile state + // -- session keys included -- but leave the persistent store. + spi.csn_high(); + session.abort(); + Ok(TcpFrame::new(tag, vec![])) + } + TcpTag::Wait => { + // Host requested a real-time delay (the model server sleeps); + // we don't actually sleep -- the simulator is instant. + Ok(TcpFrame::new(TcpTag::Wait, vec![])) + } + TcpTag::ResetTarget => { + spi.csn_high(); + spi.stage_response(None); + session.abort(); + Ok(TcpFrame::new(TcpTag::ResetTarget, vec![])) + } + TcpTag::Invalid | TcpTag::Unsupported => { + // Host sent us one of its own error sentinels back -- treat + // as a protocol error and close the connection by surfacing + // io::ErrorKind::InvalidData. + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected client tag {:#04x}", frame.tag), + )) + } + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/crc.rs b/TROPIC01Sim/tropic01-sim/src/crc.rs new file mode 100644 index 0000000..a32972f --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/crc.rs @@ -0,0 +1,72 @@ +/* crc.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// TROPIC01 CRC-16 used by libtropic's `lt_crc16.c`: +/// poly = 0x8005, init = 0x0000, refin = false, refout = false (the +/// final byte-swap is just so the result is serialized big-endian), +/// xorout = 0x0000. +/// +/// Important: this is NOT CRC-16/X-25. The reflection lives only in the +/// final return value (`crc << 8 | crc >> 8`) so the wire bytes match the +/// big-endian CRC the chip emits and the host expects. +pub fn crc16(buf: &[u8]) -> u16 { + let mut crc: u16 = 0x0000; + for &b in buf { + crc ^= (b as u16) << 8; + for _ in 0..8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x8005; + } else { + crc <<= 1; + } + } + } + (crc << 8) | (crc >> 8) +} + +/// Append the 2-byte CRC to `frame` (matches the `add_crc` helper in +/// `lt_crc16.c` — CRC is computed over `[REQ_ID][REQ_LEN][REQ_DATA]` and +/// emitted as `[crc_hi][crc_lo]`). +pub fn append_crc(frame: &mut Vec) { + let crc = crc16(frame); + frame.push((crc >> 8) as u8); + frame.push((crc & 0xFF) as u8); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input() { + // crc16 over empty input == initial value (0x0000), byte-swapped == 0x0000. + assert_eq!(crc16(&[]), 0x0000); + } + + #[test] + fn append_round_trip() { + let mut buf = vec![0x01, 0x02, 0xAA, 0xBB]; + let expected = crc16(&buf); + append_crc(&mut buf); + let crc_on_wire = u16::from_be_bytes([buf[buf.len() - 2], buf[buf.len() - 1]]); + assert_eq!(crc_on_wire, expected); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/dispatch.rs b/TROPIC01Sim/tropic01-sim/src/dispatch.rs new file mode 100644 index 0000000..972a284 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/dispatch.rs @@ -0,0 +1,158 @@ +/* dispatch.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// L2 request router. Parses an L2 frame, looks at REQ_ID, and either +/// hands the body to a per-REQ handler (GET_INFO, ENCRYPTED_CMD, etc.) or +/// returns a status-only response. Handshake (M2) and Encrypted_Cmd (M3) +/// reach into `Session`; everything else just touches the `Store`. +use crate::frame::{build_response, parse_request, status, FrameError}; +use crate::handlers; +use crate::object_store::Store; +use crate::session::Session; + +/// L2 REQ_IDs from `lt_l2_api_structs.h`. Only the ones we route on need +/// listing; others fall through to `UNKNOWN_ERR`. +pub mod req { + pub const GET_INFO: u8 = 0x01; + pub const HANDSHAKE: u8 = 0x02; + pub const ENCRYPTED_CMD: u8 = 0x04; + pub const ENCRYPTED_SESSION_ABT: u8 = 0x08; + pub const RESEND: u8 = 0x10; + pub const SLEEP: u8 = 0x20; + pub const STARTUP: u8 = 0xB3; +} + +pub struct Dispatcher; + +impl Dispatcher { + /// Parse `raw`, route, and return the L2 response bytes + /// (`[STATUS][RSP_LEN][DATA][CRC]`). The SPI emulator wraps these with + /// the leading CHIP_STATUS byte during the polled-read transaction. + pub fn dispatch(store: &mut Store, session: &mut Session, raw: &[u8]) -> Vec { + let req = match parse_request(raw) { + Ok(r) => r, + // Frame-level errors are reported via L2 STATUS bytes -- the + // chip never closes the link (host SDK retries on these). + Err(FrameError::BadCrc) => return build_response(status::CRC_ERR, &[]), + Err(FrameError::TooShort) + | Err(FrameError::LenMismatch) + | Err(FrameError::Overflow) => return build_response(status::GEN_ERR, &[]), + }; + + match req.req_id { + req::GET_INFO => handlers::get_info::handle(&store.device, req.data), + req::HANDSHAKE => match session.handshake(&store.device, req.data) { + Ok(rsp_body) => build_response(status::REQUEST_OK, &rsp_body), + Err(_) => { + session.abort(); + build_response(status::HSK_ERR, &[]) + } + }, + req::ENCRYPTED_CMD => { + if !session.is_open() { + return build_response(status::NO_SESSION, &[]); + } + let plaintext = match session.unwrap_l3_request(req.data) { + Ok(p) => p, + Err(_) => { + // AES-GCM open failed or framing was wrong -- + // tear down the session, mirroring real silicon. + session.abort(); + return build_response(status::TAG_ERR, &[]); + } + }; + let response_plaintext = handlers::l3::dispatch(&mut store.device, &plaintext); + match session.wrap_l3_response(&response_plaintext) { + Ok(wire) => build_response(status::RESULT_OK, &wire), + Err(_) => { + session.abort(); + build_response(status::GEN_ERR, &[]) + } + } + } + req::ENCRYPTED_SESSION_ABT => { + session.abort(); + build_response(status::REQUEST_OK, &[]) + } + req::RESEND => { + // Real silicon would replay the prior response. We just + // ack -- nothing in libtropic's main flow exercises this. + build_response(status::REQUEST_OK, &[]) + } + req::SLEEP => { + session.abort(); + build_response(status::REQUEST_OK, &[]) + } + req::STARTUP => { + session.abort(); + build_response(status::REQUEST_OK, &[]) + } + _ => build_response(status::UNKNOWN_ERR, &[]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::build_request; + + #[test] + fn unknown_req_id_returns_unknown_err() { + let mut store = Store::fresh(); + let mut session = Session::new(); + let raw = build_request(0x77, &[]); + let resp = Dispatcher::dispatch(&mut store, &mut session, &raw); + assert_eq!(resp[0], status::UNKNOWN_ERR); + } + + #[test] + fn bad_crc_returns_crc_err() { + let mut store = Store::fresh(); + let mut session = Session::new(); + let mut raw = build_request(req::GET_INFO, &[0x01, 0]); + let last = raw.len() - 1; + raw[last] ^= 0xFF; + let resp = Dispatcher::dispatch(&mut store, &mut session, &raw); + assert_eq!(resp[0], status::CRC_ERR); + } + + #[test] + fn get_info_chip_id_round_trip() { + let mut store = Store::fresh(); + let mut session = Session::new(); + let raw = build_request(req::GET_INFO, &[0x01, 0]); + let resp = Dispatcher::dispatch(&mut store, &mut session, &raw); + assert_eq!(resp[0], status::REQUEST_OK); + // [STATUS][RSP_LEN=128][CHIP_ID(12) + zeros][CRC] + assert_eq!(resp[1], 128); + assert_eq!(&resp[2..2 + 12], &store.device.chip_id); + } + + #[test] + fn encrypted_cmd_without_session_is_no_session() { + let mut store = Store::fresh(); + let mut session = Session::new(); + let raw = build_request(req::ENCRYPTED_CMD, &[0; 16]); + let resp = Dispatcher::dispatch(&mut store, &mut session, &raw); + assert_eq!(resp[0], status::NO_SESSION); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/frame.rs b/TROPIC01Sim/tropic01-sim/src/frame.rs new file mode 100644 index 0000000..c33556a --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/frame.rs @@ -0,0 +1,210 @@ +/* frame.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// TROPIC01 L2 frame layout matches the structs in +/// `libtropic/src/lt_l2_api_structs.h` and the polling protocol in +/// `libtropic/src/lt_l1.c`: +/// +/// Request (host -> chip): [REQ_ID(1B)] [REQ_LEN(1B)] [REQ_DATA(REQ_LEN)] [CRC(2B BE)] +/// Response (chip -> host): [STATUS(1B)] [RSP_LEN(1B)] [RSP_DATA(RSP_LEN)] [CRC(2B BE)] +/// +/// The response is preceded on the SPI wire by a CHIP_STATUS byte that +/// `lt_l1.c` reads in its first 1-byte transfer. CHIP_STATUS is not part +/// of the L2 frame proper -- it's the chip's "ready / startup / alarm" +/// signalling -- so this module deals only with `[STATUS][RSP_LEN][DATA][CRC]`. +/// `spi.rs` prepends the CHIP_STATUS byte during the polled-read flow. +/// +/// CRC is the libtropic `crc16` variant (poly 0x8005, init 0x0000, no +/// reflection apart from the final big-endian byte swap), computed over +/// `[REQ_ID][REQ_LEN][REQ_DATA]` for requests and `[STATUS][RSP_LEN][DATA]` +/// for responses. +use crate::crc::{crc16, append_crc}; + +/// `TR01_L1_GET_RESPONSE_REQ_ID` in libtropic's `lt_l1.h`. Reserved as the +/// "poll the chip for a pending response" magic; can never appear as a +/// real REQ_ID on a write. +pub const GET_RESPONSE_REQ_ID: u8 = 0xAA; + +/// `TR01_L2_CHUNK_MAX_DATA_SIZE` from `libtropic_common.h` - max payload +/// the chip will produce in a single L2 response chunk. +pub const MAX_L2_DATA_SIZE: usize = 252; + +/// Max bytes for a complete L2 frame: REQ_ID + REQ_LEN + DATA + CRC. +pub const MAX_L2_FRAME_SIZE: usize = 1 + 1 + MAX_L2_DATA_SIZE + 2; + +/// L2 STATUS byte values, matching `TR01_L2_STATUS_*` in +/// `libtropic/src/lt_l2_frame_check.h`. The chip puts one of these in the +/// second byte of the polled-read response (after the CHIP_STATUS byte). +pub mod status { + /// `TR01_L2_STATUS_REQUEST_OK = 0x01` - chip accepted a plain L2 request + /// (e.g. GET_INFO, HANDSHAKE) and the reply payload follows. + pub const REQUEST_OK: u8 = 0x01; + /// `TR01_L2_STATUS_RESULT_OK = 0x02` - chip executed an Encrypted_Cmd + /// L3 command and the encrypted reply follows. + pub const RESULT_OK: u8 = 0x02; + /// `TR01_L2_STATUS_REQUEST_CONT = 0x03` - more chunks expected in this + /// request. + pub const REQUEST_CONT: u8 = 0x03; + /// `TR01_L2_STATUS_RESULT_CONT = 0x04` - more chunks of response to come. + pub const RESULT_CONT: u8 = 0x04; + /// `TR01_L2_STATUS_RESP_DISABLED = 0x78` - the request's REQ_ID is + /// disabled in the current chip mode (e.g. APP-mode-only command in + /// startup mode). + pub const RESP_DISABLED: u8 = 0x78; + /// `TR01_L2_STATUS_HSK_ERR = 0x79` - handshake failed. + pub const HSK_ERR: u8 = 0x79; + /// `TR01_L2_STATUS_NO_SESSION = 0x7A` - Encrypted_Cmd issued without an + /// open Secure Channel. + pub const NO_SESSION: u8 = 0x7A; + /// `TR01_L2_STATUS_TAG_ERR = 0x7B` - AES-GCM tag failed. + pub const TAG_ERR: u8 = 0x7B; + /// `TR01_L2_STATUS_CRC_ERR = 0x7C` - chip computed a different CRC over + /// the inbound request frame. + pub const CRC_ERR: u8 = 0x7C; + /// `TR01_L2_STATUS_UNKNOWN_ERR = 0x7E` - REQ_ID not recognised. + pub const UNKNOWN_ERR: u8 = 0x7E; + /// `TR01_L2_STATUS_GEN_ERR = 0x7F` - unspecified failure. + pub const GEN_ERR: u8 = 0x7F; + /// `TR01_L2_STATUS_NO_RESP = 0xFF` - chip has nothing to give yet, host + /// should re-poll. We never embed this in a built response; the SPI + /// emulator surfaces it directly during the polling loop instead. + pub const NO_RESP: u8 = 0xFF; +} + +#[derive(Debug, PartialEq, Eq)] +pub enum FrameError { + TooShort, + LenMismatch, + BadCrc, + Overflow, +} + +/// A parsed L2 request frame. +#[derive(Debug, PartialEq, Eq)] +pub struct Request<'a> { + pub req_id: u8, + pub data: &'a [u8], +} + +/// Parse an L2 request frame from the host. `buf` is the entire frame +/// including REQ_ID, REQ_LEN, REQ_DATA, CRC. +pub fn parse_request(buf: &[u8]) -> Result, FrameError> { + if buf.len() < 4 { + // REQ_ID + REQ_LEN + 0 data + CRC(2) + return Err(FrameError::TooShort); + } + if buf.len() > MAX_L2_FRAME_SIZE { + return Err(FrameError::Overflow); + } + let req_id = buf[0]; + let req_len = buf[1] as usize; + if buf.len() != 1 + 1 + req_len + 2 { + return Err(FrameError::LenMismatch); + } + let crc_offset = 2 + req_len; + let received_crc = u16::from_be_bytes([buf[crc_offset], buf[crc_offset + 1]]); + let computed = crc16(&buf[..crc_offset]); + if computed != received_crc { + return Err(FrameError::BadCrc); + } + Ok(Request { + req_id, + data: &buf[2..crc_offset], + }) +} + +/// Build an L2 response frame `[STATUS][RSP_LEN][DATA][CRC]`. CHIP_STATUS +/// is added separately by the SPI emulator when serving the polled-read +/// transaction. +pub fn build_response(status: u8, data: &[u8]) -> Vec { + assert!( + data.len() <= MAX_L2_DATA_SIZE, + "L2 response data exceeds chunk size" + ); + let mut out = Vec::with_capacity(2 + data.len() + 2); + out.push(status); + out.push(data.len() as u8); + out.extend_from_slice(data); + append_crc(&mut out); + out +} + +/// Convenience wrapper used by tests + by the SPI emulator's "format your +/// own L2 request" path: builds `[REQ_ID][REQ_LEN][DATA][CRC]`. +pub fn build_request(req_id: u8, data: &[u8]) -> Vec { + assert!( + data.len() <= MAX_L2_DATA_SIZE, + "L2 request data exceeds chunk size" + ); + let mut out = Vec::with_capacity(2 + data.len() + 2); + out.push(req_id); + out.push(data.len() as u8); + out.extend_from_slice(data); + append_crc(&mut out); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_simple() { + let body = [0x11, 0x22, 0x33]; + let frame = build_request(0x01, &body); + let req = parse_request(&frame).unwrap(); + assert_eq!(req.req_id, 0x01); + assert_eq!(req.data, &body); + } + + #[test] + fn rejects_bad_crc() { + let mut frame = build_request(0x01, &[0x00, 0x00]); + let last = frame.len() - 1; + frame[last] ^= 0xFF; + assert_eq!(parse_request(&frame), Err(FrameError::BadCrc)); + } + + #[test] + fn rejects_truncated() { + let frame = [0x01, 0x05, 0x00, 0x00]; + assert_eq!(parse_request(&frame), Err(FrameError::LenMismatch)); + } + + #[test] + fn response_layout() { + let resp = build_response(status::REQUEST_OK, &[0xDE, 0xAD]); + assert_eq!(resp.len(), 1 + 1 + 2 + 2); + assert_eq!(resp[0], status::REQUEST_OK); + assert_eq!(resp[1], 2); + assert_eq!(&resp[2..4], &[0xDE, 0xAD]); + // CRC is over [STATUS][RSP_LEN][DATA]. + let expected = crc16(&resp[..4]); + assert_eq!(u16::from_be_bytes([resp[4], resp[5]]), expected); + } + + #[test] + fn empty_response() { + let resp = build_response(status::REQUEST_OK, &[]); + assert_eq!(resp.len(), 4); + assert_eq!(resp[1], 0); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/handlers/get_info.rs b/TROPIC01Sim/tropic01-sim/src/handlers/get_info.rs new file mode 100644 index 0000000..a94e04e --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/handlers/get_info.rs @@ -0,0 +1,143 @@ +/* get_info.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// L2 GET_INFO (REQ_ID = 0x01). Wire layout from +/// `lt_l2_api_structs.h::lt_l2_get_info_req_t`: +/// +/// [object_id (1B)] [block_index (1B)] +/// +/// `object_id` selects which artefact to return: +/// 0x00 X509_CERTIFICATE -- chip cert (block_index addresses 128B chunks) +/// 0x01 CHIP_ID +/// 0x02 RISCV_FW_VERSION +/// 0x04 SPECT_FW_VERSION +/// 0xB0 FW_BANK (start-up mode only; not modelled) +/// +/// All return data lands in `[STATUS=REQUEST_OK][RSP_LEN][object][CRC]`. +use crate::frame::{build_response, status}; +use crate::object_store::Device; + +const OBJECT_ID_X509_CERT: u8 = 0x00; +const OBJECT_ID_CHIP_ID: u8 = 0x01; +const OBJECT_ID_RISCV_FW: u8 = 0x02; +const OBJECT_ID_SPECT_FW: u8 = 0x04; + +/// Each GET_INFO chunk is at most 128B (`TR01_L2_CHUNK_MAX_DATA_SIZE` is +/// 252B for the wire frame, but the cert chunking convention used by the +/// host is 128B per block_index). +const CHUNK_SIZE: usize = 128; + +/// Fake firmware version emitted as RISCV_FW_VERSION / SPECT_FW_VERSION. +/// Top bit clear means "APP mode" (per the doc comment in lt_l2_api_structs.h). +const FAKE_FW_VERSION: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; + +pub fn handle(device: &Device, body: &[u8]) -> Vec { + if body.len() != 2 { + return build_response(status::GEN_ERR, &[]); + } + let object_id = body[0]; + let block_index = body[1] as usize; + + match object_id { + OBJECT_ID_X509_CERT => respond_chunked(&device.cert_store, block_index), + OBJECT_ID_CHIP_ID => { + // Pad chip_id to a fixed 128B size; the host receives whatever + // RSP_LEN says, so emitting just the 12-byte ID is also valid. + let mut out = vec![0u8; 128]; + out[..device.chip_id.len()].copy_from_slice(&device.chip_id); + build_response(status::REQUEST_OK, &out) + } + OBJECT_ID_RISCV_FW | OBJECT_ID_SPECT_FW => { + build_response(status::REQUEST_OK, &FAKE_FW_VERSION) + } + _ => build_response(status::UNKNOWN_ERR, &[]), + } +} + +fn respond_chunked(blob: &[u8], block_index: usize) -> Vec { + // libtropic's `lt_get_info_cert_store` requires every chunk to be + // exactly 128 bytes (it errors out on any other rsp_len). We always + // emit a full 128B chunk; if the block is past the end of the blob + // we just emit zeros for that range. The host's loop terminates + // based on the cert lengths in the header, not the chunk contents. + let mut chunk = vec![0u8; CHUNK_SIZE]; + let start = block_index.saturating_mul(CHUNK_SIZE); + if start < blob.len() { + let end = (start + CHUNK_SIZE).min(blob.len()); + chunk[..end - start].copy_from_slice(&blob[start..end]); + } + build_response(status::REQUEST_OK, &chunk) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::status; + use crate::object_store::Store; + + #[test] + fn chip_id_round_trip() { + let store = Store::fresh(); + let resp = handle(&store.device, &[OBJECT_ID_CHIP_ID, 0]); + // [STATUS][RSP_LEN][DATA(128)][CRC(2)] + assert_eq!(resp[0], status::REQUEST_OK); + assert_eq!(resp[1], 128); + assert_eq!(&resp[2..2 + 12], &store.device.chip_id); + } + + #[test] + fn cert_chunk_zero_returns_full_128() { + let store = Store::fresh(); + let resp = handle(&store.device, &[OBJECT_ID_X509_CERT, 0]); + assert_eq!(resp[0], status::REQUEST_OK); + // Always exactly 128 bytes, regardless of where the cert ends. + assert_eq!(resp[1] as usize, CHUNK_SIZE); + // First chunk starts with the cert-store header (version=1, num_certs=4). + assert_eq!(resp[2], 1); + assert_eq!(resp[3], 4); + } + + #[test] + fn cert_chunk_past_end_returns_zeros() { + let store = Store::fresh(); + let resp = handle(&store.device, &[OBJECT_ID_X509_CERT, 99]); + assert_eq!(resp[0], status::REQUEST_OK); + assert_eq!(resp[1] as usize, CHUNK_SIZE); + // All zeros past the end -- the host's loop would have stopped + // already based on the cert lengths header. + assert!(resp[2..2 + CHUNK_SIZE].iter().all(|&b| b == 0)); + } + + #[test] + fn fw_version_returns_4_bytes() { + let store = Store::fresh(); + let resp = handle(&store.device, &[OBJECT_ID_RISCV_FW, 0]); + assert_eq!(resp[0], status::REQUEST_OK); + assert_eq!(resp[1], 4); + } + + #[test] + fn unknown_object_returns_unknown_err() { + let store = Store::fresh(); + let resp = handle(&store.device, &[0xFE, 0]); + assert_eq!(resp[0], status::UNKNOWN_ERR); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/handlers/l3.rs b/TROPIC01Sim/tropic01-sim/src/handlers/l3.rs new file mode 100644 index 0000000..fad006e --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/handlers/l3.rs @@ -0,0 +1,513 @@ +/* l3.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// L3 plaintext command dispatcher. Each L3 command is `[cmd_id (1B)] + +/// fields...` after AES-GCM open; the response is `[result (1B)] + +/// fields...` before AES-GCM seal. cmd_id values are from +/// `lt_l3_api_structs.h`. +/// +/// L3 result codes (`lt_l3_process.h`): +/// OK = 0xC3 +/// FAIL = 0x3C +/// UNAUTHORIZED = 0x01 +/// INVALID_CMD = 0x02 +use ed25519_dalek::SigningKey as Ed25519Signing; +use p256::{ + elliptic_curve::sec1::ToEncodedPoint, EncodedPoint as P256EncodedPoint, SecretKey as P256Secret, +}; +use rand::rngs::OsRng; +use rand_core::RngCore; + +use crate::object_store::{ + types::{CurveKind, EccSlot, KeyOrigin, PairingSlot, RMemSlot}, + Device, +}; + +pub mod result { + pub const OK: u8 = 0xC3; + pub const FAIL: u8 = 0x3C; + pub const UNAUTHORIZED: u8 = 0x01; + pub const INVALID_CMD: u8 = 0x02; +} + +pub mod cmd_id { + pub const PING: u8 = 0x01; + pub const PAIRING_KEY_WRITE: u8 = 0x10; + pub const PAIRING_KEY_READ: u8 = 0x11; + pub const PAIRING_KEY_INVALIDATE: u8 = 0x12; + pub const R_MEM_DATA_WRITE: u8 = 0x40; + pub const R_MEM_DATA_READ: u8 = 0x41; + pub const RANDOM_VALUE_GET: u8 = 0x50; + pub const ECC_KEY_GENERATE: u8 = 0x60; + pub const ECC_KEY_STORE: u8 = 0x61; + pub const ECC_KEY_READ: u8 = 0x62; + pub const ECC_KEY_ERASE: u8 = 0x63; +} + +const ED25519_PUBKEY_LEN: usize = 32; +const P256_PUBKEY_LEN: usize = 64; + +/// Maximum R_MEM slot payload that can round-trip through the L2/L3 stack. +/// An R_MEM_DATA_READ response is wrapped as +/// `[cmd_size(2)] [ [result(1)][padding(3)][data] ] [tag(16)]` = 22 + data_len +/// bytes, and that buffer becomes the L2 data field whose RSP_LEN is u8 and +/// whose hard cap is `MAX_L2_DATA_SIZE = 252`. So data_len is bounded by +/// 252 - 22 = 230. +const MAX_R_MEM_DATA_SIZE: usize = 230; + +pub fn dispatch(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.is_empty() { + return single_byte(result::INVALID_CMD); + } + match plaintext[0] { + cmd_id::PING => ping(plaintext), + cmd_id::PAIRING_KEY_WRITE => pairing_key_write(device, plaintext), + cmd_id::PAIRING_KEY_READ => pairing_key_read(device, plaintext), + cmd_id::PAIRING_KEY_INVALIDATE => pairing_key_invalidate(device, plaintext), + cmd_id::R_MEM_DATA_WRITE => r_mem_data_write(device, plaintext), + cmd_id::R_MEM_DATA_READ => r_mem_data_read(device, plaintext), + cmd_id::RANDOM_VALUE_GET => random_value_get(plaintext), + cmd_id::ECC_KEY_GENERATE => ecc_key_generate(device, plaintext), + cmd_id::ECC_KEY_STORE => ecc_key_store(device, plaintext), + cmd_id::ECC_KEY_READ => ecc_key_read(device, plaintext), + cmd_id::ECC_KEY_ERASE => ecc_key_erase(device, plaintext), + _ => single_byte(result::INVALID_CMD), + } +} + +fn single_byte(code: u8) -> Vec { + vec![code] +} + +/// PING: `[0x01][data_in...]` -> `[0xC3][data_in...]`. +fn ping(plaintext: &[u8]) -> Vec { + let mut out = Vec::with_capacity(plaintext.len()); + out.push(result::OK); + out.extend_from_slice(&plaintext[1..]); + out +} + +/// PAIRING_KEY_WRITE: `[0x10][slot u16 LE][padding 1B][s_hipub 32B]` +/// (cmd_size = 36) -> `[result 1B]`. +fn pairing_key_write(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 36 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + if slot > 3 { + return single_byte(result::FAIL); + } + let mut pubkey = [0u8; 32]; + pubkey.copy_from_slice(&plaintext[4..36]); + device.pairing_slots.insert( + slot as u8, + PairingSlot { + public_key: pubkey, + is_valid: true, + }, + ); + single_byte(result::OK) +} + +/// PAIRING_KEY_READ: `[0x11][slot u16 LE]` -> `[result 1B][padding 3B][s_hipub 32B]` (res_size=36). +fn pairing_key_read(device: &Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 3 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let Some(entry) = device.pairing_slots.get(&(slot as u8)) else { + return single_byte(result::FAIL); + }; + if !entry.is_valid { + return single_byte(result::UNAUTHORIZED); + } + let mut out = Vec::with_capacity(36); + out.push(result::OK); + out.extend_from_slice(&[0u8; 3]); // padding + out.extend_from_slice(&entry.public_key); + out +} + +/// PAIRING_KEY_INVALIDATE: `[0x12][slot u16 LE]` -> `[result 1B]`. +fn pairing_key_invalidate(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 3 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + match device.pairing_slots.get_mut(&(slot as u8)) { + Some(entry) => { + entry.is_valid = false; + single_byte(result::OK) + } + None => single_byte(result::FAIL), + } +} + +/// R_MEM_DATA_WRITE: `[0x40][udata_slot u16 LE][padding 1B][data...]` +/// (cmd_size_min = 5) -> `[result 1B]`. +fn r_mem_data_write(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() < 5 { + return single_byte(result::FAIL); + } + let data_len = plaintext.len() - 4; + if data_len > MAX_R_MEM_DATA_SIZE { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let data = plaintext[4..].to_vec(); + device.r_mem_slots.insert(slot, RMemSlot { data }); + single_byte(result::OK) +} + +/// R_MEM_DATA_READ: `[0x41][udata_slot u16 LE]` -> `[result 1B][padding 3B][data...]` +/// (res_size = 4 + data_len). +fn r_mem_data_read(device: &Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 3 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let Some(entry) = device.r_mem_slots.get(&slot) else { + return single_byte(result::FAIL); + }; + if entry.data.len() > MAX_R_MEM_DATA_SIZE { + // Stale persisted slot is too large to encode in a single L2 frame. + // Better to FAIL cleanly than panic in build_response further up. + return single_byte(result::FAIL); + } + let mut out = Vec::with_capacity(4 + entry.data.len()); + out.push(result::OK); + out.extend_from_slice(&[0u8; 3]); // padding + out.extend_from_slice(&entry.data); + out +} + +/// RANDOM_VALUE_GET: `[0x50][n_bytes 1B]` -> `[result 1B][padding 3B][random n_bytes]` +/// (res_size = 4 + n; max n = 255). +fn random_value_get(plaintext: &[u8]) -> Vec { + if plaintext.len() != 2 { + return single_byte(result::FAIL); + } + let n = plaintext[1] as usize; + if n > 255 { + return single_byte(result::FAIL); + } + let mut bytes = vec![0u8; n]; + OsRng.fill_bytes(&mut bytes); + let mut out = Vec::with_capacity(4 + n); + out.push(result::OK); + out.extend_from_slice(&[0u8; 3]); // padding + out.extend_from_slice(&bytes); + out +} + +/// ECC_KEY_GENERATE: `[0x60][slot u16 LE][curve 1B]` (cmd_size = 4) -> `[result 1B]`. +fn ecc_key_generate(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 4 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let Some(curve) = CurveKind::from_wire_id(plaintext[3]) else { + return single_byte(result::FAIL); + }; + let key = generate_private_key(curve); + device.ecc_slots.insert( + slot, + EccSlot { + curve, + origin: KeyOrigin::Generated, + private_key: key, + }, + ); + single_byte(result::OK) +} + +/// ECC_KEY_STORE: `[0x61][slot u16 LE][curve 1B][padding 12B][k 32B]` +/// (cmd_size = 48) -> `[result 1B]`. +fn ecc_key_store(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 48 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let Some(curve) = CurveKind::from_wire_id(plaintext[3]) else { + return single_byte(result::FAIL); + }; + let key = plaintext[16..48].to_vec(); + device.ecc_slots.insert( + slot, + EccSlot { + curve, + origin: KeyOrigin::Stored, + private_key: key, + }, + ); + single_byte(result::OK) +} + +/// ECC_KEY_READ: `[0x62][slot u16 LE]` -> `[result 1B][curve 1B][origin 1B][padding 13B][pub_key]` +/// where pub_key is 32B for Ed25519 (res_size = 48) or 64B for P-256 (res_size = 80). +fn ecc_key_read(device: &Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 3 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + let Some(entry) = device.ecc_slots.get(&slot) else { + return single_byte(result::FAIL); + }; + let pubkey = match derive_public_key(entry) { + Some(p) => p, + None => return single_byte(result::FAIL), + }; + let mut out = Vec::with_capacity(16 + pubkey.len()); + out.push(result::OK); + out.push(entry.curve.wire_id()); + out.push(entry.origin.wire_id()); + out.extend_from_slice(&[0u8; 13]); // padding + out.extend_from_slice(&pubkey); + out +} + +/// ECC_KEY_ERASE: `[0x63][slot u16 LE]` -> `[result 1B]`. +fn ecc_key_erase(device: &mut Device, plaintext: &[u8]) -> Vec { + if plaintext.len() != 3 { + return single_byte(result::FAIL); + } + let slot = u16::from_le_bytes([plaintext[1], plaintext[2]]); + device.ecc_slots.remove(&slot); + single_byte(result::OK) +} + +fn generate_private_key(curve: CurveKind) -> Vec { + match curve { + CurveKind::Ed25519 => { + let mut k = [0u8; 32]; + OsRng.fill_bytes(&mut k); + // Ed25519 private keys are arbitrary 32 bytes (the seed); no clamping needed at storage. + k.to_vec() + } + CurveKind::P256 => { + let secret = P256Secret::random(&mut OsRng); + secret.to_bytes().to_vec() + } + } +} + +fn derive_public_key(slot: &EccSlot) -> Option> { + match slot.curve { + CurveKind::Ed25519 => { + let bytes: [u8; 32] = slot.private_key.as_slice().try_into().ok()?; + let signing = Ed25519Signing::from_bytes(&bytes); + Some(signing.verifying_key().to_bytes().to_vec()) + } + CurveKind::P256 => { + let secret = P256Secret::from_slice(&slot.private_key).ok()?; + let pt: P256EncodedPoint = secret.public_key().to_encoded_point(false); + // pt is `[0x04 | X(32) | Y(32)]` (uncompressed). Strip the + // leading 0x04 so the on-wire pub_key is the raw 64-byte + // X||Y form `lt_in__ecc_key_read` checks against + // `TR01_CURVE_P256_PUBKEY_LEN = 64`. + let bytes = pt.as_bytes(); + if bytes.len() != 65 || bytes[0] != 0x04 { + return None; + } + Some(bytes[1..].to_vec()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::object_store::Store; + + #[test] + fn ping_echoes_payload() { + let payload = [cmd_id::PING, 1, 2, 3, 4]; + let mut store = Store::fresh(); + let resp = dispatch(&mut store.device, &payload); + assert_eq!(resp, vec![result::OK, 1, 2, 3, 4]); + } + + #[test] + fn random_returns_n_bytes() { + let mut store = Store::fresh(); + let resp = dispatch(&mut store.device, &[cmd_id::RANDOM_VALUE_GET, 16]); + assert_eq!(resp.len(), 4 + 16); + assert_eq!(resp[0], result::OK); + } + + #[test] + fn r_mem_read_returns_fixture() { + let mut store = Store::fresh(); + let resp = dispatch( + &mut store.device, + &[cmd_id::R_MEM_DATA_READ, 0x00, 0x00], + ); + assert_eq!(resp[0], result::OK); + assert_eq!(resp.len(), 4 + 32); // padding(3) + 32 bytes of AES key fixture + } + + #[test] + fn r_mem_write_then_read() { + let mut store = Store::fresh(); + let mut write = vec![cmd_id::R_MEM_DATA_WRITE, 0x05, 0x00, 0x00]; + write.extend_from_slice(b"hello world!"); + let resp = dispatch(&mut store.device, &write); + assert_eq!(resp, vec![result::OK]); + let read = dispatch( + &mut store.device, + &[cmd_id::R_MEM_DATA_READ, 0x05, 0x00], + ); + assert_eq!(&read[4..], b"hello world!"); + } + + #[test] + fn ecc_keygen_then_read_ed25519() { + let mut store = Store::fresh(); + let gen = dispatch( + &mut store.device, + &[ + cmd_id::ECC_KEY_GENERATE, + 0x01, + 0x00, + CurveKind::Ed25519.wire_id(), + ], + ); + assert_eq!(gen, vec![result::OK]); + let read = dispatch( + &mut store.device, + &[cmd_id::ECC_KEY_READ, 0x01, 0x00], + ); + // [result(1)][curve(1)][origin(1)][padding(13)][pub(32)] + assert_eq!(read.len(), 48); + assert_eq!(read[0], result::OK); + assert_eq!(read[1], CurveKind::Ed25519.wire_id()); + assert_eq!(read[2], KeyOrigin::Generated.wire_id()); + } + + #[test] + fn ecc_keygen_then_read_p256() { + let mut store = Store::fresh(); + let gen = dispatch( + &mut store.device, + &[ + cmd_id::ECC_KEY_GENERATE, + 0x02, + 0x00, + CurveKind::P256.wire_id(), + ], + ); + assert_eq!(gen, vec![result::OK]); + let read = dispatch( + &mut store.device, + &[cmd_id::ECC_KEY_READ, 0x02, 0x00], + ); + // [result(1)][curve(1)][origin(1)][padding(13)][pub(64)] + assert_eq!(read.len(), 80); + assert_eq!(read[0], result::OK); + assert_eq!(read[1], CurveKind::P256.wire_id()); + } + + #[test] + fn ecc_erase_clears_slot() { + let mut store = Store::fresh(); + dispatch( + &mut store.device, + &[ + cmd_id::ECC_KEY_GENERATE, + 0x03, + 0x00, + CurveKind::Ed25519.wire_id(), + ], + ); + let erase = dispatch( + &mut store.device, + &[cmd_id::ECC_KEY_ERASE, 0x03, 0x00], + ); + assert_eq!(erase, vec![result::OK]); + let read = dispatch( + &mut store.device, + &[cmd_id::ECC_KEY_READ, 0x03, 0x00], + ); + assert_eq!(read, vec![result::FAIL]); + } + + #[test] + fn pairing_read_returns_fixture() { + let mut store = Store::fresh(); + let resp = dispatch( + &mut store.device, + &[cmd_id::PAIRING_KEY_READ, 0x00, 0x00], + ); + assert_eq!(resp.len(), 36); + assert_eq!(resp[0], result::OK); + assert_eq!( + &resp[4..36], + &crate::object_store::default_host_pairing_pub() + ); + } + + #[test] + fn pairing_invalidate_blocks_subsequent_read() { + let mut store = Store::fresh(); + let inv = dispatch( + &mut store.device, + &[cmd_id::PAIRING_KEY_INVALIDATE, 0x00, 0x00], + ); + assert_eq!(inv, vec![result::OK]); + let read = dispatch( + &mut store.device, + &[cmd_id::PAIRING_KEY_READ, 0x00, 0x00], + ); + assert_eq!(read[0], result::UNAUTHORIZED); + } + + #[test] + fn unknown_cmd_returns_invalid_cmd() { + let mut store = Store::fresh(); + let resp = dispatch(&mut store.device, &[0xFE]); + assert_eq!(resp, vec![result::INVALID_CMD]); + } + + #[test] + fn r_mem_write_rejects_oversized_payload() { + let mut store = Store::fresh(); + let mut write = vec![cmd_id::R_MEM_DATA_WRITE, 0x06, 0x00, 0x00]; + write.extend(std::iter::repeat(0xAB).take(MAX_R_MEM_DATA_SIZE + 1)); + let resp = dispatch(&mut store.device, &write); + assert_eq!(resp, vec![result::FAIL]); + } + + #[test] + fn r_mem_read_fails_when_slot_too_large() { + let mut store = Store::fresh(); + store.device.r_mem_slots.insert( + 0x07, + RMemSlot { + data: vec![0xCD; MAX_R_MEM_DATA_SIZE + 1], + }, + ); + let resp = dispatch( + &mut store.device, + &[cmd_id::R_MEM_DATA_READ, 0x07, 0x00], + ); + assert_eq!(resp, vec![result::FAIL]); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/handlers/mod.rs b/TROPIC01Sim/tropic01-sim/src/handlers/mod.rs new file mode 100644 index 0000000..e305245 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/handlers/mod.rs @@ -0,0 +1,23 @@ +/* mod.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +pub mod get_info; +pub mod l3; diff --git a/TROPIC01Sim/tropic01-sim/src/lib.rs b/TROPIC01Sim/tropic01-sim/src/lib.rs new file mode 100644 index 0000000..6a78b4d --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/lib.rs @@ -0,0 +1,36 @@ +/* lib.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +pub mod crc; +pub mod dispatch; +pub mod frame; +pub mod handlers; +pub mod object_store; +pub mod session; +pub mod spi; +pub mod tcp_proto; + +pub use dispatch::Dispatcher; +pub use frame::{build_response, parse_request, FrameError}; +pub use object_store::Store; +pub use session::Session; +pub use spi::SpiEmulator; +pub use tcp_proto::{TcpFrame, TcpTag}; diff --git a/TROPIC01Sim/tropic01-sim/src/object_store/mod.rs b/TROPIC01Sim/tropic01-sim/src/object_store/mod.rs new file mode 100644 index 0000000..1663c24 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/object_store/mod.rs @@ -0,0 +1,354 @@ +/* mod.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +pub mod types; + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use rand::rngs::OsRng; +use rand_core::RngCore; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Static}; + +pub use types::{CurveKind, Device, EccSlot, KeyOrigin, PairingSlot, RMemSlot}; + +/// Default pairing-key slot used by the wolfSSL port (`PAIRING_KEY_SLOT_INDEX_0`). +pub const DEFAULT_PAIRING_SLOT: u8 = 0; + +/// In-memory store with optional JSON-file persistence. Mirrors the +/// `Store` shape used by the other simulators in this repo. +pub struct Store { + pub device: Device, + path: Option, +} + +impl Store { + /// Load from `path` if it exists, otherwise create a freshly provisioned + /// store and persist it back to `path`. + pub fn load_or_init(path: &Path) -> io::Result { + if path.exists() { + let bytes = fs::read(path)?; + let device: Device = serde_json::from_slice(&bytes) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + return Ok(Self { + device, + path: Some(path.to_path_buf()), + }); + } + let store = Self { + device: fresh_device(), + path: Some(path.to_path_buf()), + }; + store.persist()?; + Ok(store) + } + + /// Build a freshly provisioned store with no on-disk persistence. + pub fn fresh() -> Self { + Self { + device: fresh_device(), + path: None, + } + } + + pub fn persist(&self) -> io::Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let bytes = serde_json::to_vec_pretty(&self.device) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(path, bytes) + } +} + +/// Build a freshly provisioned TROPIC01 simulator device: +/// - Random 12-byte chip ID. +/// - Random X25519 static keypair (STPRIV/STPUB). +/// - A minimal X.509-shaped certificate carrying STPUB at the X25519 +/// subject-public-key offset libtropic's ASN.1 helper looks for. +/// - Pairing slot 0 holds the host pairing pubkey from `default_host_pairing_pub()`. +/// The matching host private key is `default_host_pairing_priv()`. These +/// are the dev fixtures the wolfSSL test app feeds via `Tropic01_SetPairingKeys`. +/// - R-memory slots 0..=3 pre-loaded with the AES/Ed25519 fixtures the +/// wolfSSL crypto callback expects (see tropic01.h:57-76). +/// - No ECC slots populated; wolfSSL's keygen test populates ECC slot 1 +/// on-the-fly. +fn fresh_device() -> Device { + let mut chip_id = [0u8; 12]; + OsRng.fill_bytes(&mut chip_id); + + let st_priv_bytes = { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + b + }; + let st_secret = X25519Static::from(st_priv_bytes); + let st_pub_bytes = X25519Public::from(&st_secret).to_bytes(); + + let cert_store = build_minimal_cert_store(&st_pub_bytes); + + let mut pairing_slots = BTreeMap::new(); + pairing_slots.insert( + DEFAULT_PAIRING_SLOT, + PairingSlot { + public_key: default_host_pairing_pub(), + is_valid: true, + }, + ); + + let mut r_mem_slots = BTreeMap::new(); + r_mem_slots.insert(0, RMemSlot { data: default_aes_key().to_vec() }); + r_mem_slots.insert(1, RMemSlot { data: default_aes_iv().to_vec() }); + r_mem_slots.insert(2, RMemSlot { data: default_ed25519_pub().to_vec() }); + r_mem_slots.insert(3, RMemSlot { data: default_ed25519_priv().to_vec() }); + + Device { + chip_id, + st_priv: st_priv_bytes, + st_pub: st_pub_bytes, + cert_store, + pairing_slots, + ecc_slots: BTreeMap::new(), + r_mem_slots, + } +} + +/// Sizes of each cert in the cert-store fixture, in order +/// `[device, xxxx, tropic01, root]`. Each entry must be > 128 bytes so +/// `lt_get_info_cert_store`'s "at most one trailing chunk" assumption +/// holds. The device cert is 256B (so the X25519 SPKI fits comfortably +/// with room for an outer SEQUENCE wrapper); the others are 128B-aligned +/// padding because nothing inspects them. +const CERT_LENS: [usize; 4] = [256, 128, 128, 128]; + +/// Header length: version (1) + num_certs (1) + 4 BE u16 cert lengths (8) = 10 B. +const CERT_STORE_HEADER_LEN: usize = 10; + +/// Total cert-store blob length (header + concatenated certs). Padded up +/// to the next 128B boundary so chunked reads always return a full +/// 128-byte chunk -- libtropic's helper checks `rsp_len == 128` per +/// chunk and would error out on a short final chunk. +pub fn cert_store_blob_len() -> usize { + let total = CERT_STORE_HEADER_LEN + CERT_LENS.iter().sum::(); + total.div_ceil(128) * 128 +} + +/// Build the 4-cert cert-store blob libtropic reads via +/// `GET_INFO(X509_CERTIFICATE)`. Layout (matches the parser in +/// `libtropic/src/libtropic.c::lt_get_info_cert_store`): +/// +/// [0] version (1) +/// [1] num_certs (4) +/// [2..10] 4 BE u16 cert lengths +/// [10..] +/// +/// +/// Cert 0 is a 256-byte DER blob containing an X25519 SPKI: +/// 30 82 00 FC SEQUENCE (long-form, len 252) +/// 30 05 06 03 2B 65 6E AlgorithmIdentifier (X25519 OID) +/// 03 21 00 <32 STPUB bytes> BIT STRING (33 B: unused-bits=0 + key) +/// +/// +/// libtropic's recursive ASN.1 parser walks the outer SEQUENCE, finds +/// the X25519 OID (1.3.101.110), captures the next BIT STRING, and +/// crops the leading "unused-bits" byte to recover the 32-byte STPUB. +/// Padding is consumed silently: trailing 0x00 bytes parse as +/// zero-length tags that the parser drops without error. +fn build_minimal_cert_store(st_pub: &[u8; 32]) -> Vec { + let mut out = vec![0u8; cert_store_blob_len()]; + out[0] = 1; // version + out[1] = 4; // num_certs + for (i, &len) in CERT_LENS.iter().enumerate() { + let lo = 2 + i * 2; + out[lo..lo + 2].copy_from_slice(&(len as u16).to_be_bytes()); + } + + // Place cert 0 starting at byte 10. + let mut p = CERT_STORE_HEADER_LEN; + let device_cert = build_device_cert(st_pub, CERT_LENS[0]); + out[p..p + device_cert.len()].copy_from_slice(&device_cert); + p += CERT_LENS[0]; + + // Certs 1..3: 128 bytes each. They're never inspected by libtropic + // beyond a memcpy into the host buffer, so any DER-shaped padding + // works. We emit a SEQUENCE wrapper of length 125 with zero padding + // inside so an offline DER inspector doesn't get confused. + for &len in &CERT_LENS[1..] { + let inner_len = len - 3; + out[p] = 0x30; + out[p + 1] = 0x81; + out[p + 2] = inner_len as u8; + // bytes p+3..p+len already zero + p += len; + } + + out +} + +fn build_device_cert(st_pub: &[u8; 32], total_len: usize) -> Vec { + // Outer SEQUENCE header: 30 82 -- 4 bytes. + // Inner length = total_len - 4. + assert!(total_len >= 4 + 44 && total_len <= 0xFFFF + 4); + let inner_len = (total_len - 4) as u16; + let mut out = vec![0u8; total_len]; + out[0] = 0x30; + out[1] = 0x82; + out[2..4].copy_from_slice(&inner_len.to_be_bytes()); + + // Then the SPKI right after the header. + let spki = [ + 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x6E, // AlgorithmIdentifier (X25519) + 0x03, 0x21, 0x00, // BIT STRING tag, length 33, unused-bits=0 + ]; + let spki_start = 4; + out[spki_start..spki_start + spki.len()].copy_from_slice(&spki); + out[spki_start + spki.len()..spki_start + spki.len() + 32].copy_from_slice(st_pub); + // Remainder is zero padding, which the ASN.1 parser absorbs as + // sequences of (tag=0x00, length=0). + out +} + +/// `sh0priv_eng_sample` from `libtropic/src/libtropic_default_sh0_keys.c`. +/// libtropic exports this constant as the host pairing private key for +/// engineering (pre-production) TROPIC01 samples in slot 0; using the same +/// bytes here means the wolfSSL test app and any libtropic client can +/// authenticate against the simulator with no extra setup. +pub const DEFAULT_HOST_PAIRING_PRIV: [u8; 32] = [ + 0xd0, 0x99, 0x92, 0xb1, 0xf1, 0x7a, 0xbc, 0x4d, 0xb9, 0x37, 0x17, 0x68, 0xa2, 0x7d, 0xa0, 0x5b, + 0x18, 0xfa, 0xb8, 0x56, 0x13, 0xa7, 0x84, 0x2c, 0xa6, 0x4c, 0x79, 0x10, 0xf2, 0x2e, 0x71, 0x6b, +]; + +/// `sh0pub_eng_sample` from `libtropic/src/libtropic_default_sh0_keys.c`. +/// Matches the X25519 public key derived from `DEFAULT_HOST_PAIRING_PRIV`. +pub const DEFAULT_HOST_PAIRING_PUB: [u8; 32] = [ + 0xe7, 0xf7, 0x35, 0xba, 0x19, 0xa3, 0x3f, 0xd6, 0x73, 0x23, 0xab, 0x37, 0x26, 0x2d, 0xe5, 0x36, + 0x08, 0xca, 0x57, 0x85, 0x76, 0x53, 0x43, 0x52, 0xe1, 0x8f, 0x64, 0xe6, 0x13, 0xd3, 0x8d, 0x54, +]; + +pub fn default_host_pairing_priv() -> [u8; 32] { + DEFAULT_HOST_PAIRING_PRIV +} + +pub fn default_host_pairing_pub() -> [u8; 32] { + DEFAULT_HOST_PAIRING_PUB +} + +pub fn default_aes_key() -> [u8; 32] { + let mut k = [0u8; 32]; + for (i, b) in k.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(0x11); + } + k +} + +pub fn default_aes_iv() -> [u8; 32] { + let mut iv = [0u8; 32]; + for (i, b) in iv.iter_mut().enumerate() { + *b = i as u8; + } + iv +} + +pub fn default_ed25519_priv() -> [u8; 32] { + let mut s = [0u8; 32]; + for (i, b) in s.iter_mut().enumerate() { + *b = 0x40 | (i as u8 & 0x3F); + } + s +} + +pub fn default_ed25519_pub() -> [u8; 32] { + use ed25519_dalek::SigningKey; + SigningKey::from_bytes(&default_ed25519_priv()) + .verifying_key() + .to_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn fresh_store_has_chip_identity() { + let store = Store::fresh(); + assert_ne!(store.device.st_pub, [0u8; 32]); + // [version=1][num_certs=4][4x BE u16 cert lengths starting at byte 2] + assert_eq!(store.device.cert_store[0], 1); + assert_eq!(store.device.cert_store[1], 4); + // Device cert begins at byte 10 with `30 82 ` + // and STPUB lands 14 bytes in (header + SPKI prefix). + assert_eq!(store.device.cert_store[10], 0x30); + let stpub_offset = 10 + 4 + 10; // header + outer hdr + SPKI prefix + assert_eq!( + &store.device.cert_store[stpub_offset..stpub_offset + 32], + &store.device.st_pub + ); + } + + #[test] + fn cert_store_blob_is_chunk_aligned() { + let store = Store::fresh(); + assert_eq!(store.device.cert_store.len() % 128, 0); + assert_eq!(store.device.cert_store.len(), 768); // 6 chunks of 128 + } + + #[test] + fn fresh_store_has_default_pairing_slot() { + let store = Store::fresh(); + let slot = store.device.pairing_slots.get(&0).unwrap(); + assert!(slot.is_valid); + assert_eq!(slot.public_key, default_host_pairing_pub()); + } + + #[test] + fn engineering_sample_keys_are_consistent_pair() { + // Verify the sh0priv/sh0pub bytes from libtropic actually form a + // valid X25519 pair under x25519-dalek's clamping. + let secret = X25519Static::from(DEFAULT_HOST_PAIRING_PRIV); + let derived = X25519Public::from(&secret).to_bytes(); + assert_eq!(derived, DEFAULT_HOST_PAIRING_PUB); + } + + #[test] + fn fresh_store_has_r_mem_fixtures() { + let store = Store::fresh(); + assert_eq!(store.device.r_mem_slots.get(&0).unwrap().data.len(), 32); + assert_eq!(store.device.r_mem_slots.get(&3).unwrap().data.len(), 32); + assert_eq!( + store.device.r_mem_slots.get(&2).unwrap().data, + default_ed25519_pub().to_vec() + ); + } + + #[test] + fn load_or_init_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tropic_store.json"); + let store_a = Store::load_or_init(&path).unwrap(); + let chip_id = store_a.device.chip_id; + drop(store_a); + let store_b = Store::load_or_init(&path).unwrap(); + assert_eq!(store_b.device.chip_id, chip_id); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/object_store/types.rs b/TROPIC01Sim/tropic01-sim/src/object_store/types.rs new file mode 100644 index 0000000..785a1a9 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/object_store/types.rs @@ -0,0 +1,124 @@ +/* types.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Curves the TROPIC01 ECC engine supports. P-256 and Ed25519 are the only +/// two listed in `lt_l3_api_structs.h::lt_l3_ecc_key_generate_cmd_t` +/// (CURVE values 1 and 2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CurveKind { + P256, + Ed25519, +} + +impl CurveKind { + pub fn wire_id(self) -> u8 { + match self { + CurveKind::P256 => 1, + CurveKind::Ed25519 => 2, + } + } + + pub fn from_wire_id(v: u8) -> Option { + match v { + 1 => Some(CurveKind::P256), + 2 => Some(CurveKind::Ed25519), + _ => None, + } + } +} + +/// `ECC_Key_Read.origin` field. The chip distinguishes keys it generated +/// internally from those uploaded with `ECC_Key_Store`. wolfSSL's port +/// reads this back via `lt_ecc_key_read` but treats both equivalently. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum KeyOrigin { + Generated, + Stored, +} + +impl KeyOrigin { + pub fn wire_id(self) -> u8 { + match self { + KeyOrigin::Generated => 1, + KeyOrigin::Stored => 2, + } + } +} + +/// One ECC key slot. Private bytes are stored unmodified; the public key +/// is derived on demand by the handler so we don't have to re-derive it +/// across crate version bumps. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EccSlot { + pub curve: CurveKind, + pub origin: KeyOrigin, + /// Raw private scalar bytes. 32B for both P-256 and Ed25519. + pub private_key: Vec, +} + +/// One R-memory slot. The chip allows arbitrary host-defined bytes up to +/// 444B per slot (`TR01_L3_R_MEM_DATA_*`); wolfSSL only ever stores 32B +/// values (AES key, IV, Ed25519 priv, Ed25519 pub). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RMemSlot { + pub data: Vec, +} + +/// One pairing-key slot. Holds the host's static X25519 public key the +/// chip will accept for handshakes targeting this slot. `is_valid` mirrors +/// the chip's per-slot "invalidated" bit (set by Pairing_Key_Invalidate). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PairingSlot { + pub public_key: [u8; 32], + pub is_valid: bool, +} + +/// Persistent on-disk device state. All fields together define the +/// chip's identity, pairing trust anchors, and persistent key/data store. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Device { + /// 12-byte chip ID (silicon revision + unique device ID), returned by + /// GET_INFO(object_id=CHIP_ID). + pub chip_id: [u8; 12], + /// Chip's static X25519 keypair (STPRIV/STPUB). The Noise_KK1 handshake + /// uses STPUB. STPRIV never leaves the chip in real silicon. + pub st_priv: [u8; 32], + pub st_pub: [u8; 32], + /// 4-cert "cert store" blob returned by GET_INFO(X509_CERTIFICATE). + /// libtropic reads this in 128-byte chunks and parses the leading + /// 10-byte header (`[version=1][num_certs=4][len_cert0..len_cert3 as + /// 4 BE u16]`) followed by `num_certs` concatenated DER blobs. Cert 0 + /// is the device cert and must contain the X25519 SPKI carrying + /// STPUB at a libtropic-recognisable ASN.1 offset; certs 1..3 are + /// padding so the chunked reader walks all four boundaries cleanly. + pub cert_store: Vec, + /// Pairing-key slots 0..=3 (`TR01_L3_PAIRING_KEY_SLOT_*`). + pub pairing_slots: BTreeMap, + /// ECC key slots. Slot indices follow libtropic's per-application + /// convention; wolfSSL's port uses slot 1 for Ed25519. + pub ecc_slots: BTreeMap, + /// R-memory slots (host-defined arbitrary data). + pub r_mem_slots: BTreeMap, +} diff --git a/TROPIC01Sim/tropic01-sim/src/session.rs b/TROPIC01Sim/tropic01-sim/src/session.rs new file mode 100644 index 0000000..a6b9b31 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/session.rs @@ -0,0 +1,402 @@ +/* session.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// Noise_KK1_25519_AESGCM_SHA256 handshake + AES-GCM tunnel for the +/// TROPIC01 simulator. +/// +/// Mirrors the host-side implementation in +/// `libtropic/src/libtropic_l3.c::lt_in__session_start` exactly, because +/// any deviation in transcript hashing or HKDF chaining produces a +/// different `kAUTH` and the host's tag verification fails. +/// +/// Transcript chain (each step replaces `h`): +/// 1. h = SHA256(protocol_name) protocol_name = b"Noise_KK1_25519_AESGCM_SHA256\0\0\0" (32B) +/// 2. h = SHA256(h || SHIPUB) host static pubkey (32B) +/// 3. h = SHA256(h || STPUB) chip static pubkey (32B) +/// 4. h = SHA256(h || EHPUB) host ephemeral pubkey (32B) +/// 5. h = SHA256(h || pkey_index) 1 byte +/// 6. h = SHA256(h || ETPUB) chip ephemeral pubkey (32B) +/// +/// Key derivation (libtropic's custom HKDF, see `lt_hkdf.c`): +/// ck = protocol_name (32B) +/// ck = HKDF(ck, X25519(EHPRIV, ETPUB), take output_1 only) +/// ck = HKDF(ck33, X25519(SHIPRIV, ETPUB), take output_1 only) +/// ck, kAUTH = HKDF(ck33, X25519(EHPRIV, STPUB), take both) +/// kCMD, kRES = HKDF(ck33, "", take both) +/// +/// where each "ck" stored as 33 bytes (32B HMAC output + trailing 0) so +/// the next call's salt is 33 bytes wide -- matches the libtropic buffer +/// shape literally (`output_1[33]`). +/// +/// Auth tag: AES-GCM-Encrypt(key=kAUTH, iv=zeros[12], aad=h, plaintext="") +/// produces the 16-byte tag returned in the HANDSHAKE_RSP. +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use hmac::{Hmac, Mac}; +use rand::rngs::OsRng; +use rand_core::RngCore; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Static}; + +use crate::object_store::Device; + +type HmacSha256 = Hmac; + +const PROTOCOL_NAME: [u8; 32] = *b"Noise_KK1_25519_AESGCM_SHA256\0\0\0"; +pub const HANDSHAKE_REQ_LEN: usize = 33; // EHPUB(32) + pkey_index(1) +pub const HANDSHAKE_RSP_LEN: usize = 48; // ETPUB(32) + tag(16) + +/// AES-GCM keys + nonce counters for an open Secure Channel. +pub struct SessionKeys { + pub k_cmd: [u8; 32], + pub k_res: [u8; 32], + pub nonce_cmd: [u8; 12], + pub nonce_res: [u8; 12], +} + +/// Per-connection volatile state. +#[derive(Default)] +pub struct Session { + pub keys: Option, +} + +/// Errors during HANDSHAKE_REQ processing. The L2 dispatcher maps these +/// to `status::HSK_ERR` -- the host then sees `LT_L2_HSK_ERR` and bails. +#[derive(Debug)] +pub enum HandshakeError { + BadRequestLen, + InvalidPairingSlot, + UnauthorizedPairingSlot, +} + +/// Errors when (un)wrapping an L3 ENCRYPTED_CMD packet. +#[derive(Debug)] +pub enum L3WrapError { + /// L2 body too short to contain `[cmd_size: u16][ciphertext][tag: 16]`. + Truncated, + /// AES-GCM tag verification failed -- attacker, bit flip, nonce desync. + BadTag, + /// Per-direction nonce wrapped 2^32. Real silicon throws SESSION_INVALID. + NonceOverflow, +} + +fn step_nonce(nonce: &mut [u8; 12]) -> Result<(), L3WrapError> { + // libtropic treats bytes 0..4 as a little-endian u32 counter and + // leaves bytes 4..12 zero -- see `lt_l3_nonce_increase` in + // `lt_l3_process.c`. + let counter = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]); + if counter == u32::MAX { + return Err(L3WrapError::NonceOverflow); + } + let next = counter + 1; + nonce[0..4].copy_from_slice(&next.to_le_bytes()); + Ok(()) +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + pub fn is_open(&self) -> bool { + self.keys.is_some() + } + + pub fn abort(&mut self) { + self.keys = None; + } + + /// Decrypt an ENCRYPTED_CMD L2 body into the L3 plaintext bytes + /// (`[cmd_id (1B)][...fields...]`). The L2 body wire layout is + /// `[cmd_size: u16 LE][ciphertext (cmd_size B)][tag (16B)]`. + /// Increments `nonce_cmd` on success. + pub fn unwrap_l3_request(&mut self, l2_body: &[u8]) -> Result, L3WrapError> { + let keys = self.keys.as_mut().ok_or(L3WrapError::BadTag)?; + if l2_body.len() < 2 + 16 { + return Err(L3WrapError::Truncated); + } + let cmd_size = u16::from_le_bytes([l2_body[0], l2_body[1]]) as usize; + if l2_body.len() != 2 + cmd_size + 16 { + return Err(L3WrapError::Truncated); + } + let ct_and_tag = &l2_body[2..]; + let cipher = Aes256Gcm::new_from_slice(&keys.k_cmd).expect("32-byte key"); + let nonce: &Nonce = (&keys.nonce_cmd).into(); + let plaintext = cipher + .decrypt(nonce, Payload { msg: ct_and_tag, aad: &[] }) + .map_err(|_| L3WrapError::BadTag)?; + step_nonce(&mut keys.nonce_cmd)?; + Ok(plaintext) + } + + /// Encrypt an L3 plaintext response (`[result (1B)][...fields...]`) + /// into an ENCRYPTED_CMD L2 body. Increments `nonce_res` on success. + pub fn wrap_l3_response(&mut self, plaintext: &[u8]) -> Result, L3WrapError> { + let keys = self.keys.as_mut().ok_or(L3WrapError::BadTag)?; + let cipher = Aes256Gcm::new_from_slice(&keys.k_res).expect("32-byte key"); + let nonce: &Nonce = (&keys.nonce_res).into(); + let ct_and_tag = cipher + .encrypt(nonce, Payload { msg: plaintext, aad: &[] }) + .expect("AES-GCM encrypt cannot fail with valid key/nonce"); + step_nonce(&mut keys.nonce_res)?; + let res_size = plaintext.len() as u16; + let mut wire = Vec::with_capacity(2 + ct_and_tag.len()); + wire.extend_from_slice(&res_size.to_le_bytes()); + wire.extend_from_slice(&ct_and_tag); + Ok(wire) + } + + /// Process a HANDSHAKE_REQ payload and emit the 48-byte HANDSHAKE_RSP + /// body (`ETPUB(32) || tag(16)`). On success, opens a Secure Channel + /// keyed by `kCMD`/`kRES` with both nonce counters reset to zero. + pub fn handshake( + &mut self, + device: &Device, + request: &[u8], + ) -> Result, HandshakeError> { + if request.len() != HANDSHAKE_REQ_LEN { + return Err(HandshakeError::BadRequestLen); + } + let ehpub: [u8; 32] = request[..32].try_into().unwrap(); + let pkey_index = request[32]; + + let pairing_slot = device + .pairing_slots + .get(&pkey_index) + .ok_or(HandshakeError::InvalidPairingSlot)?; + if !pairing_slot.is_valid { + return Err(HandshakeError::UnauthorizedPairingSlot); + } + let shipub = pairing_slot.public_key; + + // Generate the chip's ephemeral X25519 keypair (ETPRIV/ETPUB). + let mut etpriv_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut etpriv_bytes); + let etpriv = X25519Static::from(etpriv_bytes); + let etpub = X25519Public::from(&etpriv).to_bytes(); + + // Transcript hash: h = SHA256(protocol_name || SHIPUB || STPUB || EHPUB || pkey_index || ETPUB), + // applied iteratively (each new piece prepends the prior digest). + let h0 = sha256(&PROTOCOL_NAME); + let h1 = sha256_concat(&h0, &shipub); + let h2 = sha256_concat(&h1, &device.st_pub); + let h3 = sha256_concat(&h2, &ehpub); + let h4 = sha256_concat(&h3, &[pkey_index]); + let h = sha256_concat(&h4, &etpub); + + // ECDH triple. Note the chip plays the same three X25519 ops as + // the host -- it computes the same shared secrets from the other + // direction: + // ss1 = X25519(ETPRIV, EHPUB) == X25519(EHPRIV, ETPUB) + // ss2 = X25519(ETPRIV, SHIPUB) == X25519(SHIPRIV, ETPUB) + // ss3 = X25519(STPRIV, EHPUB) == X25519(EHPRIV, STPUB) + let ehpub_pk = X25519Public::from(ehpub); + let shipub_pk = X25519Public::from(shipub); + let stpriv = X25519Static::from(device.st_priv); + + let ss1 = etpriv.diffie_hellman(&ehpub_pk).to_bytes(); + let ss2 = etpriv.diffie_hellman(&shipub_pk).to_bytes(); + let ss3 = stpriv.diffie_hellman(&ehpub_pk).to_bytes(); + + // libtropic-style HKDF chain. + let mut ck33 = [0u8; 33]; + ck33[..32].copy_from_slice(&PROTOCOL_NAME); + // After step 0 the salt is 32 bytes (PROTOCOL_NAME). The + // subsequent steps use a 33-byte salt -- the prior `output_1` + // padded with one trailing zero -- to mirror the + // `output_1[33] = {0}` buffer libtropic passes. + let (out1_a, _) = lt_hkdf(&PROTOCOL_NAME, &ss1); + ck33[..32].copy_from_slice(&out1_a); + let (out1_b, _) = lt_hkdf(&ck33, &ss2); + ck33[..32].copy_from_slice(&out1_b); + let (out1_c, k_auth) = lt_hkdf(&ck33, &ss3); + ck33[..32].copy_from_slice(&out1_c); + let (k_cmd, k_res) = lt_hkdf(&ck33, b""); + + // Auth tag: AES-256-GCM seal with kAUTH, IV = 12 zero bytes, + // AAD = h, plaintext empty. The 16-byte tag is the only output. + let tag = aes_gcm_seal_tag(&k_auth, &[0u8; 12], &h); + + self.keys = Some(SessionKeys { + k_cmd, + k_res, + nonce_cmd: [0u8; 12], + nonce_res: [0u8; 12], + }); + + let mut response = Vec::with_capacity(HANDSHAKE_RSP_LEN); + response.extend_from_slice(&etpub); + response.extend_from_slice(&tag); + Ok(response) + } +} + +fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(data); + h.finalize().into() +} + +fn sha256_concat(prev: &[u8; 32], extra: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(prev); + h.update(extra); + h.finalize().into() +} + +/// Reproduces `lt_hkdf` from `libtropic/src/lt_hkdf.c`: +/// tmp = HMAC-SHA256(key=salt, msg=ikm) +/// output1 = HMAC-SHA256(key=tmp, msg=[0x01]) +/// output2 = HMAC-SHA256(key=tmp, msg=output1 || [0x02]) +fn lt_hkdf(salt: &[u8], ikm: &[u8]) -> ([u8; 32], [u8; 32]) { + let mut mac = + ::new_from_slice(salt).expect("HMAC accepts any key length"); + mac.update(ikm); + let tmp: [u8; 32] = mac.finalize().into_bytes().into(); + + let mut mac = ::new_from_slice(&tmp).unwrap(); + mac.update(&[0x01]); + let out1: [u8; 32] = mac.finalize().into_bytes().into(); + + let mut helper = [0u8; 33]; + helper[..32].copy_from_slice(&out1); + helper[32] = 0x02; + let mut mac = ::new_from_slice(&tmp).unwrap(); + mac.update(&helper); + let out2: [u8; 32] = mac.finalize().into_bytes().into(); + + (out1, out2) +} + +fn aes_gcm_seal_tag(key: &[u8; 32], iv: &[u8; 12], aad: &[u8]) -> [u8; 16] { + let cipher = Aes256Gcm::new_from_slice(key).expect("32-byte AES key"); + let nonce: &Nonce = (iv).into(); + let ct = cipher + .encrypt(nonce, Payload { msg: &[], aad }) + .expect("AES-GCM seal of empty plaintext cannot fail"); + // Empty plaintext -> output is just the 16-byte tag. + let mut tag = [0u8; 16]; + tag.copy_from_slice(&ct); + tag +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::object_store::{ + default_host_pairing_priv, default_host_pairing_pub, Store, + }; + + /// End-to-end check: drive the handshake from the host side using the + /// engineering-sample SHIPRIV/SHIPUB and verify the chip's auth tag. + #[test] + fn handshake_round_trip_is_authenticated() { + let store = Store::fresh(); + let device = &store.device; + + // Host's ephemeral keypair. + let mut ehpriv_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut ehpriv_bytes); + let ehpriv = X25519Static::from(ehpriv_bytes); + let ehpub = X25519Public::from(&ehpriv).to_bytes(); + + // Send HANDSHAKE_REQ to the simulator. + let mut req = Vec::with_capacity(HANDSHAKE_REQ_LEN); + req.extend_from_slice(&ehpub); + req.push(0u8); // pkey_index + let mut session = Session::new(); + let rsp = session.handshake(device, &req).expect("handshake"); + assert_eq!(rsp.len(), HANDSHAKE_RSP_LEN); + assert!(session.is_open()); + + // Host-side verification of the auth tag. + let etpub: [u8; 32] = rsp[..32].try_into().unwrap(); + let tag: [u8; 16] = rsp[32..48].try_into().unwrap(); + + let shipub = default_host_pairing_pub(); + let shipriv_bytes = default_host_pairing_priv(); + let shipriv = X25519Static::from(shipriv_bytes); + + let h0 = sha256(&PROTOCOL_NAME); + let h1 = sha256_concat(&h0, &shipub); + let h2 = sha256_concat(&h1, &device.st_pub); + let h3 = sha256_concat(&h2, &ehpub); + let h4 = sha256_concat(&h3, &[0u8]); + let h = sha256_concat(&h4, &etpub); + + let etpub_pk = X25519Public::from(etpub); + let stpub_pk = X25519Public::from(device.st_pub); + let ss1 = ehpriv.diffie_hellman(&etpub_pk).to_bytes(); + let ss2 = shipriv.diffie_hellman(&etpub_pk).to_bytes(); + let ss3 = ehpriv.diffie_hellman(&stpub_pk).to_bytes(); + + let mut ck33 = [0u8; 33]; + ck33[..32].copy_from_slice(&PROTOCOL_NAME); + let (out_a, _) = lt_hkdf(&PROTOCOL_NAME, &ss1); + ck33[..32].copy_from_slice(&out_a); + let (out_b, _) = lt_hkdf(&ck33, &ss2); + ck33[..32].copy_from_slice(&out_b); + let (out_c, host_kauth) = lt_hkdf(&ck33, &ss3); + ck33[..32].copy_from_slice(&out_c); + let (host_kcmd, host_kres) = lt_hkdf(&ck33, b""); + + // Verify the chip's tag using AES-GCM open semantics. + let cipher = Aes256Gcm::new_from_slice(&host_kauth).unwrap(); + let zero_iv = [0u8; 12]; + let nonce: &Nonce = (&zero_iv).into(); + // Ciphertext is just the tag (no plaintext bytes). + let result = cipher.decrypt(nonce, Payload { msg: &tag, aad: &h }); + assert!(result.is_ok(), "host-side tag verification failed"); + + // Confirm both sides derived identical traffic keys. + let chip_keys = session.keys.as_ref().unwrap(); + assert_eq!(host_kcmd, chip_keys.k_cmd); + assert_eq!(host_kres, chip_keys.k_res); + } + + #[test] + fn handshake_rejects_invalid_pairing_slot() { + let store = Store::fresh(); + let mut session = Session::new(); + let mut req = vec![0u8; HANDSHAKE_REQ_LEN]; + req[32] = 5; // pkey_index out of range + let res = session.handshake(&store.device, &req); + assert!(matches!(res, Err(HandshakeError::InvalidPairingSlot))); + assert!(!session.is_open()); + } + + #[test] + fn handshake_rejects_bad_request_len() { + let store = Store::fresh(); + let mut session = Session::new(); + let res = session.handshake(&store.device, &[0u8; 10]); + assert!(matches!(res, Err(HandshakeError::BadRequestLen))); + } + + #[test] + fn lt_hkdf_two_outputs_match_libtropic_shape() { + // Smoke test: lt_hkdf should produce 32B outputs and the second + // output should differ from the first (otherwise the helper byte + // [0x02] suffix isn't being applied). + let (a, b) = lt_hkdf(b"saltysalt", b"some input keying material"); + assert_ne!(a, b); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/spi.rs b/TROPIC01Sim/tropic01-sim/src/spi.rs new file mode 100644 index 0000000..1def9db --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/spi.rs @@ -0,0 +1,304 @@ +/* spi.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// Bit-accurate SPI byte-exchange emulator for TROPIC01. +/// +/// Mirrors the L1 protocol from `libtropic/src/lt_l1.c`: +/// +/// Write transaction (host -> chip): +/// CSN_LOW -> SPI_SEND([REQ_ID][REQ_LEN][DATA][CRC]) -> CSN_HIGH +/// Read transaction (host <- chip), polled until ready: +/// CSN_LOW -> SPI_SEND(0xAA) returning CHIP_STATUS +/// if READY: SPI_SEND(2 dummy bytes) returning [STATUS][RSP_LEN] +/// SPI_SEND(RSP_LEN+2 dummy) returning [DATA][CRC] +/// CSN_HIGH +/// +/// Within one CSN-asserted span, the host can issue several `SPI_SEND` +/// calls, so this emulator tracks state across them. State is reset on +/// every CSN_LOW. +/// +/// The first inbound MOSI byte after CSN_LOW disambiguates the transaction +/// kind: `0xAA` (`GET_RESPONSE_REQ_ID`) means "poll for a staged response", +/// any other byte begins a new request that we accumulate until the L2 +/// frame is complete (REQ_ID + REQ_LEN + REQ_LEN bytes + 2 CRC bytes). +use crate::frame::{status, GET_RESPONSE_REQ_ID, MAX_L2_FRAME_SIZE}; + +/// `TR01_L1_CHIP_MODE_*` bits from `lt_l1.h`. The chip exposes its mode +/// as the first byte the host sees after a polled SPI_SEND; the host +/// reads `READY` before requesting the rest of the response. +pub mod chip_status { + pub const READY: u8 = 0x01; + pub const ALARM: u8 = 0x02; + pub const STARTUP: u8 = 0x04; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + /// CSN is high; no transaction in flight. + Idle, + /// CSN is low and we have not yet seen any MOSI bytes; the next byte + /// determines whether this is a write (REQ_ID != 0xAA) or a read poll. + AwaitingFirstByte, + /// Accumulating MOSI bytes into `request_buf` until the L2 frame is + /// complete. We know it's complete once we have `REQ_LEN + 4` bytes. + Writing, + /// Servicing a polled-read transaction. Each subsequent MOSI byte the + /// host clocks gets the next byte from `response_stream` returned as + /// MISO. Once the host CSN_HIGH's, we drop the cursor. + Reading, +} + +/// State of the SPI line for a single host connection. +pub struct SpiEmulator { + phase: Phase, + /// Bytes of the in-flight L2 request being assembled (empty between + /// transactions). + request_buf: Vec, + /// Whatever has been queued for the host to read next: this is + /// `[STATUS][RSP_LEN][DATA][CRC]` -- the full L2 response frame -- and + /// gets prefixed with CHIP_STATUS=READY at poll time. + pending_response: Option>, + /// Cursor into the response being clocked out. Reset to 0 on CSN_LOW. + response_cursor: usize, +} + +/// What `feed_byte` returned about the just-completed request: when the +/// caller sees `RequestComplete`, the dispatcher should be invoked on +/// `request_buf` to produce the L2 response, then `stage_response` called +/// before CSN_HIGH so the next poll can serve it. +#[derive(Debug, Clone)] +pub enum SpiOutcome { + /// Continue clocking; this byte was buffered. + Pending, + /// Full L2 request received; caller should run dispatch on the bytes + /// returned by `take_request()` and then `stage_response()`. + RequestComplete, +} + +impl Default for SpiEmulator { + fn default() -> Self { + Self::new() + } +} + +impl SpiEmulator { + pub fn new() -> Self { + Self { + phase: Phase::Idle, + request_buf: Vec::with_capacity(MAX_L2_FRAME_SIZE), + pending_response: None, + response_cursor: 0, + } + } + + /// Host drives CSN low. Resets per-transaction cursors but retains + /// any staged response so the next poll can pick it up. + pub fn csn_low(&mut self) { + self.phase = Phase::AwaitingFirstByte; + self.request_buf.clear(); + self.response_cursor = 0; + } + + /// Host drives CSN high. Ends the current transaction. We keep the + /// staged response around for the next poll cycle. + pub fn csn_high(&mut self) { + self.phase = Phase::Idle; + } + + /// Process one full SPI_SEND. The host clocks `mosi.len()` bytes; we + /// return `mosi.len()` MISO bytes plus an outcome flag. The outcome is + /// `RequestComplete` exactly once -- on the byte that closes the L2 + /// request frame -- so the caller can run dispatch right then. + pub fn spi_transfer(&mut self, mosi: &[u8]) -> (Vec, SpiOutcome) { + let mut miso = Vec::with_capacity(mosi.len()); + let mut outcome = SpiOutcome::Pending; + + for &byte in mosi { + let response_byte = self.feed_byte(byte, &mut outcome); + miso.push(response_byte); + } + (miso, outcome) + } + + fn feed_byte(&mut self, byte: u8, outcome: &mut SpiOutcome) -> u8 { + match self.phase { + Phase::Idle => { + // Host sent SPI_SEND without a CSN_LOW first. Real silicon + // would just sample garbage; return 0xFF and stay idle. + 0xFF + } + Phase::AwaitingFirstByte => { + if byte == GET_RESPONSE_REQ_ID { + // Poll. CHIP_STATUS is always READY in this simulator + // -- we never enter ALARM or STARTUP mode -- so the + // first MISO byte is the constant `chip_status::READY`. + // What follows on subsequent bytes depends on whether + // a real L2 response is staged: + // - If yes, byte stream is [STATUS][RSP_LEN][DATA][CRC] + // (as built by `frame::build_response`). + // - If no, byte stream is [STATUS=NO_RESP=0xFF][...zeros], + // which the host's `lt_l1_read` recognises as + // "still busy, retry". + self.phase = Phase::Reading; + self.response_cursor = 0; + if self.pending_response.is_none() { + // Stage a NO_RESP placeholder so the Reading-phase + // bytes the host clocks out have STATUS=0xFF in + // position 1. + self.pending_response = Some(vec![status::NO_RESP, 0, 0, 0]); + } + chip_status::READY + } else { + // Start of a write: this is REQ_ID. We don't transition + // to Reading; subsequent bytes accumulate until the + // frame is complete. + self.phase = Phase::Writing; + self.request_buf.push(byte); + self.maybe_complete(outcome); + 0x00 + } + } + Phase::Writing => { + if self.request_buf.len() < MAX_L2_FRAME_SIZE { + self.request_buf.push(byte); + } + self.maybe_complete(outcome); + 0x00 + } + Phase::Reading => { + let resp = self + .pending_response + .as_ref() + .expect("pending_response must be set in Reading phase"); + let out = if self.response_cursor < resp.len() { + resp[self.response_cursor] + } else { + // Host clocked past the response; emit 0xFF padding. + 0xFF + }; + self.response_cursor += 1; + out + } + } + } + + fn maybe_complete(&mut self, outcome: &mut SpiOutcome) { + // L2 request frame layout: REQ_ID (1) + REQ_LEN (1) + DATA (REQ_LEN) + CRC (2). + if self.request_buf.len() < 2 { + return; + } + let req_len = self.request_buf[1] as usize; + let total = 1 + 1 + req_len + 2; + if self.request_buf.len() == total { + *outcome = SpiOutcome::RequestComplete; + } + } + + /// Pull the assembled L2 request bytes out for dispatch. Resets the + /// internal accumulator. + pub fn take_request(&mut self) -> Vec { + std::mem::take(&mut self.request_buf) + } + + /// Stage the L2 response bytes (`[STATUS][RSP_LEN][DATA][CRC]`) so + /// they can be served on the next polled-read cycle. Calling this + /// with `None` clears any prior staged response (so the next poll + /// sees the synthetic NO_RESP placeholder). + pub fn stage_response(&mut self, response: Option>) { + self.pending_response = response; + self.response_cursor = 0; + } + + /// Clear the staged response after a successful read transaction. + /// Should be called by the dispatcher after `csn_high` so the next + /// L2 request gets a fresh slate. + pub fn clear_response(&mut self) { + self.pending_response = None; + self.response_cursor = 0; + } + + /// True if a response is currently waiting to be polled. + pub fn has_pending_response(&self) -> bool { + self.pending_response.is_some() + } +} + +/// Build the bytes the chip emits during a poll when it has nothing yet: +/// `[CHIP_STATUS=READY][STATUS=NO_RESP]`. lt_l1 inspects byte-1 for the +/// 0xFF sentinel and re-polls. Used by the dispatcher when a request is +/// incomplete or when we want to signal "still working" on a slow op. +#[allow(dead_code)] +pub fn build_no_resp_polling_reply() -> Vec { + vec![chip_status::READY, status::NO_RESP, 0x00, 0x00] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::{build_request, build_response}; + + #[test] + fn write_then_poll_round_trip() { + let mut spi = SpiEmulator::new(); + + // Host sends a write transaction: csn_low, send full request, csn_high. + spi.csn_low(); + let req_bytes = build_request(0x01, &[0x00, 0x00]); + let (_miso, outcome) = spi.spi_transfer(&req_bytes); + assert!(matches!(outcome, SpiOutcome::RequestComplete)); + + // Caller would dispatch and stage a response here; we fake one. + let req = spi.take_request(); + assert_eq!(req, req_bytes); + spi.stage_response(Some(build_response(status::REQUEST_OK, &[0xDE, 0xAD]))); + spi.csn_high(); + + // Host now polls: csn_low, send 0xAA, expect CHIP_STATUS=READY. + spi.csn_low(); + let (miso1, _) = spi.spi_transfer(&[GET_RESPONSE_REQ_ID]); + assert_eq!(miso1, vec![chip_status::READY]); + + // Host clocks 2 more bytes -> expect [STATUS][RSP_LEN]. + let (miso2, _) = spi.spi_transfer(&[0x00, 0x00]); + assert_eq!(miso2, vec![status::REQUEST_OK, 2]); + + // Host clocks 4 more bytes -> expect [DATA(2)][CRC(2)]. + let (miso3, _) = spi.spi_transfer(&[0x00; 4]); + assert_eq!(miso3.len(), 4); + assert_eq!(&miso3[..2], &[0xDE, 0xAD]); + spi.csn_high(); + } + + #[test] + fn poll_before_response_returns_ready_then_no_resp() { + // The chip is always alive (READY bit set) but signals "no data + // yet" via the STATUS=0xFF (NO_RESP) byte that follows. + // This matches `lt_l1_read`'s polling loop, which only retries on + // STATUS=NO_RESP, never on CHIP_STATUS. + let mut spi = SpiEmulator::new(); + spi.csn_low(); + let (miso1, _) = spi.spi_transfer(&[GET_RESPONSE_REQ_ID]); + assert_eq!(miso1, vec![chip_status::READY]); + let (miso2, _) = spi.spi_transfer(&[0x00, 0x00]); + assert_eq!(miso2[0], status::NO_RESP); + spi.csn_high(); + } +} diff --git a/TROPIC01Sim/tropic01-sim/src/tcp_proto.rs b/TROPIC01Sim/tropic01-sim/src/tcp_proto.rs new file mode 100644 index 0000000..a941630 --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/src/tcp_proto.rs @@ -0,0 +1,176 @@ +/* tcp_proto.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +/// libtropic's "TROPIC01 Model" TCP framing used by `hal/posix/tcp/`. +/// Each message on the wire is `[tag (1B)] [len (2B little-endian)] [payload (len B)]`. +/// The host (libtropic) and the server (this simulator) speak the same +/// frame in both directions; the server echoes the tag back. +/// +/// The byte order of `len` follows the packed C struct in +/// `libtropic_port_posix_tcp.h::lt_posix_tcp_buffer_t` -- on every platform +/// libtropic actually targets via this HAL (Linux x86_64 / aarch64 / armv7), +/// that means little-endian. +use std::io::{self, Read, Write}; + +pub const TAG_AND_LEN_SIZE: usize = 3; +pub const MAX_PAYLOAD_LEN: usize = 1 + 1 + 252 + 2; // TR01_L2_MAX_FRAME_SIZE + +/// Tags from `libtropic_port_posix_tcp.h::lt_posix_tcp_tag_t`. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TcpTag { + SpiDriveCsnLow = 0x01, + SpiDriveCsnHigh = 0x02, + SpiSend = 0x03, + PowerOn = 0x04, + PowerOff = 0x05, + Wait = 0x06, + ResetTarget = 0x10, + Invalid = 0xFD, + Unsupported = 0xFE, +} + +impl TcpTag { + pub fn from_u8(v: u8) -> Self { + match v { + 0x01 => TcpTag::SpiDriveCsnLow, + 0x02 => TcpTag::SpiDriveCsnHigh, + 0x03 => TcpTag::SpiSend, + 0x04 => TcpTag::PowerOn, + 0x05 => TcpTag::PowerOff, + 0x06 => TcpTag::Wait, + 0x10 => TcpTag::ResetTarget, + 0xFD => TcpTag::Invalid, + 0xFE => TcpTag::Unsupported, + _ => TcpTag::Invalid, + } + } +} + +/// One framed message read from / written to the socket. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TcpFrame { + pub tag: u8, + pub payload: Vec, +} + +impl TcpFrame { + pub fn new(tag: TcpTag, payload: Vec) -> Self { + Self { + tag: tag as u8, + payload, + } + } + + /// Read one frame off the wire. Returns Ok(None) on clean EOF. + pub fn read_from(r: &mut R) -> io::Result> { + let mut header = [0u8; TAG_AND_LEN_SIZE]; + match r.read_exact(&mut header) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let tag = header[0]; + let len = u16::from_le_bytes([header[1], header[2]]) as usize; + if len > MAX_PAYLOAD_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("oversized TCP frame: len={len}"), + )); + } + let mut payload = vec![0u8; len]; + if len > 0 { + r.read_exact(&mut payload)?; + } + Ok(Some(Self { tag, payload })) + } + + pub fn write_to(&self, w: &mut W) -> io::Result<()> { + if self.payload.len() > MAX_PAYLOAD_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "TCP frame payload too large: {} > {}", + self.payload.len(), + MAX_PAYLOAD_LEN + ), + )); + } + let mut header = [0u8; TAG_AND_LEN_SIZE]; + header[0] = self.tag; + let len = self.payload.len() as u16; + header[1..3].copy_from_slice(&len.to_le_bytes()); + w.write_all(&header)?; + if !self.payload.is_empty() { + w.write_all(&self.payload)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn round_trip_csn_low() { + let frame = TcpFrame::new(TcpTag::SpiDriveCsnLow, vec![]); + let mut buf = Vec::new(); + frame.write_to(&mut buf).unwrap(); + assert_eq!(buf, vec![0x01, 0x00, 0x00]); + + let mut cursor = Cursor::new(buf); + let parsed = TcpFrame::read_from(&mut cursor).unwrap().unwrap(); + assert_eq!(parsed, frame); + } + + #[test] + fn round_trip_spi_send() { + let frame = TcpFrame::new(TcpTag::SpiSend, vec![0xAA, 0x01, 0x02, 0x03]); + let mut buf = Vec::new(); + frame.write_to(&mut buf).unwrap(); + assert_eq!(buf[0], 0x03); + assert_eq!(u16::from_le_bytes([buf[1], buf[2]]), 4); + assert_eq!(&buf[3..], &[0xAA, 0x01, 0x02, 0x03]); + + let mut cursor = Cursor::new(buf); + let parsed = TcpFrame::read_from(&mut cursor).unwrap().unwrap(); + assert_eq!(parsed, frame); + } + + #[test] + fn read_eof_returns_none() { + let mut empty = Cursor::new(Vec::new()); + assert!(TcpFrame::read_from(&mut empty).unwrap().is_none()); + } + + #[test] + fn write_rejects_oversized_payload() { + let frame = TcpFrame { + tag: TcpTag::SpiSend as u8, + payload: vec![0u8; MAX_PAYLOAD_LEN + 1], + }; + let mut sink: Vec = Vec::new(); + let err = frame.write_to(&mut sink).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/TROPIC01Sim/tropic01-sim/tests/tcp.rs b/TROPIC01Sim/tropic01-sim/tests/tcp.rs new file mode 100644 index 0000000..931b3aa --- /dev/null +++ b/TROPIC01Sim/tropic01-sim/tests/tcp.rs @@ -0,0 +1,139 @@ +/* tcp.rs + * + * Copyright (C) 2026 wolfSSL Inc. + * + * This file is part of TROPIC01Sim. + * + * TROPIC01Sim is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * TROPIC01Sim is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + */ + +//! End-to-end smoke tests against the TCP server. Spawns the binary and +//! drives it the way libtropic's `hal/posix/tcp/` would: send a sequence +//! of `[tag][len LE][payload]` frames covering one CSN_LOW / SPI_SEND / +//! CSN_HIGH transaction for a write, then a poll cycle for the response. + +use std::net::TcpStream; +use std::process::{Child, Command, Stdio}; +use std::thread::sleep; +use std::time::Duration; + +use tropic01_sim::frame::{build_request, GET_RESPONSE_REQ_ID}; +use tropic01_sim::tcp_proto::{TcpFrame, TcpTag}; + +/// RAII guard so a panicking test still kills its spawned server. +struct ServerGuard { + child: Child, + port: u16, +} + +impl Drop for ServerGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Spawn the TCP server on an unused port. Returns a guard that owns the +/// child and exposes the bound port. Sets `TROPIC01_SIM_FRESH=1` so each +/// test starts from a known-good provisioned state. +fn spawn_server() -> ServerGuard { + let port = pick_port(); + let child = Command::new(env!("CARGO_BIN_EXE_tcp_server")) + .env("TROPIC01_SIM_BIND", "127.0.0.1") + .env("TROPIC01_SIM_PORT", port.to_string()) + .env("TROPIC01_SIM_FRESH", "1") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn tcp_server"); + wait_for_listen(port); + ServerGuard { child, port } +} + +fn pick_port() -> u16 { + // Bind ephemeral, read assigned port, drop. There's a TOCTOU window + // before the server claims it, but this is integration-test grade. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +fn wait_for_listen(port: u16) { + for _ in 0..200 { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + sleep(Duration::from_millis(25)); + } + panic!("tcp_server did not start listening on port {port}"); +} + +fn round_trip(stream: &mut TcpStream, frame: TcpFrame) -> TcpFrame { + frame.write_to(stream).unwrap(); + TcpFrame::read_from(stream).unwrap().expect("eof from server") +} + +#[test] +fn get_info_chip_id_e2e() { + let server = spawn_server(); + let mut stream = TcpStream::connect(("127.0.0.1", server.port)).unwrap(); + + // Write transaction: csn_low, send GET_INFO(CHIP_ID), csn_high. + round_trip(&mut stream, TcpFrame::new(TcpTag::SpiDriveCsnLow, vec![])); + let req = build_request(0x01, &[0x01, 0x00]); // GET_INFO, object=CHIP_ID, block=0 + let r = round_trip(&mut stream, TcpFrame::new(TcpTag::SpiSend, req.clone())); + assert_eq!(r.payload.len(), req.len()); // MISO bytes match MOSI length + round_trip(&mut stream, TcpFrame::new(TcpTag::SpiDriveCsnHigh, vec![])); + + // Poll: csn_low, send 0xAA -> CHIP_STATUS=READY (0x01). + round_trip(&mut stream, TcpFrame::new(TcpTag::SpiDriveCsnLow, vec![])); + let status = round_trip( + &mut stream, + TcpFrame::new(TcpTag::SpiSend, vec![GET_RESPONSE_REQ_ID]), + ); + assert_eq!(status.payload, vec![0x01]); // chip_status::READY + + // Read STATUS + RSP_LEN. + let header = round_trip(&mut stream, TcpFrame::new(TcpTag::SpiSend, vec![0x00, 0x00])); + assert_eq!(header.payload[0], 0x01); // status::REQUEST_OK + let rsp_len = header.payload[1] as usize; + assert_eq!(rsp_len, 128); + + // Read DATA + CRC. + let trailer = round_trip( + &mut stream, + TcpFrame::new(TcpTag::SpiSend, vec![0x00; rsp_len + 2]), + ); + assert_eq!(trailer.payload.len(), rsp_len + 2); + // First 12 bytes of data are the chip ID; the rest are zero padding. + let chip_id = &trailer.payload[..12]; + assert!(chip_id.iter().any(|&b| b != 0)); + round_trip(&mut stream, TcpFrame::new(TcpTag::SpiDriveCsnHigh, vec![])); + + drop(stream); + drop(server); +} + +#[test] +fn power_on_resets_chip() { + let server = spawn_server(); + let mut stream = TcpStream::connect(("127.0.0.1", server.port)).unwrap(); + + let r = round_trip(&mut stream, TcpFrame::new(TcpTag::PowerOn, vec![])); + assert_eq!(r.tag, TcpTag::PowerOn as u8); + assert!(r.payload.is_empty()); + + drop(stream); + drop(server); +} diff --git a/TROPIC01Sim/wolfcrypt-test/run_test.sh b/TROPIC01Sim/wolfcrypt-test/run_test.sh new file mode 100644 index 0000000..95616c4 --- /dev/null +++ b/TROPIC01Sim/wolfcrypt-test/run_test.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# run_test.sh +# +# Copyright (C) 2026 wolfSSL Inc. +# +# This file is part of TROPIC01Sim. +# +# TROPIC01Sim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. + +# Spawns the simulator on TCP port 28992 (libtropic v0.1.0's hardcoded +# default) then runs the upstream tropic01-wolfssl-test binary, which +# exercises wolfCrypt RNG / AES-CBC / Ed25519 keygen+sign+verify all +# through the WOLF_TROPIC01_DEVID crypto callback. + +set -eu + +SIM_BIN="${SIM_BIN:-/app/tcp_server}" +TEST_BIN="${TEST_BIN:-/app/tropic01-wolfssl-test/lt-wolfssl-test}" +SIM_PORT="${TROPIC01_SIM_PORT:-28992}" +SIM_HOST="${TROPIC01_SIM_HOST:-127.0.0.1}" + +export TROPIC01_SIM_BIND="${TROPIC01_SIM_BIND:-127.0.0.1}" +export TROPIC01_SIM_PORT="${SIM_PORT}" +export TROPIC01_SIM_FRESH=1 + +cleanup() { + if [ -n "${SIM_PID:-}" ] && kill -0 "${SIM_PID}" 2>/dev/null; then + kill "${SIM_PID}" 2>/dev/null || true + wait "${SIM_PID}" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +"${SIM_BIN}" & +SIM_PID=$! + +SIM_READY=0 +for i in $(seq 1 50); do + if (echo > /dev/tcp/"${SIM_HOST}"/"${SIM_PORT}") 2>/dev/null; then + SIM_READY=1 + break + fi + sleep 0.1 +done +if [ "${SIM_READY}" -ne 1 ]; then + echo "ERROR: tropic01 simulator did not start listening on ${SIM_HOST}:${SIM_PORT} within 5s" >&2 + exit 1 +fi + +"${TEST_BIN}" +RC=$? +exit $RC