eculocate

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 3a0dbe5c907677ca5e8bda6c46ab4a0665fff985
parent 2f517e991d9f2a5ac1d6f8d37593bcb978d91c4b
Author: Daniel Barlow <dan@telent.net>
Date:   Fri, 23 Jan 2026 19:38:39 +0000

sign firmware with chained hashes

signing each block individually is slow (well, verifying is slow) and
doesn't protect against a MITM e.g. reordering or dropping
blocks. Instead we use the technique described in "How to Sign Digital
Streams (Gennaro, Rohatgi) where only the _first_ block is
cryptographically signed but each block contains a sha256 hash of its
successor block.

https://users.ece.cmu.edu/~adrian/731-sp04/readings/gr-stream-sig.pdf

Diffstat:
MCargo.lock | 1+
Mecu-esp32/Cargo.toml | 4++--
Mecu-esp32/src/main.rs | 5++++-
Mecu-esp32/src/wifi.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mnotes.md | 106++++++++++++-------------------------------------------------------------------
Motaclient.py | 61+++++++++++++++++++++++++++++++++++++++++++++----------------
6 files changed, 119 insertions(+), 129 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -384,6 +384,7 @@ dependencies = [ "esp-rtos", "esp-storage", "log", + "nb 1.1.0", "rand_core 0.6.4", "smoltcp", "static_cell", diff --git a/ecu-esp32/Cargo.toml b/ecu-esp32/Cargo.toml @@ -79,4 +79,5 @@ embedded-storage = "0.3.1" ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } -rand_core = "0.6" -\ No newline at end of file +rand_core = "0.6" +nb = "1.1.0" diff --git a/ecu-esp32/src/main.rs b/ecu-esp32/src/main.rs @@ -66,7 +66,10 @@ async fn main(s : Spawner) { ); join( - crate::wifi::start_wifi(&s, peripherals.WIFI, peripherals.FLASH, &mut ecu), + crate::wifi::start_wifi(&s, + peripherals.WIFI, + peripherals.FLASH, + peripherals.SHA, &mut ecu), async { loop { Timer::after_millis(1000).await; diff --git a/ecu-esp32/src/wifi.rs b/ecu-esp32/src/wifi.rs @@ -6,6 +6,7 @@ use alloc::vec::Vec; use alloc::string::String; use alloc::format; +use nb::block; use ed25519_dalek::{VerifyingKey, Verifier, Signature}; @@ -36,6 +37,8 @@ use esp_radio::{ }, }; use esp_hal_ota::Ota; +use esp_hal::sha; + use esp_storage::FlashStorage; use embassy_net::Stack; @@ -94,6 +97,7 @@ fn dns_records(my_ip_address: [u8;4], serial: Option<usize>) -> Vec<mdns::Resour pub async fn start_wifi(spawner: &Spawner, wifi_peripheral: esp_hal::peripherals::WIFI<'static>, flash_peripheral: esp_hal::peripherals::FLASH<'static>, + sha_peripheral: esp_hal::peripherals::SHA<'static>, ecu: &mut crate::ecu::Ecu<'_>) { @@ -145,7 +149,7 @@ pub async fn start_wifi(spawner: &Spawner, join( join( crate::streamer::loop_udp_thing(stack, ecu), - run_tcp_handler(stack, flash_peripheral), + run_tcp_handler(stack, sha_peripheral, flash_peripheral), ), async { loop { @@ -240,24 +244,36 @@ async fn read_all(socket: &mut embassy_net::tcp::TcpSocket<'_>, const ERASE_BLOCK_BYTES : usize = 4096; const ED25519_SIGNATURE_BYTES : usize = 64; +const SHA256_BYTES : usize = 32; async fn write_ota(ota : &mut Ota<FlashStorage<'_>>, + sha_engine : &mut sha::Sha<'_>, sock: &mut embassy_net::tcp::TcpSocket<'_>) { - let mut buf = [0u8; ERASE_BLOCK_BYTES + ED25519_SIGNATURE_BYTES]; + let mut buf = [0u8; SHA256_BYTES + ERASE_BLOCK_BYTES]; let mut buf_offset = 0; - let mut signed = true; - - let len = { - let mut len_bytes = [0u8; 4]; - if read_all(sock, &mut len_bytes, 4).await == 4 { - u32::from_be_bytes(len_bytes) - } else { - 0 - } - }; + let mut signed; + let mut signature = [0u8; ED25519_SIGNATURE_BYTES ]; + let mut next_block_hash = [0u8; SHA256_BYTES]; + + // ed25519 signature of first block + // first block is sha256 of second block, plus length as u32 + // second+ block is sha256 of successor block, plus data + + let mut block0 = [0u8; SHA256_BYTES + 4]; + + read_all(sock, &mut signature, ED25519_SIGNATURE_BYTES).await; + read_all(sock, &mut block0, SHA256_BYTES + 4).await; let verifying_key_bytes = include_bytes!("../../pubkey.bin"); let verifying_key: VerifyingKey = VerifyingKey::from_bytes(verifying_key_bytes).expect("verify"); + signed = verifying_key.verify(&mut block0, + &Signature::try_from(&signature).expect("sig")).is_ok(); + + info!("initial sig checks out? {:?}", signed); + + let (sha, len_bytes) = block0.split_at(SHA256_BYTES); + let len = u32::from_be_bytes(len_bytes.try_into().expect("4 bytes")); + next_block_hash.clone_from_slice(sha); info!("ota length {}", len); if let Err(e) = ota.ota_begin(len, 0) { @@ -265,7 +281,9 @@ async fn write_ota(ota : &mut Ota<FlashStorage<'_>>, return; }; - let mut remaining = len; + // total payload to expect is image size plus per-block hashes + let mut remaining = len + (len/(ERASE_BLOCK_BYTES as u32)+ 1) * (SHA256_BYTES as u32); + while remaining > 0 { // info!("remaining {}, offset {}", remaining, buf_offset); match sock.read(&mut buf[buf_offset..]).await { @@ -274,7 +292,6 @@ async fn write_ota(ota : &mut Ota<FlashStorage<'_>>, break; } Ok(chunksize) => { - // info!("read {} at {}, {} remaining", chunksize, buf_offset, remaining); buf_offset += chunksize; remaining -= chunksize as u32; } @@ -284,10 +301,19 @@ async fn write_ota(ota : &mut Ota<FlashStorage<'_>>, } } - if remaining <= 0 || buf_offset == ED25519_SIGNATURE_BYTES + ERASE_BLOCK_BYTES { - let (sig, chunk) = buf.split_at(ED25519_SIGNATURE_BYTES); - let signature: Signature = Signature::try_from(sig).expect("sig"); - signed = signed && verifying_key.verify(chunk, &signature).is_ok(); + if remaining <= 0 || buf_offset == SHA256_BYTES + ERASE_BLOCK_BYTES { + let mut actual_hash = [0u8; SHA256_BYTES]; + let mut hasher = sha_engine.start::<sha::Sha256>(); + let mut p = &buf[..buf_offset]; + while !p.is_empty() { + match block!(hasher.update(p)) { + Ok(remaining) => { p = remaining; } + _ => () + } + } + block!(hasher.finish(&mut actual_hash)); + + signed = signed && next_block_hash == actual_hash; if signed { if let Err(_) = ota.ota_write_chunk(&buf[ED25519_SIGNATURE_BYTES..buf_offset]) { @@ -297,7 +323,9 @@ async fn write_ota(ota : &mut Ota<FlashStorage<'_>>, // accept the rest of the update, just don't flash it. warn!("signature invalid with {remaining} left"); } + // buf_offset = 0; + next_block_hash.copy_from_slice(&buf[0..SHA256_BYTES]); } } if remaining == 0 { @@ -320,11 +348,14 @@ fn register_session_key(key : &[u8]) { } -async fn run_tcp_handler(stack : Stack<'_>, flash_peripheral: esp_hal::peripherals::FLASH<'static>) { +async fn run_tcp_handler(stack : Stack<'_>, + sha_peripheral: esp_hal::peripherals::SHA<'static>, + flash_peripheral: esp_hal::peripherals::FLASH<'static>) { let mut rx_buffer = [0; 4096]; let mut tx_buffer = [0; 4096]; let mut buf = [0u8; 96]; let flash = FlashStorage::new(flash_peripheral); + let mut sha_engine = sha::Sha::new(sha_peripheral); let mut ota = Ota::new(flash).unwrap(); @@ -346,7 +377,7 @@ async fn run_tcp_handler(stack : Stack<'_>, flash_peripheral: esp_hal::periphera match &buf[0..4] { b"ROM0" => { info!("OTA request"); - write_ota(&mut ota, &mut socket).await; + write_ota(&mut ota, &mut sha_engine, &mut socket).await; }, b"KEY0" => { info!("sessionkey"); diff --git a/notes.md b/notes.md @@ -126,97 +126,23 @@ according to https://forum.pgmfi.org/viewtopic.php?p=140964#p140964 table 0 is "ecmid" -# TODO - -* OTA updates so that we can run it without a USB connection - - - over wifi, using tcp - - perhaps just add a minimal http server that accepts a suitably authenticated POST request - - this would be simple to automate as well as not impossible to design into a - client app - - what does authentication look like? - -we can generate a ECDSA key pair at build time. embed the pub key in the binary -and sign the firmware with the private key. use the "ring" library to verify the -signature of anything that's http uploaded - -we probably don't want to mdns-advertise the http server (what would -be the point?) . just run it on our ip address at 5001 - - - - -* wifi provisioning over ble - -[done] * ecu needs to timestamp the table reads before sending. - -* add the 12v->5v buck converter, make a cable to plug into diag port - -* reverse-engineer the mystery values - -* add an IMU to get lean angles - -* some kind of security for the udp stream - -could we use the firmware keypair somehow? if the client has the -private key, it can send an http request containing a proposed session -key which the esp32 verifies, then it can use that session key when it -sends subscription requests - -do we need to bulk out the request so that we have something big enought sign? -https://stackoverflow.com/questions/4676828/when-generating-a-sha256-512-hash-is-there-a-minimum-safe-amount-of-data-to thinks not - - -if we do it like this then we probably _should_ advertise the http -service +# TODO -does it need to be http or could we just do tcp streams? there's -https://crates.io/crates/httparse - -the only two things we need to send are session keys and firmware - -0: "ROM0" -4: eddsa sig -68: length of payload -72: .........payload....... - - -0: "KEY0" -4: eddsa sig -68: proposed session key (32 bytes) -100: ...ends - - -the 0 in KEY0/ROM0 is a version number, in case we want future -versions of eculocate to accept different formats for these things - - -the signature also means we know the data is intact/untampered - - - -<ecdsa> -nnnn0765f6ac86e65e87a76d65f5c\0 - -ROMnnnn765d76e0765f............6ac86e7a765f5c\0<ecdsa> - -it might be better to send strings with leading length instead of using \0 -for end, because then we don't need to ascii-encode. esp32-c3 can support up to -16MB (2^24) of flash, so a 32 bit length will be plenty - - -### TODO - -- make a TCP listener -- parse tcp messages to extract command/signature/payload +X make a TCP listener +X parse tcp messages to extract command/signature/payload - discard signature -- implement flashing support : https://docs.rs/esp-hal-ota/latest/esp_hal_ota/ https://github.com/filipton/esp-hal-ota -- add ring crate -- create an eddsa keypair with openssl -- read the pubkey into the rust binary -- sign something with the key using openssl -- as PoC, add the signed thing into the rust binary and verify the signature at startup -- verify signature before flashing -- maintain a hash of session keys and expire them eventually +X implement flashing support : https://docs.rs/esp-hal-ota/latest/esp_hal_ota/ https://github.com/filipton/esp-hal-ota +O add ring crate +X create an eddsa keypair with openssl +X read the pubkey into the rust binary +X sign something with the key using openssl +X as PoC, add the signed thing into the rust binary and verify the signature at startup +X verify signature before flashing +- maintain a hash of session keys for udp listener and expire them eventually - update the subscription request parsing to expect a session key, validate it +- wifi provisioning over ble +- add the 12v->5v buck converter, make a cable to plug into diag port +- ride +- reverse-engineer the mystery values +- add an IMU to get lean angles diff --git a/otaclient.py b/otaclient.py @@ -3,36 +3,65 @@ import struct import sys import nacl import os +import nacl.hash from nacl.signing import SigningKey from nacl.encoding import RawEncoder +HASHER = nacl.hash.sha256 + +BLOCK_SIZE=4096 + with open("privkey.bin", "rb") as f: signing_key = SigningKey(f.read(), encoder=RawEncoder) -sock = socket.create_connection(("eculocate.local", 5000)) +PEER=("eculocate.local", 5000) +# PEER=("localhost",5000) +sock = socket.create_connection(PEER) with open(sys.argv[1], "rb") as f: rom_image = f.read() -image_len = len(rom_image) -print(f"raw {image_len}") -# 64 byte signature needed for each 4096 byte block -send_len = image_len + (1+int(image_len/4096)) * 64 -print(f"expanded {send_len}") +def data_for_block(n): + # block 0 is header information, so actual data blocks start at 1 + return rom_image[(n-1)*BLOCK_SIZE : n*BLOCK_SIZE] + +# suppose 102 blocks + +# 100: hash(hash(<00000> + data102) + data101) + data100 +# 101: hash(<00000> + data102) + data101 +# 102: <00000> + data102 + +def sha256sum(bytes): + return HASHER(bytes, encoder=RawEncoder) + +def hash_for_block(n): + if (n-1)*BLOCK_SIZE < len(rom_image): + data = linked_block(n) + return sha256sum(data) + else: + return b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0" + +def linked_block(n): + if n==0: + return hash_for_block(1) + struct.pack(">L", len(rom_image)) + else: + return hash_for_block(n+1) + data_for_block(n) + +sig = signing_key.sign(linked_block(0), encoder=RawEncoder) +sock.send(b"ROM0" + sig.signature) + +def hexy(h): + return "".join("{:02x} ".format(x) for x in bytearray(h)) -header = ( - b"ROM0" + struct.pack(">L", send_len) -) +def ddec(h): + return "".join("{:02d} ".format(x) for x in bytearray(h)) -sock.send(header) +for i in range(0, 2 + len(rom_image)//BLOCK_SIZE): + h = hash_for_block(i) + print(f"block {i} {len(data_for_block(i))}") + sock.send(linked_block(i)) -for i in range(0, image_len, 4096): - chunk = rom_image[i : i + 4096] - sig = signing_key.sign(chunk, encoder=RawEncoder) - print(f"offset {i}") - sock.send(sig.signature) - sock.send(chunk) # Read a line from the socket (until newline)