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