OTA step 1: Update File format and streaming parser (host-tested)

lib/ota: a 160-byte header (magic, format, image size and SHA-256,
version, ECDSA signature over the first 80 bytes) then the image.
UpdateParser checks the header and signature before writing anything,
hashes the image as it streams into an UpdateSink, and only finishes
the sink when the hash matches. Downgrades are flagged, not refused.
Includes a dependency-free SHA-256 and semver comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-03 21:30:13 +02:00
co-authored by Claude Opus 5.5
parent e9efbcca4d
commit 326e864264
6 changed files with 574 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
#include "sha256.h"
#include <cstring>
namespace roro {
namespace {
const uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
inline uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); }
} // namespace
Sha256::Sha256() {
const uint32_t init[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
std::memcpy(h_, init, sizeof(h_));
}
void Sha256::block(const uint8_t* p) {
uint32_t w[64];
for (int i = 0; i < 16; i++)
w[i] = (uint32_t(p[4 * i]) << 24) | (uint32_t(p[4 * i + 1]) << 16) | (uint32_t(p[4 * i + 2]) << 8) | p[4 * i + 3];
for (int i = 16; i < 64; i++) {
uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3);
uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
}
uint32_t a = h_[0], b = h_[1], c = h_[2], d = h_[3], e = h_[4], f = h_[5], g = h_[6], h = h_[7];
for (int i = 0; i < 64; i++) {
uint32_t t1 = h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
uint32_t t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
h = g;
g = f;
f = e;
e = d + t1;
d = c;
c = b;
b = a;
a = t1 + t2;
}
h_[0] += a; h_[1] += b; h_[2] += c; h_[3] += d;
h_[4] += e; h_[5] += f; h_[6] += g; h_[7] += h;
}
void Sha256::update(const uint8_t* data, size_t len) {
bits_ += static_cast<uint64_t>(len) * 8;
while (len > 0) {
size_t take = 64 - used_ < len ? 64 - used_ : len;
std::memcpy(buf_ + used_, data, take);
used_ += take;
data += take;
len -= take;
if (used_ == 64) {
block(buf_);
used_ = 0;
}
}
}
void Sha256::finish(uint8_t out[32]) {
uint64_t bits = bits_;
uint8_t pad = 0x80;
update(&pad, 1);
uint8_t zero = 0;
while (used_ != 56) update(&zero, 1);
uint8_t len[8];
for (int i = 0; i < 8; i++) len[i] = static_cast<uint8_t>(bits >> (56 - 8 * i));
update(len, 8);
for (int i = 0; i < 8; i++) {
out[4 * i] = h_[i] >> 24;
out[4 * i + 1] = h_[i] >> 16;
out[4 * i + 2] = h_[i] >> 8;
out[4 * i + 3] = h_[i];
}
}
} // namespace roro
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace roro {
// Plain SHA-256 (FIPS 180-4), streaming. Small and dependency-free, so the same code runs in the
// PC tests and on the device.
class Sha256 {
public:
Sha256();
void update(const uint8_t* data, size_t len);
void finish(uint8_t out[32]);
static void hash(const uint8_t* data, size_t len, uint8_t out[32]) {
Sha256 s;
s.update(data, len);
s.finish(out);
}
private:
void block(const uint8_t* p);
uint32_t h_[8];
uint8_t buf_[64];
size_t used_ = 0;
uint64_t bits_ = 0;
};
} // namespace roro
+87
View File
@@ -0,0 +1,87 @@
#include "update_parser.h"
#include <cstring>
#include "version_compare.h"
namespace roro {
namespace {
uint16_t u16(const uint8_t* p) { return p[0] | (p[1] << 8); }
uint32_t u32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (uint32_t(p[3]) << 24); }
} // namespace
void UpdateParser::fail(const std::string& why) {
if (state_ == State::Image) sink_.abort();
state_ = State::Failed;
error_ = why;
}
void UpdateParser::parseHeader() {
const uint8_t* h = header_;
if (std::memcmp(h, update::kMagic, 8) != 0) return fail("not an update file");
if (u16(h + 8) != update::kFormat || u16(h + 10) != update::kHeaderSize) return fail("unsupported update format");
imageSize_ = u32(h + 12);
if (imageSize_ == 0 || imageSize_ > maxImage_) return fail("image doesn't fit the update slot");
std::memcpy(expectedHash_, h + 16, 32);
version_.assign(reinterpret_cast<const char*>(h + 48), strnlen(reinterpret_cast<const char*>(h + 48), 32));
size_t sigLen = u16(h + 80);
if (sigLen == 0 || sigLen > update::kMaxSignature) return fail("missing signature");
uint8_t digest[32];
Sha256::hash(h, update::kSignedBytes, digest);
if (!verifier_.verify(digest, h + 82, sigLen)) return fail("bad signature: not signed with this device's key");
downgrade_ = versionOlder(version_, installed_);
if (!sink_.begin(imageSize_)) return fail("could not prepare the update slot");
state_ = State::Image;
}
void UpdateParser::feed(const uint8_t* data, size_t len) {
while (len > 0 && (state_ == State::Header || state_ == State::Image)) {
if (state_ == State::Header) {
size_t take = std::min(len, update::kHeaderSize - headerUsed_);
std::memcpy(header_ + headerUsed_, data, take);
headerUsed_ += take;
data += take;
len -= take;
if (headerUsed_ == update::kHeaderSize) parseHeader();
} else {
size_t take = std::min(len, imageSize_ - received_);
if (take == 0) return fail("data after the end of the image");
hash_.update(data, take);
if (!sink_.write(data, take)) return fail("writing the update failed");
received_ += take;
data += take;
len -= take;
}
}
if (len > 0 && state_ == State::Image) fail("data after the end of the image");
}
bool UpdateParser::end() {
if (state_ == State::Done) return true;
if (state_ != State::Image) {
if (state_ == State::Header) fail("update file too short");
return false;
}
if (received_ != imageSize_) {
fail("update file too short");
return false;
}
uint8_t got[32];
hash_.finish(got);
if (std::memcmp(got, expectedHash_, 32) != 0) {
fail("image corrupted (hash mismatch)");
return false;
}
if (!sink_.finish()) {
state_ = State::Failed;
error_ = "could not switch to the new image";
return false;
}
state_ = State::Done;
return true;
}
} // namespace roro
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include "sha256.h"
namespace roro {
// The Update File (see CONTEXT.md): a 160-byte header, then the firmware image. All integers are
// little-endian.
// 0 magic "RORO-OTA" 8 u16 format (1) 10 u16 header size (160) 12 u32 image size
// 16 image SHA-256[32] 48 version, NUL-padded [32]
// 80 u16 signature length 82 signature (DER, up to 72 bytes) 154 reserved
// The signature covers SHA-256 of bytes 0..79, which include the image's hash, so a bad signature
// is caught before anything is written, and a bad image when its hash is checked at the end.
namespace update {
constexpr char kMagic[] = "RORO-OTA";
constexpr uint16_t kFormat = 1;
constexpr size_t kHeaderSize = 160;
constexpr size_t kSignedBytes = 80;
constexpr size_t kMaxSignature = 72;
} // namespace update
class SignatureVerifier {
public:
virtual ~SignatureVerifier() = default;
virtual bool verify(const uint8_t digest[32], const uint8_t* signature, size_t len) = 0;
};
// Where the image goes (the inactive app slot on the device).
class UpdateSink {
public:
virtual ~UpdateSink() = default;
virtual bool begin(size_t imageSize) = 0;
virtual bool write(const uint8_t* data, size_t len) = 0;
virtual bool finish() = 0; // make it the image to boot next
virtual void abort() = 0;
};
// Streams an Update File into a sink: checks the header and signature first, then hashes the image
// as it passes through, and only finishes the sink if everything matches.
class UpdateParser {
public:
enum class State { Header, Image, Done, Failed };
UpdateParser(SignatureVerifier& verifier, UpdateSink& sink, size_t maxImageSize, std::string installedVersion)
: verifier_(verifier), sink_(sink), maxImage_(maxImageSize), installed_(std::move(installedVersion)) {}
void feed(const uint8_t* data, size_t len);
bool end(); // no more data: true if the update was installed
State state() const { return state_; }
const std::string& error() const { return error_; }
const std::string& version() const { return version_; }
bool isDowngrade() const { return downgrade_; }
int percent() const { return imageSize_ ? static_cast<int>(received_ * 100 / imageSize_) : 0; }
private:
void parseHeader();
void fail(const std::string& why);
SignatureVerifier& verifier_;
UpdateSink& sink_;
size_t maxImage_;
std::string installed_;
State state_ = State::Header;
uint8_t header_[update::kHeaderSize];
size_t headerUsed_ = 0;
uint8_t expectedHash_[32];
size_t imageSize_ = 0;
size_t received_ = 0;
Sha256 hash_;
std::string version_, error_;
bool downgrade_ = false;
};
} // namespace roro
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <cstdlib>
#include <string>
namespace roro {
// True if `a` is an older release than `b`, comparing "vMAJOR.MINOR.PATCH" and ignoring any
// git-describe suffix ("-3-gabc1234-dirty"). Anything that doesn't parse is never called older.
inline bool parseVersion(const std::string& s, int out[3]) {
size_t pos = s.size() > 0 && s[0] == 'v' ? 1 : 0;
for (int i = 0; i < 3; i++) {
if (pos >= s.size() || s[pos] < '0' || s[pos] > '9') return false;
char* end;
out[i] = static_cast<int>(std::strtol(s.c_str() + pos, &end, 10));
pos = end - s.c_str();
if (i < 2) {
if (pos >= s.size() || s[pos] != '.') return false;
pos++;
}
}
return true;
}
inline bool versionOlder(const std::string& a, const std::string& b) {
int x[3], y[3];
if (!parseVersion(a, x) || !parseVersion(b, y)) return false;
for (int i = 0; i < 3; i++)
if (x[i] != y[i]) return x[i] < y[i];
return false;
}
} // namespace roro
+259
View File
@@ -0,0 +1,259 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "sha256.h"
#include "update_parser.h"
#include "version_compare.h"
using namespace roro;
void setUp() {}
void tearDown() {}
// ---- helpers ----------------------------------------------------------------
static std::string hex(const uint8_t* d, size_t n) {
static const char* h = "0123456789abcdef";
std::string s;
for (size_t i = 0; i < n; i++) {
s += h[d[i] >> 4];
s += h[d[i] & 15];
}
return s;
}
// Accepts a signature equal to the first bytes of the digest it is asked about ("signed" by us).
struct FakeVerifier : SignatureVerifier {
bool verify(const uint8_t digest[32], const uint8_t* sig, size_t len) override {
calls++;
return len == 8 && std::memcmp(digest, sig, 8) == 0;
}
int calls = 0;
};
struct MemorySink : UpdateSink {
bool begin(size_t size) override {
began = true;
expected = size;
return true;
}
bool write(const uint8_t* d, size_t n) override {
data.insert(data.end(), d, d + n);
return true;
}
bool finish() override { return finished = true; }
void abort() override { aborted = true; }
bool began = false, finished = false, aborted = false;
size_t expected = 0;
std::vector<uint8_t> data;
};
// Builds an Update File the way make_ota.py does, signing with FakeVerifier's scheme.
static std::vector<uint8_t> makeFile(const std::string& version, const std::vector<uint8_t>& image, bool goodSig = true) {
std::vector<uint8_t> h(update::kHeaderSize, 0);
std::memcpy(h.data(), update::kMagic, 8);
h[8] = update::kFormat & 0xFF;
h[10] = update::kHeaderSize & 0xFF;
h[11] = update::kHeaderSize >> 8;
uint32_t n = static_cast<uint32_t>(image.size());
for (int i = 0; i < 4; i++) h[12 + i] = (n >> (8 * i)) & 0xFF;
Sha256::hash(image.data(), image.size(), &h[16]);
std::strncpy(reinterpret_cast<char*>(&h[48]), version.c_str(), 31);
uint8_t digest[32];
Sha256::hash(h.data(), update::kSignedBytes, digest);
h[80] = 8;
std::memcpy(&h[82], digest, 8);
if (!goodSig) h[82] ^= 0xFF;
h.insert(h.end(), image.begin(), image.end());
return h;
}
static std::vector<uint8_t> image(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; i++) v[i] = static_cast<uint8_t>(i * 7 + 3);
return v;
}
// ---- SHA-256 ----------------------------------------------------------------
void test_sha256_known_vectors() {
uint8_t d[32];
Sha256::hash(reinterpret_cast<const uint8_t*>(""), 0, d);
TEST_ASSERT_EQUAL_STRING("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hex(d, 32).c_str());
Sha256::hash(reinterpret_cast<const uint8_t*>("abc"), 3, d);
TEST_ASSERT_EQUAL_STRING("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hex(d, 32).c_str());
const char* two = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
Sha256::hash(reinterpret_cast<const uint8_t*>(two), std::strlen(two), d);
TEST_ASSERT_EQUAL_STRING("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", hex(d, 32).c_str());
}
void test_sha256_streaming_matches_one_shot() {
auto img = image(1000);
uint8_t a[32], b[32];
Sha256::hash(img.data(), img.size(), a);
Sha256 s;
for (size_t i = 0; i < img.size(); i += 37) s.update(img.data() + i, std::min<size_t>(37, img.size() - i));
s.finish(b);
TEST_ASSERT_EQUAL_MEMORY(a, b, 32);
}
// ---- parser -----------------------------------------------------------------
void test_valid_file_in_one_chunk_is_installed() {
FakeVerifier v;
MemorySink sink;
auto img = image(5000);
auto file = makeFile("v0.3.0", img);
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_TRUE(p.end());
TEST_ASSERT_EQUAL(static_cast<int>(UpdateParser::State::Done), static_cast<int>(p.state()));
TEST_ASSERT_TRUE(sink.finished);
TEST_ASSERT_TRUE(sink.data == img);
TEST_ASSERT_EQUAL_STRING("v0.3.0", p.version().c_str());
TEST_ASSERT_FALSE(p.isDowngrade());
}
void test_valid_file_byte_by_byte() {
FakeVerifier v;
MemorySink sink;
auto img = image(700);
auto file = makeFile("v0.3.0", img);
UpdateParser p(v, sink, 100000, "v0.2.1");
for (auto b : file) p.feed(&b, 1);
TEST_ASSERT_TRUE(p.end());
TEST_ASSERT_TRUE(sink.data == img);
}
void test_progress_counts_image_bytes() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(1000));
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), update::kHeaderSize + 250);
TEST_ASSERT_EQUAL(25, p.percent());
}
void test_not_an_update_file_is_refused_before_writing() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(100));
file[0] = 'X';
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_EQUAL(static_cast<int>(UpdateParser::State::Failed), static_cast<int>(p.state()));
TEST_ASSERT_FALSE(sink.began);
TEST_ASSERT_FALSE(p.error().empty());
}
void test_bad_signature_is_refused_before_writing() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(100), false);
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_EQUAL(static_cast<int>(UpdateParser::State::Failed), static_cast<int>(p.state()));
TEST_ASSERT_FALSE(sink.began);
TEST_ASSERT_TRUE(p.error().find("signature") != std::string::npos);
}
void test_tampered_version_breaks_the_signature() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(100));
file[48 + 3] = '9'; // v0.3.0 -> v0.9.0 after signing
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_EQUAL(static_cast<int>(UpdateParser::State::Failed), static_cast<int>(p.state()));
}
void test_corrupted_image_is_aborted_at_the_end() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(500));
file[update::kHeaderSize + 123] ^= 1;
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_FALSE(p.end());
TEST_ASSERT_TRUE(sink.aborted);
TEST_ASSERT_FALSE(sink.finished);
}
void test_truncated_file_is_aborted() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(500));
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size() - 10);
TEST_ASSERT_FALSE(p.end());
TEST_ASSERT_TRUE(sink.aborted);
}
void test_trailing_bytes_are_refused() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(100));
file.push_back(0);
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_FALSE(p.end());
TEST_ASSERT_FALSE(sink.finished);
}
void test_image_larger_than_the_slot_is_refused_before_writing() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.3.0", image(2000));
UpdateParser p(v, sink, 1000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_EQUAL(static_cast<int>(UpdateParser::State::Failed), static_cast<int>(p.state()));
TEST_ASSERT_FALSE(sink.began);
}
void test_downgrade_is_flagged_but_allowed() {
FakeVerifier v;
MemorySink sink;
auto file = makeFile("v0.2.0", image(100));
UpdateParser p(v, sink, 100000, "v0.2.1");
p.feed(file.data(), file.size());
TEST_ASSERT_TRUE(p.end());
TEST_ASSERT_TRUE(p.isDowngrade());
}
// ---- versions ---------------------------------------------------------------
void test_version_comparison() {
TEST_ASSERT_TRUE(versionOlder("v0.2.0", "v0.2.1"));
TEST_ASSERT_TRUE(versionOlder("v0.9.9", "v0.10.0"));
TEST_ASSERT_TRUE(versionOlder("v0.2.1", "v1.0.0"));
TEST_ASSERT_FALSE(versionOlder("v0.2.1", "v0.2.1"));
TEST_ASSERT_FALSE(versionOlder("v0.3.0", "v0.2.1"));
// build suffixes from git describe compare on the release part
TEST_ASSERT_FALSE(versionOlder("v0.2.1-3-gabc1234", "v0.2.1"));
TEST_ASSERT_TRUE(versionOlder("v0.2.1-3-gabc1234-dirty", "v0.3.0"));
// unknown versions are never called older
TEST_ASSERT_FALSE(versionOlder("unknown", "v0.2.1"));
TEST_ASSERT_FALSE(versionOlder("v0.2.1", "4c0be89"));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_sha256_known_vectors);
RUN_TEST(test_sha256_streaming_matches_one_shot);
RUN_TEST(test_valid_file_in_one_chunk_is_installed);
RUN_TEST(test_valid_file_byte_by_byte);
RUN_TEST(test_progress_counts_image_bytes);
RUN_TEST(test_not_an_update_file_is_refused_before_writing);
RUN_TEST(test_bad_signature_is_refused_before_writing);
RUN_TEST(test_tampered_version_breaks_the_signature);
RUN_TEST(test_corrupted_image_is_aborted_at_the_end);
RUN_TEST(test_truncated_file_is_aborted);
RUN_TEST(test_trailing_bytes_are_refused);
RUN_TEST(test_image_larger_than_the_slot_is_refused_before_writing);
RUN_TEST(test_downgrade_is_flagged_but_allowed);
RUN_TEST(test_version_comparison);
return UNITY_END();
}