diff --git a/lib/ota/src/sha256.cpp b/lib/ota/src/sha256.cpp new file mode 100644 index 0000000..8a1e78b --- /dev/null +++ b/lib/ota/src/sha256.cpp @@ -0,0 +1,85 @@ +#include "sha256.h" + +#include + +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(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(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 diff --git a/lib/ota/src/sha256.h b/lib/ota/src/sha256.h new file mode 100644 index 0000000..80f19e0 --- /dev/null +++ b/lib/ota/src/sha256.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +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 diff --git a/lib/ota/src/update_parser.cpp b/lib/ota/src/update_parser.cpp new file mode 100644 index 0000000..0cae285 --- /dev/null +++ b/lib/ota/src/update_parser.cpp @@ -0,0 +1,87 @@ +#include "update_parser.h" + +#include + +#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(h + 48), strnlen(reinterpret_cast(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 diff --git a/lib/ota/src/update_parser.h b/lib/ota/src/update_parser.h new file mode 100644 index 0000000..0c6114c --- /dev/null +++ b/lib/ota/src/update_parser.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#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(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 diff --git a/lib/ota/src/version_compare.h b/lib/ota/src/version_compare.h new file mode 100644 index 0000000..f794f85 --- /dev/null +++ b/lib/ota/src/version_compare.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +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(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 diff --git a/test/test_ota/test_ota.cpp b/test/test_ota/test_ota.cpp new file mode 100644 index 0000000..afbe3c1 --- /dev/null +++ b/test/test_ota/test_ota.cpp @@ -0,0 +1,259 @@ +#include + +#include +#include +#include + +#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 data; +}; + +// Builds an Update File the way make_ota.py does, signing with FakeVerifier's scheme. +static std::vector makeFile(const std::string& version, const std::vector& image, bool goodSig = true) { + std::vector 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(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(&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 image(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) v[i] = static_cast(i * 7 + 3); + return v; +} + +// ---- SHA-256 ---------------------------------------------------------------- + +void test_sha256_known_vectors() { + uint8_t d[32]; + Sha256::hash(reinterpret_cast(""), 0, d); + TEST_ASSERT_EQUAL_STRING("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hex(d, 32).c_str()); + Sha256::hash(reinterpret_cast("abc"), 3, d); + TEST_ASSERT_EQUAL_STRING("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hex(d, 32).c_str()); + const char* two = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; + Sha256::hash(reinterpret_cast(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(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(UpdateParser::State::Done), static_cast(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(UpdateParser::State::Failed), static_cast(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(UpdateParser::State::Failed), static_cast(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(UpdateParser::State::Failed), static_cast(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(UpdateParser::State::Failed), static_cast(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(); +}