A Post-Quantum DTLS1.3 Handshake on bare-metal RISCV
Contents:
- The challenge
- The setup
- Inter-IIT QTrinoLabs Report
- 1. Problem Understanding
- 2. Architecture and Design Approach
- 3. PQC and Classical Algorithm Choices
- 4. Firmware Design for RISC-V Bare-Metal
- 5. Integration of WolfSSL/WolfCrypt
- 6. Challenges and Solutions
- 7. Security Considerations
- 8. Performance Metrics
- 9. Support for Session Resumption
- 10. Low-Power RISC-V Optimisations
- 11. Custom simulated TRNG
- Annexures
- Annexure 1.1: Architecture and Design Details
- Annexure 1.2: Algorithm Selection Rationale
- Annexure 1.3: WolfSSL Configuration (user_settings.h)
- Annexure 1.4: Additional Challenges and Solutions
- Annexure 1.5: Performance Metrics & Methodology
- Annexure 1.6: Session Resumption Implementation
- Annexure 1.7: Low Power Optimisations
- Annexure 1.8: Custom Entropy (TRNG) Integration
This year for Inter-IIT we had by far the best solution (we got gold). This is a blog that explains how we did what we did.
# The challenge
The problem was to implement a DTLSv1.3 (The UDP version of TLSv1.3) in a bare-metal RISC-V simulation through post quantum KEMs and signature verification schemes. There are basically the following problems in doing this:
- The sizes of the generated keys and certificates through PQC algorithms is very large
- UDP default buffer size is 1500 bytes (called the MTU/PMTU)
- We have only 2 rxbuffer slots (which means our sim can only hold 2 UDP packets at a time)
So, the certificates and keys were all fragmented, which later had to be corrected. Now this is allowed according to the RFC9147 but it’s a pain to deal with.
# The setup
- We’re running the simulation on litex which is a python based simulator for embedded systems, its pretty cool
- This is a
rv32immachine, which means it can’t do no floating point operations, so we need to be careful here wolfsslis a beast. It’s a beautiful library inCwhich provides us all the necessary code for actually performing a DTLS handshake according to the RFC.
So the idea is the following:
- Cross compile the wolfssl library for
rv32im - Define a
user_settings.hfile for setting up the right flags to enable DTLSv1.3 - We don’t have a filesystem or a RNG so we’ll have to figure that out
- Measuring latency through
number of CPU cyclestaken for the handshake to be completed - Resuming a session without performing a handshake again after authentication has finished once before
Cross compiling wolfssl is a slight problem, firstly because we need to compile the riscv 32-bit toolkit. We compile and link all the wolfssl source code during the make process.
What follows is the report we submitted.
# Inter-IIT QTrinoLabs Report
# 1. Problem Understanding
We had to get a DTLS 1.3 communication channel running on a resource-constrained, bare-metal RISC-V (RV32IM) machine simulated in LiteX, and it had to use Post-Quantum Cryptography (PQC) primitives for both the Key Encapsulation Mechanism (KEM) and the Digital Signatures. On top of that we had to optimise the crypto stack to keep handshake latency down and throughput up, inside whatever budget the soft-core CPU gave us.
# 2. Architecture and Design Approach
Our client runs on a LiteX-simulated RISC-V (RV32IM) core at roughly 100 MHz (we had to stay at or above 75MHz), while the server runs on a modern x86 Linux machine. That’s a huge gap, and most of the pain in this project comes straight out of it. We carry the DTLS 1.3 traffic over Ethernet using LiteX’s minimal UDP interface (udp_send(), udp_callback(), udp_service()), and all the handshake logic and crypto comes from WolfSSL and WolfCrypt, with the post-quantum parts being ML-KEM-1024 (KEM) and ML-DSA-87 (signatures). We measure handshake latency in CPU cycles, from ARP completion to DTLS 1.3 session establishment.
I’ve written up the reasoning behind this approach in Annexure 1.1.
# 3. PQC and Classical Algorithm Choices
The implementation uses the following cryptographic primitives:
- KEM: ML-KEM-1024 (Kyber)
- Digital Signatures: ML-DSA-87 (Dilithium Level 5)
- AEAD: ChaCha20–Poly1305
- Hash Function: SHA-256
Every number I quote below is for this configuration unless I say otherwise. Where it’s useful I’ve also thrown in results for the lower-cost variants (Kyber and Dilithium Level 1).
I go into detail on why we picked these in Annexure 1.2.
# 4. Firmware Design for RISC-V Bare-Metal
- Using ring buffers: We store incoming packets in a ring buffer through a custom receive callback. The callback writes each packet it gets into the buffer, and our own receive function — which wolfSSL calls internally — pulls the data back out when it needs it.
- Interrupt timers: We wrote our own interrupt timers, which turned out to be important for session resumption.
# 5. Integration of WolfSSL/WolfCrypt
We compiled wolfSSL straight from source instead of linking against a static libwolfssl.a. That let us strip the cipher suites we weren’t using at compile time with preprocessor directives, which cut the binary size down a lot.
The exact user_settings.h and the Makefile bits are in Annexure 1.3.
# 6. Challenges and Solutions
MTU Constraints: The 1500-byte MTU caused severe fragmentation for ML-KEM handshake messages. Solution: Enabled DTLS fragmentation and implemented timely ACK transmission to prevent server-side retransmissions.
Packet Loss Under Heavy Crypto Load: Early handshake packets (EncryptedExtensions) required expensive decryption, and with only two RX slots on the SoC, packets were dropped. Solution: Added a ring-buffered receive path to decouple packet arrival from decryption.
Optional solution: increase the nrxslots in the litex SoC.
Packet loss due to ARP: The UDP rxslots were filled with ARP packets which caused additional packet losses. Solution: Since udp_service internally calls for a pending read to the ethernet slots, we called this function at strategic places to ensure that our buffers are available for handshake packets.
Alignment Requirements: VexRiscV’s memory logic requires strict alignment; misalignment caused hard-to-trace failures. Solution: Applied WolfSSL alignment macros to all allocated buffers. (see
WOLFSSL_USE_ALIGN)Lack of RISC-V32 Optimisations: WolfSSL’s optimised assembly targets rv64 only and not rv32 which we had. Solution: Relied on compiler-based optimisations.
ACK Handling: With slower ACKs (due to large processing time in the client side), the server retransmitted most handshake data repeatedly, causing large delays. Solution: Issued ACK packets as soon as the receive buffer was processed, as permitted by the RFC.
Source Modifications: Due to some added flags in the WolfSSL build we had to define our own TimeNowInMilliseconds() function and make some modifications in the WolfSSL source code.
More on this in Annexure 1.6.
Parameter Tuning and minimising ROM: We systematically stripped the required macro list to reduce the compiled binary size while ensuring maximum throughput.
Open bug in wolfssl: KeyShare mismatch is not detected as wolfSSL does not raise errors for incorrect useKeyShare() configurations, causing handshakes to appear successful with invalid ML-KEM suite settings.
More of what bit us, and how we got around it, in Annexure 1.4.
# 7. Security Considerations
- Relay attacks: Since we’re implementing session resumption, it raises concerns for relay attacks where the PSK is spoofed and used within the TTL. To minimise this we’ve set the expiry time for session resumption at 5 minutes.
- Our use of ChaCha20 cipher stream allows the implementation to be resistant to timing attacks because all crypto algorithms used are just ARX.
- MLKEM’s underlying assumption is to keep secret the decapsulation key and shared secret key. Since these keys are not stored locally and are encrypted in epoch 2, the implementation is resistant to this attack vector.
# 8. Performance Metrics
The following is a measurement of the clock cycles, the CipherSuite specifies the MLKEM and MLDSA levels, the syntax being (MLKEM_version, MLDSA_version). The CPU cycles for the DTLS handshake are written as (establishment_cycles, resumption_cycles). The Handshake times are written for (establishment_time, resumption_time) in seconds.
| CipherSuite | Handshake cycles | Handshake time |
|---|---|---|
| (1024, 87) | (40,017,198; 8,147,943) | (0.400, 0.081) |
| (1024, 44) | (23,617,691; 8,049,218) | (0.236, 0.080) |
| (512, 87) | (39,449,096; 8,137,365) | (0.394, 0.081) |
| (512, 44) | (21,008,301; 6,989,774) | (0.210, 0.069) |
Table 1: Performance metrics for different cipher suites
We established a DTLS Handshake using the industry standard MLKEM1024 and MLDSA87 crypto suites using 40 mil CPU cycles for establishment and 8 mil CPU cycles for resumption on litex.
For comparison, a modern x86 machine without fpu takes 24 mil cycles for the latter.
Our best case was with MLKEM512 and MLDSA44 for which a handshake could be established using 21 mil CPU cycles and resumption in 7 mil CPU cycles.
- Max heap usage: 122kb
- Max binary size: 349kb
- Max throughput: 860 kbps (for a 100MHz setting in Litex with client sending to server)
- CPU cycles for Encryption and decryption: For decrypting 150 bytes of data on the client side, it takes approximately 38000 cycles and this value is roughly the same for encrypting the same 150 bytes (tested on MLKEM1024 and MLDSA87)
More graphs, and how we actually measured all of this, in Annexure 1.5.
# 9. Support for Session Resumption
Session resumption allowed us to reduce the handshake time by 60-70%. 0-RTT early data allowed user payload transmission during the initial ClientHello, eliminating one RTT when reconnecting.
Writing our own timing functions (we had to, because of NO_ASN_TIME) gave us stable ticket-age validation and stopped the session cache from being invalidated.
Setting WOLFSSL_DTLS13_NO_HRR_ON_RESUME on the server removed the HelloRetryRequest on compatible resumptions, which cut the overhead down further.
More on this in Annexure 1.6.
# 10. Low-Power RISC-V Optimisations
- Link-Time and Linker Optimisations: Enabled
-flto,-ffunction-sections, and-fdata-sectionsalong with-Osto allow whole-program optimisation and minimise binary size. - Low-Power Idle via
wfi: Used the RISC-Vwfiinstruction to gate the CPU clock
For details on compiler flags and memory access optimisations, please refer to Annexure 1.7.
# 11. Custom simulated TRNG
We built a lightweight entropy source into the simulation by combining an LFSR-based hardware stub with a ChaCha8-based CSPRNG in software. When called using a custom flag, the entropy source is used for the random number generation.
More detail on the simulated TRNG in Annexure 1.8.
# Annexures
# Annexure 1.1: Architecture and Design Details
Performance Asymmetry: The client is in a single-threaded environment with a clock frequency of about 100MHz. This makes it orders of magnitude slower than the server which is running on an X86 Linux machine. This was deliberately done to replicate a real world scenario.
That gap is a severe bottleneck during the DTLS 1.3 handshake, especially with post-quantum primitives in the mix. It meant we had to optimise the crypto routines and the network-processing path pretty aggressively, otherwise we’d hit retransmissions, watchdog resets and handshake timeouts.
RISC-V Toolchain: The bare-metal client firmware is built using a custom RV32IM toolchain configured with: ./configure --prefix=/opt/riscv --with-arch=rv32im --with-abi=ilp32. This configuration produces compact 32-bit binaries suited to the limited instruction set and memory footprint of the simulated soft-core.
The simulated TRNG is covered in Annexure 1.8.
# Annexure 1.2: Algorithm Selection Rationale
The cryptographic primitives used in this work are selected to comply with the emerging post-quantum security landscape defined by NIST. In particular, the Key Encapsulation Mechanism (KEM) must follow the NIST-standardised algorithms published in FIPS 203, “Module-Lattice-Based Key-Encapsulation Mechanism (ML-KEM)” (Federal Register Announcement). The ML-KEM family consists of three parameter sets (512, 768, and 1024), formally defined in Section 8 of the specification (FIPS 203 PDF). In addition, NIST has recently selected the HQC algorithm as a secondary or “fallback” post-quantum KEM (NIST Announcement), though ML-KEM remains the preferred primary mechanism for interoperability and compatibility within standardised protocols such as TLS/DTLS 1.3.
Among the available ML-KEM parameter sets, ML-KEM-1024 provides the highest security strength. Its larger keys and ciphertexts impose additional computational and network overhead, but the security justifies this choice for a DTLS 1.3 channel.
For authentication, we use the ML-DSA (Dilithium) family, standardised by NIST as the primary post-quantum signature scheme. The three security levels—ML-DSA-44 (Level 2), ML-DSA-65 (Level 3), and ML-DSA-87 (Level 5)—offer progressively stronger protection. We select ML-DSA-87 to maintain consistency with the high-security ML-KEM-1024 KEM. However, this choice results in substantially larger artifacts: the signatures (4627 bytes) significantly increase handshake message sizes. Consequently, DTLS 1.3 fragmentation and retransmission handling become essential to ensure handshake reliability on a constrained RISC-V client.
Additionally, we had the option of using Falcon instead of Dilithium but Falcon relies on floating point operations as well, hence we did not go ahead with that.
ChaCha20–Poly1305 is chosen as the AEAD cipher due to its efficiency on embedded and software-only environments. Unlike AES-GCM, which is competitive only when hardware acceleration is available, ChaCha20 exhibits uniform performance on low-frequency RISC-V cores. Its use reduces the overall handshake cycles by approximately half compared to AES-GCM.
SHA-256 remains the standard selection for hashing, supporting HKDF, transcript hashing, and integrity guarantees.
| Algorithm | Type | Rationale |
|---|---|---|
| ML-KEM-1024 | KEM | NIST-standardised (FIPS 203) highest-security parameter set; provides Level 5 protection. Larger key and ciphertext sizes (e.g., 1568-byte pk) increase fragmentation, but ensure long-term post-quantum confidentiality. |
| ML-DSA-87 | Signature | NIST-selected Level 5 signature scheme. Strongest security configuration (256-bit). Large artifacts (4.9 kB sk, 2.6 kB pk, 4.6 kB signatures) require careful DTLS 1.3 fragmentation and retransmission handling. |
| ChaCha20–Poly1305 | AEAD Cipher | Significantly faster than AES-GCM on software-only embedded CPUs; reduces per-record cycle cost by nearly half. Optimal choice for a RV32IM soft-core lacking AES acceleration. |
| SHA-256 | Hash | Widely standardised and required for DTLS 1.3 transcript hashing and HKDF. Lowest-complexity member of the SHA-2 family and fits comfortably in constrained RISC-V environments. |
Table 2: Selected Cryptographic Primitives and Rationale
# Annexure 1.3: WolfSSL Configuration (user_settings.h)
To integrate WolfSSL, we defined WOLFSSL_USER_SETTINGS in the Makefile and created the following configuration:
// Disabling all deprecated/vulnerable security systems
// #define WOLFSSL_SP_
extern int ;
We added the following sources in the Makefile to compile all the wolfssl/wolfcrypt source code and link it to our binary
SRCS +=
SRCS += #our src files
SRCS +=
This approach also allowed us to reduce the compiled binary size by simply deleting source files in wolfssl/wolfcrypt libraries which we did not use.
# Annexure 1.4: Additional Challenges and Solutions
Limited Ethernet Buffers: The Ethernet interface exposed only two RX buffers, which frequently became saturated with ARP packets. This prevented our application packets from being received in time. We resolved this by invoking
udp_service()at specific points in the control flow, ensuring timely buffer draining.wolfSSL Documentation Gaps: Several wolfSSL configuration macros lacked proper documentation, and the examples provided in both the website and the manual often conflicted with the actual source code. Key functions such as
LowResTimerandTimeNowInMillisecondswere referenced but never defined, leaving their implementation entirely to the user with no guidance. Critical flags were also undocumented; missing them caused internal wolfSSL functions to fail silently. These had to be discovered and enabled manually.LiteX Simulation Throughput Issues: The LiteX simulation environment is significantly slower than real hardware. As a result, our
udp_service()routine could not process more than two consecutive packets sent by the server. To mitigate this, we throttled the server’s transmission rate by inserting ausleep()delay into the packet-send path ininternal.c, allowing the simulated client enough time to drain buffers and maintain correctness.
# Annexure 1.5: Performance Metrics & Methodology
Here’s how we actually computed the metrics.
# Calculating CPU cycles
The 64-bit CPU cycle counter is read from the mcycle (lower 32 bits) and mcycleh (upper 32 bits) registers.
uint64_t
We calculate the difference in the cycle counts for three cases:
- The entire handshake (since the Client Hello to the session establishment)
- Processing individual PQ computation parts (during Client Hello and Server Hello)
- Processing back-and-forth encryption and decryption after session establishment
# Calculating Heap usage
WolfSSL allows us to pass in custom allocator functions via the use of wolfSSL_SetAllocators, passing in functions corresponding to malloc, free and realloc. In our case to measure the heap usage, our custom_malloc modifies a static variable called “current_heap” which is incremented by the size passed to the malloc on every call, thus effectively tracking the heap usage during the handshake. Similarly we implement the logic for free-s and realloc-s, and then we can track the heap usage throughout the program.
# Calculating throughput
We store the cpu cycle counter before the wolfSSL_write() function using the get_cpu_cycle(). We again find the cpu cycle counter after the function finishes. Now the difference between these values is the number of cycles taken to send a particular payload to the server.
We calculate the number of cycles taken for payloads of varying sizes from 1 byte to 300 bytes. When we plot this we approximately get a straight line with an offset (due to common computations being done there is an offset). We removed the offset to only account for the changes in the number of bytes transmitted. Now we divide this value by the frequency to find the time taken for a particular size of payload to be transmitted.
To find the throughput, we need to divide the size of payload by the time taken. We calculate this value which turns out to be approximately constant and take the average.
# Encryption-decryption cycle counts
To calculate the CPU cycles for encryption, we ran our benchmark for the udp_send() function and calculate the difference in this function. Another method to do this can be to add the benchmark for the specific ChaCha20-Poly1305 functions inside WolfSSL.
# Annexure 1.6: Session Resumption Implementation
Overview. Getting DTLS 1.3 session resumption and 0-RTT early data working on a constrained LiteX platform took a pile of build-time flags, our own timing implementations, and a few server-side adjustments. What we were after was lower reconnection latency, and being able to send data without redoing the whole handshake.
Client configuration. Session resumption required enabling session tickets and PSK support while avoiding flags that disable the cache. The client build defined: NO_SESSION_CACHE_REF, BUILD_TLS_PSK_WITH_CHACHA20_POLY1305_SHA256, and HAVE_SESSION_TICKET. Flags such as NO_SESSION_CACHE and NO_PSK were excluded to avoid disabling the resumption logic.
Effects of NO_ASN_TIME. Defining NO_ASN_TIME disables wolfSSL’s built-in time-handling paths. The library conditionally undefines session-ticket functionality or forces NO_SESSION_CACHE when this flag is present. These behaviours are not documented clearly and required commenting out in settings.h. With NO_ASN_TIME defined, the platform must supply its own timing functions:
TimeNowInMilliseconds()intls13.c// tls13.c // Return time in milliseconds sword64LowResTimer()ininternal.c// internal.c word32
Both were implemented using the RISC-V cycle counter at 100 MHz. Their accuracy directly affects ticket-age validation and session-ticket acceptance.
Session management API. Resumption used the modern session API:
wolfSSL_get1_session()to obtain a persistent session object.wolfSSL_set_session()to apply it to a new connection attempt.wolfSSL_SESSION_free()to release the object after use.
The session is saved at disconnect and reused at the next user-initiated reconnect. No modifications were required to the resumption logic inside wolfSSL.
Server build configuration. The wolfSSL server was built with DTLS 1.3, session tickets, PSKs, and early data enabled. Additionally, the server was compiled with:
-DWOLFSSL_DTLS13_NO_HRR_ON_RESUME
to suppress HelloRetryRequest on compatible resumptions. This reduces one round trip, provided that the client’s ClientHello fits both the PSK binder and a key-share without exceeding MTU. Larger KEM parameter sets may still force HRR due to fragmentation.
Ticket hint and age tolerance. The server validates the client’s ticket age against its own timing source. On constrained hardware with coarse timing resolution, this check frequently rejected valid resumptions. The tolerance window in internal.c was increased to match the configured ticket hint, preventing unnecessary failures. Improving the timing source is preferable, but widening the tolerance was sufficient for stable operation.
0-RTT early data. Both client and server enabled early data using WOLFSSL_EARLY_DATA. The client transmitted its payload using wolfSSL_write_early_data(), and the server processed it with wolfSSL_read_early_data() during the handshake progression. The server additionally set a maximum early-data size via wolfSSL_set_max_early_data() to avoid fragmenting the initial ClientHello.
Simulation constraints. LiteX’s simulation environment significantly slows packet processing. The DTLS pipeline could not consume packets fast enough unless application-level pacing was introduced. A temporary solution was adding a small usleep() delay on the server’s transmit path to prevent packet bursts from overwhelming the simulated client.
Result. With all that in place we got reliable PSK-based DTLS 1.3 resumption, early data worked, ticket-age handling stayed valid off our custom timing functions, and we avoided unnecessary HelloRetryRequests where we could.
# Annexure 1.7: Low Power Optimisations
Link-Time and Linker Optimisations: These flags place each function and zero-initialised object in its own section. Combined with
-Wl,--gc-sections, the linker can remove unused code and data.With LTO, GCC embeds intermediate representation (GIMPLE) in the object files. During linking, the linker invokes the compiler to perform cross-module inlining and dead-code elimination using global visibility over all translation units. Although this increases compile time and memory usage, the host system comfortably handles the overhead. The linker script finally merges all generated
.text.*and.bss.*sections into contiguous segments while retaining the size reductions achieved through dead-code pruning.Low-Power Idle via
wfi: We used the RISC-Vwfiinstruction to gate the CPU clock until an interrupt arrives, lowering power consumption during idle periods. Since the design does not benefit from an Ethernet interrupt handler and enabling one could increase latency due to ISR overhead, the system continues to poll for Ethernet events. A periodic timer interrupt is used instead to exitwfi.void
# Annexure 1.8: Custom Entropy (TRNG) Integration
We implemented a simulated TRNG in the hardware, which pulls out its value from a Linear Feedback Shift Register (LFSR). The shift register updates its values after each clock cycle of the processor according to the following implementation:
=
=
+=
// In main setup:
int
The LFSR is seeded with a constant seed in the hardware simulation. But in a practical system, the randomness is generated by natural noise. After each clock cycle it updates its internal values according to the above expression, the register values only repeat after an astronomically large number of clock cycles.
The value of this register can be accessed using the entropy_seed_read() function, which is generated in the generated/csr.h header during the simulation. The register value is used as the TRNG seed from the hardware. Then, the simulated TRNG seed is used to initialise ( and reinitialise ) ChaCha8, a Cryptographically Secure Random number Generator. For faster implementation, we buffer the value of ChaCha8 up to 16 values and then (after 16 cycles) update the internal state of ChaCha8, using the TRNG seed in the register at the next clock cycle.
ChaCha8 is implemented inside the function get_secure_random() and this function is called whenever the CustomRngGenerateBlock function is used by the wolfssl.
In a Real Hardware implementation we use a Ring Oscillator to provide the required Randomness.
