From d084137b77fd0c6195ef1c0d8b4a2d5c63bbb8a2 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 19 Feb 2026 09:40:10 +1100 Subject: [PATCH 1/4] OTA TLV definition and tests --- .cargo/config.toml | 3 + Cargo.lock | 108 ++++++++++++++ ota/Cargo.toml | 9 ++ ota/src/lib.rs | 228 +++++++++++++++++++++++++++- ota/src/tlv.rs | 365 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 ota/src/tlv.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index b46cbe8..5389a66 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -28,6 +28,9 @@ run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf --no-default- run-esp32s2 = "run --profile esp32s2 --target xtensa-esp32s2-none-elf --no-default-features --features esp32s2 " run-esp32s3 = "run --release --target xtensa-esp32s3-none-elf --no-default-features --features esp32s3 " +# Test alias +test-ota = "test --package ota --target x86_64-unknown-linux-gnu" + [target.xtensa-esp32-none-elf] runner = "espflash flash --baud=921600 --monitor --chip esp32" rustflags = ["-C", "link-arg=-nostartfiles", '--cfg=feature="esp32"'] diff --git a/Cargo.lock b/Cargo.lock index 5c79906..1bd82e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,56 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -158,6 +208,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const-default" version = "1.0.0" @@ -1326,6 +1409,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -1467,6 +1556,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1476,6 +1571,13 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "ota" version = "0.1.0" +dependencies = [ + "clap", + "log", + "sha2", + "sunset", + "sunset-async", +] [[package]] name = "paste" @@ -2155,6 +2257,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vcell" version = "0.1.3" diff --git a/ota/Cargo.toml b/ota/Cargo.toml index c2f3a35..ab7cca3 100644 --- a/ota/Cargo.toml +++ b/ota/Cargo.toml @@ -8,3 +8,12 @@ default = [] std = [] [dependencies] +sunset.workspace = true +sunset-async.workspace = true + +log.workspace = true +sha2.workspace = true + +# Only for helpers and tests +[target.'cfg(not(target_os = "none"))'.dependencies] +clap = "4.5" diff --git a/ota/src/lib.rs b/ota/src/lib.rs index a26ba9a..7e67638 100644 --- a/ota/src/lib.rs +++ b/ota/src/lib.rs @@ -1,4 +1,230 @@ +#![cfg_attr(not(test), no_std)] // SPDX-FileCopyrightText: 2025 Roman Valls, 2025 // // SPDX-License-Identifier: GPL-3.0-or-later -#![no_std] + +/// Module defining TLV types and constants for OTA updates +/// +/// Re-exporting this module for easier access from outside the crate: ota-packer +pub mod tlv; + +/// OTA Header structure and deserialization logic +/// +/// Re-exporting Header for easier access from outside the crate: ota-packer +pub use tlv::OtaHeader; + +#[cfg(test)] +mod ota_tlv_tests { + + use crate::OtaHeader; + use crate::tlv::*; + use sunset::sshwire; + + #[test] + fn test_ota_tlv_round_trip() { + let variants = [ + Tlv::FirmwareBlob { size: 1024 }, + Tlv::Sha256Checksum { + checksum: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }, + Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }, + ]; + for variant in variants.iter() { + let mut buffer = [0u8; MAX_TLV_SIZE as usize]; + let used = sshwire::write_ssh(&mut buffer, variant).expect("Failed to create SSH sink"); + + let decoded = + sshwire::read_ssh::(&buffer[..used], None).expect("Failed to decode TLV"); + match (variant, decoded) { + (Tlv::FirmwareBlob { size: s1 }, Tlv::FirmwareBlob { size: s2 }) => { + assert_eq!(s1, &s2); + } + (Tlv::Sha256Checksum { checksum: c1 }, Tlv::Sha256Checksum { checksum: c2 }) => { + assert_eq!(c1, &c2); + } + (Tlv::OtaType { ota_type: o1 }, Tlv::OtaType { ota_type: o2 }) => { + assert_eq!(o1, &o2); + } + _ => panic!("Decoded variant does not match original"), + } + } + } + + #[test] + fn deserializing_full_header() { + let mut buffer = [0u8; 512]; + let mut offset = 0; + + let ota_type_tlv = Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_type_tlv) + .expect("Failed to write OTA Type TLV"); + + let ota_checksum = Tlv::Sha256Checksum { + checksum: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }; + + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_checksum) + .expect("Failed to write SHA256 Checksum TLV"); + + let firmware_blob_tlv = Tlv::FirmwareBlob { size: 2048 }; + offset += sshwire::write_ssh(&mut buffer[offset..], &firmware_blob_tlv) + .expect("Failed to write Firmware Blob TLV"); + + let (header, _) = + OtaHeader::deserialize(&buffer[..offset]).expect("Failed to deserialize header"); + + assert_eq!(header.ota_type, Some(OTA_TYPE_VALUE_SSH_STAMP)); + assert_eq!(header.firmware_blob_size, Some(2048)); + assert_eq!( + header.sha256_checksum, + Some([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]) + ); + } + + #[test] + fn tlvs_after_firmware_blob_are_ignored() { + let mut buffer = [0u8; 512]; + let mut offset = 0; + + let ota_type_tlv = Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_type_tlv) + .expect("Failed to write OTA Type TLV"); + + let firmware_blob_tlv = Tlv::FirmwareBlob { size: 2048 }; + offset += sshwire::write_ssh(&mut buffer[offset..], &firmware_blob_tlv) + .expect("Failed to write Firmware Blob TLV"); + + // After firmware_blob. Will not be deserialised + let ota_checksum = Tlv::Sha256Checksum { + checksum: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_checksum) + .expect("Failed to write SHA256 Checksum TLV"); + + let (header, _) = + OtaHeader::deserialize(&buffer[..offset]).expect("Failed to deserialize header"); + + assert_eq!(header.ota_type, Some(OTA_TYPE_VALUE_SSH_STAMP)); + assert_eq!(header.firmware_blob_size, Some(2048)); + assert_eq!(header.sha256_checksum, None); + } + + #[test] + fn ota_type_must_be_first_tlv() { + let mut buffer = [0u8; 512]; + let mut offset = 0; + + let ota_checksum = Tlv::Sha256Checksum { + checksum: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }; + + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_checksum) + .expect("Failed to write SHA256 Checksum TLV"); + + let ota_type = Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_type) + .expect("Failed to write OTA Type TLV"); + + let firmware_blob_tlv = Tlv::FirmwareBlob { size: 2048 }; + offset += sshwire::write_ssh(&mut buffer[offset..], &firmware_blob_tlv) + .expect("Failed to write Firmware Blob TLV"); + + assert!(OtaHeader::deserialize(&buffer[..offset]).is_err()); + } + + #[test] + fn deserializing_header_missing_firmware_blob() { + let mut buffer = [0u8; 512]; + let mut offset = 0; + + let ota_type_tlv = Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_type_tlv) + .expect("Failed to write OTA Type TLV"); + + let ota_checksum = Tlv::Sha256Checksum { + checksum: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }; + + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_checksum) + .expect("Failed to write SHA256 Checksum TLV"); + + let (header, _) = + OtaHeader::deserialize(&buffer[..offset]).expect("Failed to deserialize header"); + + assert_eq!(header.ota_type, Some(OTA_TYPE_VALUE_SSH_STAMP)); + assert_eq!(header.firmware_blob_size, None); + assert_eq!( + header.sha256_checksum, + Some([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, 32, + ]) + ); + } + + #[test] + fn skipping_unknown_tlv() { + let mut buffer = [0u8; 512]; + let mut offset = 0; + + let ota_type_tlv = Tlv::OtaType { + ota_type: OTA_TYPE_VALUE_SSH_STAMP, + }; + offset += sshwire::write_ssh(&mut buffer[offset..], &ota_type_tlv) + .expect("Failed to write OTA Type TLV"); + + // Manually generating a valid unknown type + let unknown_type: OtaTlvType = 99; + let unknown_type_len: OtaTlvLen = 4; + let unknown_value: [u8; 4] = [10, 20, 30, 40]; + + offset += sshwire::write_ssh(&mut buffer[offset..], &unknown_type) + .expect("Failed to write unknown TLV type"); + offset += sshwire::write_ssh(&mut buffer[offset..], &unknown_type_len) + .expect("Failed to write unknown TLV length"); + offset += sshwire::write_ssh(&mut buffer[offset..], &unknown_value) + .expect("Failed to write unknown TLV value"); + + let firmware_blob_tlv = Tlv::FirmwareBlob { size: 2048 }; + let used = sshwire::write_ssh(&mut buffer[offset..], &firmware_blob_tlv) + .expect("Failed to write Firmware Blob TLV"); + offset += used; + + let (header, _) = + OtaHeader::deserialize(&buffer[..offset]).expect("Failed to deserialize header"); + + assert_eq!(header.ota_type, Some(OTA_TYPE_VALUE_SSH_STAMP)); + assert_eq!(header.firmware_blob_size, Some(2048)); + assert_eq!(header.sha256_checksum, None); + } + + // TODO: Test more error cases, such as incomplete TLVs +} diff --git a/ota/src/tlv.rs b/ota/src/tlv.rs new file mode 100644 index 0000000..64c1928 --- /dev/null +++ b/ota/src/tlv.rs @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: 2025 Roman Valls, 2025 +// +// SPDX-License-Identifier: GPL-3.0-or-later + +/// Module to define the tlv types for OTA metadata +/// +/// Unsophisticated implementation of the ssh-stamp OTA TLV types using sshwire traits. +/// +/// If you are looking into improving this, consider looking into [proto.rs](https://github.com/mkj/sunset/blob/8e5d20916cf7b29111b90e4d3b7bb7827c9be8e5/sftp/src/proto.rs) +/// for an example on how to automate the generation of protocols with macros +use log::{debug, error, info, warn}; +use sunset::sshwire::{SSHDecode, SSHEncode, SSHSource, WireError}; + +use crate::tlv; + +/// Type alias for OTA TLV type: The type field in the TLV structure will be an u8 +pub type OtaTlvType = u8; +/// Type alias for OTA TLV length: The length field in the TLV structure will be an u8 +pub type OtaTlvLen = u8; + +// TODO: We could provide a new type for better debugging information +pub const OTA_TYPE_VALUE_SSH_STAMP: u32 = 0x73736873; // 'sshs' big endian in ASCII + +pub const CHECKSUM_LEN: u32 = 32; +/// Maximum size for LTV (Length-Type-Value) entries in OTA metadata. Used during the reading of OTA parameters. +pub const MAX_TLV_SIZE: u32 = (core::mem::size_of::() + + core::mem::size_of::() + + u8::MAX as usize) as u32; // type + length + value + +/// Encodes the length and value of a sized values +fn enc_len_val( + value: &SE, + s: &mut dyn sunset::sshwire::SSHSink, +) -> sunset::sshwire::WireResult<()> +where + SE: Sized + SSHEncode, +{ + (core::mem::size_of::() as OtaTlvLen).enc(s)?; + value.enc(s) +} + +/// Decodes and checks that the length of the value matches the expected size +/// +/// Call it before decoding the actual value for simple types +fn dec_check_val_len<'de, S, SE>(s: &mut S) -> sunset::sshwire::WireResult<()> +where + S: sunset::sshwire::SSHSource<'de>, + SE: Sized, +{ + let val_len = OtaTlvLen::dec(s)?; + if val_len != (core::mem::size_of::() as OtaTlvLen) { + return Err(sunset::sshwire::WireError::PacketWrong); + } + Ok(()) +} + +// OTA TLV type defined values + +pub const OTA_TYPE: OtaTlvType = 0; +pub const FIRMWARE_BLOB: OtaTlvType = 1; +pub const SHA256_CHECKSUM: OtaTlvType = 2; + +/// OTA_TLV enum for OTA metadata LTV entries +/// This TLV does not capture length as it will be captured during parsing +/// Parsing will be done using sshwire types +#[derive(Debug)] +#[repr(u8)] // Must match the type of OtaTlvType +pub enum Tlv { + /// Type of OTA update. This **MUST be the first Tlv**. + /// For SSH Stamp, this must be OTA_FIRMWARE_BLOB_TYPE + OtaType { ota_type: u32 }, + /// Expected SHA256 checksum of the firmware blob + Sha256Checksum { + checksum: [u8; CHECKSUM_LEN as usize], + }, + /// Contains the length in bytes of the firmware blob. + /// The firmware blob follows immediately after this TLV. + /// + /// This **MUST be the last Tlv**. What follows is the firmware blob. the length of the blob is the payload value. + FirmwareBlob { size: u32 }, +} + +impl SSHEncode for Tlv { + fn enc(&self, s: &mut dyn sunset::sshwire::SSHSink) -> sunset::sshwire::WireResult<()> { + match self { + Tlv::OtaType { ota_type } => { + OTA_TYPE.enc(s)?; + enc_len_val(ota_type, s) + } + Tlv::FirmwareBlob { size } => { + FIRMWARE_BLOB.enc(s)?; + enc_len_val(size, s) + } + Tlv::Sha256Checksum { checksum } => { + SHA256_CHECKSUM.enc(s)?; + enc_len_val(checksum, s) + } + } + } +} + +impl<'de> SSHDecode<'de> for Tlv { + fn dec(s: &mut S) -> sunset::sshwire::WireResult + where + S: sunset::sshwire::SSHSource<'de>, + { + OtaTlvType::dec(s).and_then(|tlv_type| match tlv_type { + FIRMWARE_BLOB => { + dec_check_val_len::(s)?; + Ok(Tlv::FirmwareBlob { size: u32::dec(s)? }) + } + SHA256_CHECKSUM => { + if OtaTlvLen::dec(s)? != tlv::CHECKSUM_LEN as u8 { + return Err(sunset::sshwire::WireError::PacketWrong); + } + let mut checksum = [0u8; tlv::CHECKSUM_LEN as usize]; + checksum.iter_mut().for_each(|element| { + *element = u8::dec(s).unwrap_or(0); + }); + Ok(Tlv::Sha256Checksum { checksum }) + } + OTA_TYPE => { + dec_check_val_len::(s)?; + let ota_type = u32::dec(s)?; + Ok(Tlv::OtaType { ota_type }) + } + // To handle unknown TLVs, it consumes the announced len + // and returns an UnknownVariant error + _ => { + warn!("Unknown TLV type encountered: {}. Skipping it", tlv_type); + let len = OtaTlvLen::dec(s)?; + s.take(len as usize)?; // Skip unknown TLV value + Err(sunset::sshwire::WireError::UnknownPacket { number: tlv_type }) + } + }) + } +} + +/// An implementation of SSHSource based on [[sunset::sshwire::DecodeBytes]] +/// +pub struct TlvsSource<'a> { + remaining_buf: &'a [u8], + ctx: sunset::packets::ParseContext, + used: usize, +} + +impl<'a> TlvsSource<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { + remaining_buf: buf, + ctx: sunset::packets::ParseContext::default(), + used: 0, + } + } + + pub fn used(&self) -> usize { + self.used + } + /// Puts bytes in the tlv_holder and updates current_len until an OTA TLV enum variant can be decoded + /// + /// Even if it fails, it adds bytes to the tlv_holder and updates current_len accordingly + /// so more data can be added later to complete the TLV + /// + /// If more data is required, it returns WireError::RanOut + /// If successful, it returns Ok(()) and a dec + // TODO: Add test for RanOut and acomplete TLV + pub fn try_taking_bytes_for_tlv( + &mut self, + tlv_holder: &mut [u8], + current_len: &mut usize, + ) -> Result<(), WireError> { + if *current_len + < core::mem::size_of::() + core::mem::size_of::() + { + let needed = core::mem::size_of::() + + core::mem::size_of::() + - *current_len; + debug!("Adding {} bytes to have up to TLV type and length", needed); + let to_read = core::cmp::min(needed, self.remaining()); + let type_len_bytes = self.take(to_read)?; + tlv_holder[*current_len..*current_len + to_read].copy_from_slice(type_len_bytes); + *current_len += to_read; + if needed < to_read { + info!("Will get more data to complete TLV type/length"); + return Err(WireError::RanOut); + } + } + + let slice_len_start = core::mem::size_of::(); + let slice_value_start = + core::mem::size_of::() + core::mem::size_of::(); + if *current_len >= slice_value_start { + // try reading bytes to complete the value + let val_len = tlv::OtaTlvLen::from_be_bytes( + tlv_holder[slice_len_start..slice_value_start] + .try_into() + .unwrap(), + ) as usize; + debug!( + "value length: {}, Source remaining bytes: {}", + val_len, + self.remaining() + ); + + let needed = val_len + slice_value_start - *current_len; + let to_read = needed.min(self.remaining()); + + let needed_type_len_bytes = self.take(to_read)?; + tlv_holder[*current_len..*current_len + to_read].copy_from_slice(needed_type_len_bytes); + *current_len += to_read; + if needed < to_read { + info!("Will get more data to complete TLV type/length"); + return Err(WireError::RanOut); + } + } + Ok(()) + } +} + +impl<'de> SSHSource<'de> for TlvsSource<'de> { + fn take(&mut self, len: usize) -> sunset::sshwire::WireResult<&'de [u8]> { + if len > self.remaining_buf.len() { + return Err(sunset::sshwire::WireError::RanOut); + } + let t; + (t, self.remaining_buf) = self.remaining_buf.split_at(len); + self.used += len; + Ok(t) + } + + fn remaining(&self) -> usize { + self.remaining_buf.len() + } + + fn ctx(&mut self) -> &mut sunset::packets::ParseContext { + &mut self.ctx + } +} + +/// Header struct for OTA file header processing +/// +/// This struct holds the metadata that will be used to validate the OTA file prior to applying the update. +/// +/// The fields serialisation and deserialization +#[derive(Debug)] +pub struct OtaHeader { + // Not part of the header data + // hasher: sha2::Sha256, + /// Type of OTA update being processed. Used for screening incorrect ota blobs quickly + pub(crate) ota_type: Option, + /// Total size of the firmware being downloaded, if known + pub(crate) firmware_blob_size: Option, + /// Expected sha256 checksum of the firmware, if provided + pub sha256_checksum: Option<[u8; tlv::CHECKSUM_LEN as usize]>, +} + +impl OtaHeader { + /// Creates a new OTA header with the provided parameters + /// + /// Used during packing of OTA files. Therefore, not needed in the embedded side. + #[cfg(not(target_os = "none"))] + pub fn new(ota_type: u32, sha256_checksum: &[u8], firmware_blob_size: u32) -> Self { + // TODO: Check that the sha256_checksum length is correct: 32 bytes + let mut checksum_array = [0u8; tlv::CHECKSUM_LEN as usize]; + checksum_array.copy_from_slice(sha256_checksum); + Self { + ota_type: Some(ota_type), + firmware_blob_size: Some(firmware_blob_size), + sha256_checksum: Some(checksum_array), + } + } + + /// Serializes the OTA header into the provided buffer + /// + /// Returns the number of bytes written to the buffer + // #[cfg(not(target_os = "none"))] // Maybe I should remove this from embedded side as well + pub fn serialize(&self, buf: &mut [u8]) -> usize { + let mut offset = 0; + if let Some(ota_type) = self.ota_type { + let tlv = tlv::Tlv::OtaType { ota_type }; + let used = sunset::sshwire::write_ssh(&mut buf[offset..], &tlv) + .expect("Failed to serialize OTA Type TLV"); + offset += used; + } + if let Some(checksum) = &self.sha256_checksum { + let tlv = tlv::Tlv::Sha256Checksum { + checksum: *checksum, + }; + let used = sunset::sshwire::write_ssh(&mut buf[offset..], &tlv) + .expect("Failed to serialize SHA256 Checksum TLV"); + offset += used; + } + if let Some(size) = self.firmware_blob_size { + let tlv = tlv::Tlv::FirmwareBlob { size }; + let used = sunset::sshwire::write_ssh(&mut buf[offset..], &tlv) + .expect("Failed to serialize Firmware Blob TLV"); + offset += used; + } + offset + } + + /// Deserializes an OTA header from the provided buffer + /// + /// This approach requires that the whole header is contained in the buffer. An incomplete + /// header will result in unpopulated fields. + pub fn deserialize(buf: &[u8]) -> Result<(Self, usize), sunset::sshwire::WireError> { + let mut source = tlv::TlvsSource::new(buf); + let mut ota_type = None; + let mut firmware_blob_size = None; + let mut sha256_checksum = None; + + while source.remaining() > 0 { + match tlv::Tlv::dec(&mut source) { + Err(sunset::sshwire::WireError::UnknownPacket { number }) => { + warn!( + "Unknown packet type encountered: {}. TLV skipping it and continuing", + number + ); + // Unknown TLV was skipped already in the decoder + continue; + } + Err(e) => { + return Err(e); + } + Ok(tlv) => { + match tlv { + tlv::Tlv::OtaType { ota_type: ot } => { + ota_type = Some(ot); + } + tlv::Tlv::Sha256Checksum { checksum } => { + Self::check_ota_is_first_tlv(ota_type)?; + sha256_checksum = Some(checksum); + } + tlv::Tlv::FirmwareBlob { size } => { + Self::check_ota_is_first_tlv(ota_type)?; + firmware_blob_size = Some(size); + // After firmware blob, there shall be no more tlvs and the + // actual blob follows. Therefore we stop reading here + break; + } + } + } + } + } + + Ok(( + Self { + ota_type, + firmware_blob_size, + sha256_checksum, + }, + source.used(), + )) + } + + fn check_ota_is_first_tlv(ota_type: Option) -> Result<(), WireError> { + match ota_type.is_none() { + true => { + error!("SHA256 Checksum TLV encountered before OTA Type TLV. Ignoring it"); + Err(sunset::sshwire::WireError::PacketWrong) + } + false => Ok(()), + } + } +} From ada9f6187b5400dbf139ed88697c7ca3418b0168 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 19 Feb 2026 09:41:16 +1100 Subject: [PATCH 2/4] ota-packer utility This will be used to pack binary files before uploading via sftp. Read the README.md!! --- .cargo/config.toml | 4 + ota/Cargo.toml | 4 + ota/src/bin/README.md | 49 +++++++++ ota/src/bin/ota-packer.rs | 202 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 ota/src/bin/README.md create mode 100644 ota/src/bin/ota-packer.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 5389a66..f3281e3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -31,6 +31,10 @@ run-esp32s3 = "run --release --target xtensa-esp32s3-none-elf --no-default-featu # Test alias test-ota = "test --package ota --target x86_64-unknown-linux-gnu" +# Build ota-packer alias: I might need to modify it so when run it can run a build for target architecture, extract the binary from the elf image and then pack the ota file. +ota-packer = "run --package ota --bin ota-packer --target x86_64-unknown-linux-gnu" + + [target.xtensa-esp32-none-elf] runner = "espflash flash --baud=921600 --monitor --chip esp32" rustflags = ["-C", "link-arg=-nostartfiles", '--cfg=feature="esp32"'] diff --git a/ota/Cargo.toml b/ota/Cargo.toml index ab7cca3..70d4edd 100644 --- a/ota/Cargo.toml +++ b/ota/Cargo.toml @@ -17,3 +17,7 @@ sha2.workspace = true # Only for helpers and tests [target.'cfg(not(target_os = "none"))'.dependencies] clap = "4.5" + +[[bin]] +name = "ota-packer" +path = "src/bin/ota-packer.rs" diff --git a/ota/src/bin/README.md b/ota/src/bin/README.md new file mode 100644 index 0000000..21d517e --- /dev/null +++ b/ota/src/bin/README.md @@ -0,0 +1,49 @@ +# Purpose of ota-packer + +The content of this file is provided for illustrative purposes. For a complete understanding of what this utility does read `ota-packer.rs`. + +This binary is a helper cli application to pack binary files together with a header to allow for the sftp-ota procedure to validate the binary before applying the OTA. + +## What this tool does + +It takes one binary file and adds the following Type Length Value fields (TLV): + +- ota type: SSH-Stamp "magic number" used to identify the ota file as SSH-Stamp. Any other value should be rejected in a OTA procedure by an SSH-Stamp binary. +- checksum: SHA256 checksum of the binary. SSH-Stamp will calculate the checksum of the binary uploaded and will abort the OTA if it does not match this field. +- binary length: Additional validation step. SSH-Stamp will only write/validate the announced bytes into flash memory. A target chip with an ota partition smaller than the announced binary length should abort the OTA. + +## What this tool does not... + +... and it might do in the future: + +- Sign the binary +- Add information about the target architecture to help the target instance aborting a wrong binary. + +... and will definitely not do: + +- Upload the OTA to the target device (The user does this with any standard SFTP client and the appropriate credentials) +- Validate or test in any way the binary + +## Usage + +For updated information on how to use this tool build and run the binary from the `ssh-stamp/ota` directory + +```sh +ssh-stamp/ota$ cargo run --bin ota-packer -- --help +``` + +At the moment of redaction, this command outputs: + +```sh +SSH-Stamp utility 0.1.0 to pack (unpack) OTA update files adding the required metadata. + +Usage: ota-packer [OPTIONS] + +Arguments: + The file to process + +Options: + -u, --unpack Unpacks a OTA file. Will save to with .ota.npkd extension + -p, --pack (default) Packs a binary file as an OTA file. Will save to .ota + -h, --help Print help +``` \ No newline at end of file diff --git a/ota/src/bin/ota-packer.rs b/ota/src/bin/ota-packer.rs new file mode 100644 index 0000000..97a5552 --- /dev/null +++ b/ota/src/bin/ota-packer.rs @@ -0,0 +1,202 @@ +use ota::{OtaHeader, tlv}; + +use clap::{ArgAction, Command}; +use sha2::{Digest, Sha256}; +use std::{ + io::{Read, Seek, SeekFrom, Write}, + path::PathBuf, +}; + +const OTA_PACKER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn main() { + let matches = Command::new("ota-packer") + .about(format!("SSH-Stamp utility {} to pack (unpack) OTA update files adding the required metadata.", OTA_PACKER_VERSION)) + .arg(clap::arg!( "The file to process").required(true)) + .arg( + clap::arg!(-u --unpack "Unpacks a OTA file. Will save to with .ota.npkd extension") + .action(ArgAction::SetTrue) + .conflicts_with("pack"), + ) + .arg( + clap::arg!(-p --pack "(default) Packs a binary file as an OTA file. Will save to .ota") + .action(ArgAction::SetTrue) + .conflicts_with("unpack"), + ) + .get_matches(); + let Some(file_path) = matches.get_one::("FILE") else { + eprintln!("Error: No file provided"); + std::process::exit(1); + }; + + let file_path = PathBuf::from(file_path); + if !file_path.exists() { + eprintln!("Error: File '{}' does not exist", file_path.display()); + std::process::exit(2); + } + if !file_path.is_file() { + eprintln!( + "Error: File '{}' is not a regular file", + file_path.display() + ); + std::process::exit(3); + } + + if matches.get_flag("unpack") { + std::process::exit(unpack_ota(file_path)); + } + + std::process::exit(pack_bin(file_path)); +} + +fn unpack_ota(file_path: PathBuf) -> i32 { + println!("Unpacking BIN from OTA file {}...", file_path.display()); + let Ok(file) = std::fs::File::open(&file_path) else { + eprintln!("Error: Could not open file '{}'", file_path.display(),); + return 4; + }; + let mut reader = std::io::BufReader::new(file); + let mut buffer = [0u8; 512]; + let Ok(_) = reader.read(&mut buffer) else { + eprintln!("Error: Could not read from file '{}'", file_path.display(),); + return 5; + }; + let Ok((header, seek_to_bin)) = OtaHeader::deserialize(&buffer) else { + eprintln!( + "Error: Could not parse OTA header from file '{}'", + file_path.display(), + ); + return 5; + }; + + println!("Found OTA header: {:?}", header); + + let mut file_path_bin = file_path.clone(); + file_path_bin.set_extension("ota.npkd"); + println!("Saving unpacked BIN file to: {}", file_path_bin.display()); + + let Ok(mut bin_file) = std::fs::File::create(&file_path_bin) else { + eprintln!( + "Error: Could not create BIN file '{}'", + file_path_bin.display(), + ); + return 6; + }; + + reader.seek(SeekFrom::Start(seek_to_bin as u64)).unwrap(); + + let mut recover_ota_bin_hasher = Sha256::new(); + + let mut r: usize; + while { + r = reader.read(&mut buffer).unwrap_or(0); + r + } > 0 + { + let Ok(_) = bin_file.write(&buffer[..r]) else { + eprintln!( + "Error: Could not write to BIN file '{}'", + file_path_bin.display(), + ); + return 7; + }; + recover_ota_bin_hasher.update(&buffer[..r]); + } + + if let Some(recovered_firmware_sha256) = recover_ota_bin_hasher.finalize().as_array() { + if recovered_firmware_sha256 != &header.sha256_checksum.unwrap_or_default() { + eprintln!( + "Error: Recovered firmware SHA-256 does not match expected value!\nExpected: {:x?}\nRecovered: {:x?}", + header.sha256_checksum.unwrap_or_default(), + recovered_firmware_sha256 + ); + return 9; + } else { + println!("Recovered firmware SHA-256 matches expected value."); + } + } else { + eprintln!("Error: Could not finalize SHA-256 hash of recovered firmware"); + return 8; + }; + + return 0; +} + +// TODO: Optimize memory usage by streaming the file instead of reading it all at once +fn pack_bin(file_path: PathBuf) -> i32 { + println!("Packing {} as OTA...", file_path.display()); + + let firmware_size = match file_path.metadata() { + Ok(metadata) => u32::try_from(metadata.len()).unwrap_or_else(|_| { + eprintln!( + "Error: File '{}' is too large (max 4GB supported)", + file_path.display() + ); + return 5; + }), + Err(e) => { + eprintln!( + "Error: Could not retrieve metadata for file '{}': {}", + file_path.display(), + e + ); + return 4; + } + }; + println!("Bin file size: {} bytes", firmware_size); + + let mut hasher = Sha256::new(); + let Ok(read) = std::fs::read(&file_path) else { + eprintln!("Error: Could not read file '{}'", file_path.display(),); + return 5; + }; + hasher.update(&read); + + let firmware_sha256 = hasher.finalize(); + println!("Firmware SHA-256: {:x}", firmware_sha256); + + // We could read an u32 from an argument if we want to support multiple OTA types... + let ota_type = tlv::OTA_TYPE_VALUE_SSH_STAMP; + println!("OTA Type Number: {} (SSH-Stamp)", ota_type); + + let mut ota_file_path = file_path.clone(); + ota_file_path.set_extension("ota"); + + println!("Saving OTA file to: {}", ota_file_path.display()); + + let Ok(mut ota_file) = std::fs::File::create(&ota_file_path) else { + eprintln!( + "Error: Could not create OTA file '{}'", + ota_file_path.display(), + ); + return 6; + }; + + // More than enough for the header + let mut buf = [0u8; 512]; + + let header_len = + OtaHeader::new(ota_type, firmware_sha256.as_slice(), firmware_size).serialize(&mut buf); + + println!("OTA header length: {} bytes", header_len); + + let Ok(bytes) = ota_file.write(&buf[..header_len]) else { + eprintln!( + "Error: Could not write to OTA file '{}'", + ota_file_path.display(), + ); + return 5; + }; + println!("Wrote {} bytes of OTA header", bytes); + + let Ok(bytes) = ota_file.write(&read) else { + eprintln!( + "Error: Could not write firmware data to OTA file '{}'", + ota_file_path.display(), + ); + return 5; + }; + println!("Wrote {} bytes of firmware data", bytes); + + 0 +} From 25fdf480b91ee612fb625ba6fa560343073c5c34 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 19 Feb 2026 10:00:11 +1100 Subject: [PATCH 3/4] Adding ota-packer build to the ci workflow I am doing this to check if a change breaks the ota-packer utility --- .cargo/config.toml | 3 ++- .github/workflows/build.yml | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index f3281e3..9dc3894 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -31,7 +31,8 @@ run-esp32s3 = "run --release --target xtensa-esp32s3-none-elf --no-default-featu # Test alias test-ota = "test --package ota --target x86_64-unknown-linux-gnu" -# Build ota-packer alias: I might need to modify it so when run it can run a build for target architecture, extract the binary from the elf image and then pack the ota file. +# ota-packer aliases +build-ota-packer = "build --package ota --bin ota-packer --target x86_64-unknown-linux-gnu" ota-packer = "run --package ota --bin ota-packer --target x86_64-unknown-linux-gnu" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5a291a..e4afb33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,8 +10,8 @@ on: workflow_dispatch: jobs: - build: - name: Build ${{ matrix.device.soc }} + espressif-targets: + name: Espressif target ${{ matrix.device.soc }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -53,4 +53,19 @@ jobs: if: ${{ contains(fromJson('["esp32c6"]'), matrix.device.soc) }} run: | cargo +${{ matrix.device.toolchain }} clippy --features ${{ matrix.device.soc }} --target riscv32imac-unknown-none-elf -- -D warnings - cargo +${{ matrix.device.toolchain }} fmt -- --check \ No newline at end of file + cargo +${{ matrix.device.toolchain }} fmt -- --check + ota-packer: + name: OTA Packer + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Stable Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + target: riscv32imac-unknown-none-elf + toolchain: stable + - name: Build utility + run: cargo build-ota-packer \ No newline at end of file From c32260bb43ba89f078c949f8dc2108868d937902 Mon Sep 17 00:00:00 2001 From: jubeormk1 Date: Thu, 19 Feb 2026 10:11:30 +1100 Subject: [PATCH 4/4] Adding test action for CI workflows This is part of Testing (#37) issue and target for the project --- .github/workflows/tests.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e09f26b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Std tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + ota-packer: + name: OTA tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Stable Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + target: riscv32imac-unknown-none-elf + toolchain: stable + - name: Package test + run: cargo test-ota \ No newline at end of file