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:
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)