Harden recovery authentication and signed OTA admission
This commit is contained in:
1 parent
74bc91bf69
commit
5fa06ea49b
40 files changed
+3631
-2107
No files matched your search
+86
@@ -0,0 +1,86 @@
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
// The unknown-setting branches below are extracted verbatim from production.
|
||||
// The hardware-dependent recognized-setting branches are outside this fixture.
|
||||
using Fallback = void (*)(const char*, char*);
|
||||
static void commonFallback(const char* command, char* reply) {
|
||||
@COMMON_CONFIG@
|
||||
(void)config;
|
||||
if (false) {}
|
||||
@COMMON_FALLBACK@
|
||||
}
|
||||
static void observerFallback(const char* command, char* reply) {
|
||||
@COMMON_CONFIG@
|
||||
(void)config;
|
||||
if (false) {}
|
||||
@OBSERVER_FALLBACK@
|
||||
}
|
||||
|
||||
static std::string unknownCommand(bool observer, size_t command_length) {
|
||||
const char* prefix = observer ? "set mqtt1." : "set repeat.";
|
||||
assert(command_length >= strlen(prefix));
|
||||
return std::string(prefix) + std::string(command_length - strlen(prefix), 'x');
|
||||
}
|
||||
|
||||
template <size_t Capacity>
|
||||
static void exact(Fallback fallback, const std::string& command) {
|
||||
// Standalone arrays expose any past-end write to AddressSanitizer rather
|
||||
// than hiding it inside an allocation which includes the path/canary.
|
||||
std::array<char, Capacity> reply;
|
||||
reply.fill(char(0x55));
|
||||
fallback(command.c_str(), reply.data());
|
||||
assert(strcmp(reply.data(), "unknown config") == 0);
|
||||
for (size_t i = sizeof("unknown config"); i < reply.size(); ++i)
|
||||
assert(reply[i] == char(0x55));
|
||||
}
|
||||
|
||||
template <size_t Capacity>
|
||||
static void adjacent(Fallback fallback, const std::string& command) {
|
||||
struct Buffers {
|
||||
uint8_t before[16];
|
||||
char reply[Capacity];
|
||||
uint8_t reply_path[64];
|
||||
} buffers;
|
||||
memset(&buffers, 0x55, sizeof(buffers));
|
||||
fallback(command.c_str(), buffers.reply);
|
||||
assert(strcmp(buffers.reply, "unknown config") == 0);
|
||||
for (uint8_t value : buffers.before) assert(value == 0x55);
|
||||
for (uint8_t value : buffers.reply_path) assert(value == 0x55);
|
||||
for (size_t i = sizeof("unknown config"); i < sizeof(buffers.reply); ++i)
|
||||
assert(buffers.reply[i] == char(0x55));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 3);
|
||||
const bool observer = strcmp(argv[1], "observer") == 0;
|
||||
const std::string scenario(argv[2]);
|
||||
Fallback fallback = observer ? observerFallback : commonFallback;
|
||||
if (scenario == "local") {
|
||||
const auto command = unknownCommand(observer, 159);
|
||||
exact<160>(fallback, command);
|
||||
adjacent<160>(fallback, command);
|
||||
} else if (scenario == "radio") {
|
||||
const auto command = unknownCommand(observer, 179);
|
||||
exact<179>(fallback, command);
|
||||
adjacent<179>(fallback, command);
|
||||
} else if (scenario == "minimum") {
|
||||
const auto command = unknownCommand(observer, 179);
|
||||
exact<sizeof("unknown config")>(fallback, command);
|
||||
adjacent<sizeof("unknown config")>(fallback, command);
|
||||
} else if (scenario == "lengths") {
|
||||
for (size_t length = 16; length < 4096; ++length) {
|
||||
const auto command = unknownCommand(observer, length);
|
||||
exact<160>(fallback, command);
|
||||
exact<179>(fallback, command);
|
||||
exact<sizeof("unknown config")>(fallback, command);
|
||||
adjacent<179>(fallback, command);
|
||||
}
|
||||
} else assert(false);
|
||||
return 0;
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <helpers/ota/FolderMotaStore.h>
|
||||
#include <helpers/ota/MotaSeederProto.h>
|
||||
#include <helpers/ota/OtaByteIO.h>
|
||||
using namespace mesh::ota;
|
||||
struct HostStream : Stream {
|
||||
bool request=false, noise=false, bad_checksum=false, error=false;
|
||||
bool stalled=false, short_write=false;
|
||||
uint32_t byte_delay=0; int stale_reads=0, stale_at_request=0;
|
||||
size_t response_pos=0; std::vector<uint8_t> response;
|
||||
int available() override { return request ? int(response.size()-response_pos) : (noise ? 3 : 0); }
|
||||
int read() override {
|
||||
if (!request) {
|
||||
if (noise && stale_reads<100) { ++stale_reads; return 0x5a; }
|
||||
return -1;
|
||||
}
|
||||
if (stalled || response_pos==response.size()) return -1;
|
||||
g_mock_millis+=byte_delay;
|
||||
return response[response_pos++];
|
||||
}
|
||||
int peek() override { return -1; }
|
||||
void flush() override { assert(false); }
|
||||
size_t write(uint8_t) override { assert(false); return 0; }
|
||||
size_t write(const uint8_t* data, size_t size) override {
|
||||
if(short_write) return size-1;
|
||||
assert(size>=4 && data[0]==MOTA_SEEDER_REQ_MAGIC0 && data[1]==MOTA_SEEDER_REQ_MAGIC1);
|
||||
uint8_t op=data[2], xor_sum=0;
|
||||
for(size_t i=2;i<size-1;++i)xor_sum^=data[i];
|
||||
assert(xor_sum==data[size-1]);
|
||||
stale_at_request=stale_reads; request=true; response_pos=0;
|
||||
response={MOTA_SEEDER_RSP_MAGIC0,MOTA_SEEDER_RSP_MAGIC1,op,uint8_t(error ? 1 : MS_STATUS_OK)};
|
||||
if(!error && op==MS_OP_SREAD) {
|
||||
uint16_t wanted=uint16_t(data[11]) | (uint16_t(data[12])<<8);
|
||||
for(uint16_t i=0;i<wanted;++i)response.push_back(uint8_t(i));
|
||||
} else if(!error && op==MS_OP_STAT) {
|
||||
response.push_back(1); uint8_t length[4]; wr_u32le(length,128);
|
||||
response.insert(response.end(),length,length+4);
|
||||
}
|
||||
xor_sum=0; for(uint8_t value:response)xor_sum^=value;
|
||||
response.push_back(bad_checksum ? uint8_t(xor_sum^1) : xor_sum);
|
||||
return size;
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
uint8_t mid[4]={1,2,3,4}, data[32]={}, output[32]={};
|
||||
for(uint32_t initial : {0u,UINT32_MAX-10u}) {
|
||||
HostStream slow; slow.byte_delay=7; g_mock_millis=initial;
|
||||
FolderMotaStore store(slow,MotaStreamWritePolicy::NoFlush,20); store.set_mid(mid);
|
||||
assert(!store.reopen());
|
||||
assert(uint32_t(g_mock_millis-initial)<=27); // last read may cross deadline once
|
||||
assert(store.staged_size()==0);
|
||||
}
|
||||
HostStream stale; stale.noise=true; g_mock_millis=0;
|
||||
FolderMotaStore bounded(stale,MotaStreamWritePolicy::NoFlush,20); bounded.set_mid(mid);
|
||||
assert(bounded.begin(128)); assert(stale.stale_at_request==3);
|
||||
assert(bounded.staged_size()==128);
|
||||
HostStream good; FolderMotaStore valid(good,MotaStreamWritePolicy::NoFlush,3000); valid.set_mid(mid);
|
||||
assert(valid.begin(128)); assert(valid.write(4,data,sizeof(data)));
|
||||
assert(valid.read(0,output,sizeof(output)));
|
||||
for(size_t i=0;i<sizeof(output);++i)assert(output[i]==uint8_t(i));
|
||||
assert(valid.finalize() && valid.reopen() && valid.staged_size()==128);
|
||||
// Keep deliberate SlowFi tolerance: a response arriving within the overall
|
||||
// three-second budget is accepted without reducing the configured timeout.
|
||||
HostStream slow_valid; slow_valid.byte_delay=250; g_mock_millis=0;
|
||||
FolderMotaStore tolerant(slow_valid,MotaStreamWritePolicy::NoFlush,3000);
|
||||
assert(tolerant.reopen()); assert(g_mock_millis==2500);
|
||||
for(int scenario=0;scenario<4;++scenario) {
|
||||
HostStream bad;
|
||||
bad.stalled=scenario==0; bad.bad_checksum=scenario==1;
|
||||
bad.error=scenario==2; bad.short_write=scenario==3;
|
||||
g_mock_millis=0; FolderMotaStore fail(bad,MotaStreamWritePolicy::NoFlush,20);
|
||||
assert(!fail.begin(128) && fail.staged_size()==0);
|
||||
assert(g_mock_millis<=20);
|
||||
}
|
||||
puts("Folder mOTA deadline regression checks passed");
|
||||
}
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <initializer_list>
|
||||
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
|
||||
static constexpr int ESP_OK=0;
|
||||
using esp_err_t=int;
|
||||
const char* esp_err_to_name(int) { return "mock"; }
|
||||
uint32_t millis() { return 123; }
|
||||
const uint8_t rootca_bytes[16]={};
|
||||
const uint8_t* rootca_crt_bundle_start=nullptr;
|
||||
const uint8_t* rootca_crt_bundle_end=nullptr;
|
||||
static bool s_ca_bundle_loaded=false;
|
||||
struct Client {
|
||||
int credentials=0, bundles=0, attaches=0; std::string server;
|
||||
void setServer(const char* uri) { server=uri; }
|
||||
void setCACertBundle(const uint8_t*, size_t) { ++bundles; }
|
||||
void attachArduinoCACertBundle(bool value) { assert(value); ++attaches; }
|
||||
void setCredentials(const char*, const char*) { ++credentials; }
|
||||
};
|
||||
struct MQTTBridge {
|
||||
struct MQTTSlot {
|
||||
char host[100]="broker.example", broker_uri[150]={};
|
||||
char username[40]="user", password[40]="secret", audience[40]={};
|
||||
char token[80]="signed-jwt"; char* auth_token=token;
|
||||
uint16_t port=8883; Client* client=nullptr;
|
||||
bool initial_connect_done=true; uint32_t last_reconnect_attempt=0;
|
||||
} _slots[2];
|
||||
Client clients[2]; int connects=0, tokens=0;
|
||||
const char* _jwt_username="jwt";
|
||||
MQTTBridge() { for (int i=0;i<2;++i) _slots[i].client=&clients[i]; }
|
||||
bool createSlotAuthToken(int) { ++tokens; return true; }
|
||||
int reconnectSlotClient(int) { ++connects; return ESP_OK; }
|
||||
bool setupCustom(int);
|
||||
};
|
||||
@METHOD@
|
||||
int main() {
|
||||
for (const char* host : {"mqtts://broker", "wss://broker/path", "broker"}) {
|
||||
for (bool jwt : {false, true}) {
|
||||
MQTTBridge bridge; auto& slot=bridge._slots[0];
|
||||
strcpy(slot.host, host); if(jwt) strcpy(slot.audience, "fleet-audience");
|
||||
assert(!bridge.setupCustom(0));
|
||||
assert(!slot.initial_connect_done && slot.last_reconnect_attempt==123);
|
||||
assert(bridge.connects==0 && bridge.tokens==0 && bridge.clients[0].credentials==0);
|
||||
}
|
||||
}
|
||||
// Port 443 infers WSS and needs verification too.
|
||||
MQTTBridge wss; wss._slots[0].port=443; assert(!wss.setupCustom(0)); assert(wss.connects==0);
|
||||
// Explicit plaintext is an operator choice; never silently downgrade TLS.
|
||||
for (const char* host : {"mqtt://broker", "ws://broker/path", "broker"}) {
|
||||
MQTTBridge plain; strcpy(plain._slots[0].host, host); plain._slots[0].port=1883;
|
||||
assert(plain.setupCustom(0) && plain.connects==1 && plain.clients[0].credentials==1);
|
||||
assert(plain.clients[0].bundles==0);
|
||||
}
|
||||
rootca_crt_bundle_start=rootca_bytes; rootca_crt_bundle_end=rootca_bytes+sizeof(rootca_bytes);
|
||||
MQTTBridge trusted;
|
||||
#ifdef PORTABLE_MQTT_OBSERVER
|
||||
assert(!trusted.setupCustom(0) && trusted.connects==0); // bundle deliberately unlinked
|
||||
#else
|
||||
assert(trusted.setupCustom(0));
|
||||
assert(s_ca_bundle_loaded && trusted.clients[0].bundles==1 && trusted.clients[0].credentials==1);
|
||||
strcpy(trusted._slots[1].audience, "fleet-audience");
|
||||
assert(trusted.setupCustom(1));
|
||||
assert(trusted.clients[1].bundles==0 && trusted.clients[1].attaches==1);
|
||||
assert(trusted.tokens==1 && trusted.connects==2);
|
||||
assert(trusted.clients[0].server=="mqtts://broker.example:8883");
|
||||
#endif
|
||||
puts("MQTT custom TLS security regression checks passed");
|
||||
}
|
||||
+4
-2
@@ -98,7 +98,8 @@ static void filtered_catalog_remains_queryable(int scenario, bool automatic) {
|
||||
}
|
||||
}
|
||||
for (const auto& message : filtered) client.on_message(message.data(), message.size());
|
||||
assert(client.catalogCount() == (scenario == 4 ? 1 : scenario == 6 ? 0 : 15));
|
||||
assert(client.catalogCount() == (scenario == 4 ? 1 : scenario == 6 ? 0 :
|
||||
automatic ? 15 : OTA_INLINE_CATALOG));
|
||||
if (scenario == 8) {
|
||||
// Passive rows arriving before a beacon must not confer completeness on its new digest.
|
||||
client.on_message(wire, encode_adv(wire, sizeof(wire), adv));
|
||||
@@ -114,7 +115,7 @@ static void filtered_catalog_remains_queryable(int scenario, bool automatic) {
|
||||
QueryMsg recovery{};
|
||||
assert(decode_query(from_client[0].data(), from_client[0].size(), recovery));
|
||||
assert(recovery.filter_target == 0);
|
||||
assert(recovery.want_fragments == (scenario == 4 ? 1u : scenario == 5 ? 6u : scenario == 8 ? 0u : 7u));
|
||||
assert(recovery.want_fragments == (scenario == 4 ? 1u : scenario == 5 && automatic ? 6u : scenario == 8 ? 0u : 7u));
|
||||
from_server.clear();
|
||||
server.on_message(from_client[0].data(), from_client[0].size());
|
||||
for (const auto& message : from_server) client.on_message(message.data(), message.size());
|
||||
@@ -153,6 +154,7 @@ int main(int argc, char** argv) {
|
||||
assert(!row(manager, 2));
|
||||
assert(manager.sourceCount() == OTA_MAX_SOURCES);
|
||||
} else if (scenario == 1) {
|
||||
manager.queryAll(); // large catalogs are explicit operator browse policy
|
||||
for (unsigned i = 1; i <= OTA_MAX_CATALOG; ++i) {
|
||||
manager.set_clock(old + i * 100);
|
||||
have(manager, 1, i);
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#include <Identity.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
// Real Ed25519 behind the firmware Identity API for host OTA-policy fixtures.
|
||||
namespace mesh {
|
||||
unsigned signature_checks = 0;
|
||||
bool Identity::verify(const uint8_t* signature, const uint8_t* message, int length) const {
|
||||
++signature_checks;
|
||||
if (!signature || !message || length < 0) return false;
|
||||
EVP_PKEY* public_key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr,
|
||||
pub_key, sizeof(pub_key));
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
const bool valid = public_key && context &&
|
||||
EVP_DigestVerifyInit(context, nullptr, nullptr, nullptr, public_key) == 1 &&
|
||||
EVP_DigestVerify(context, signature, SIGNATURE_SIZE, message, length) == 1;
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(public_key);
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
Vendored
+394
@@ -0,0 +1,394 @@
|
||||
#include <helpers/ota/OtaManager.h>
|
||||
#include <helpers/ota/OtaManifestPolicy.h>
|
||||
#include <helpers/ota/OtaByteIO.h>
|
||||
#include <helpers/ota/Multihash.h>
|
||||
#include "test_ota/mota_vectors.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <openssl/evp.h>
|
||||
namespace mesh { extern unsigned signature_checks; }
|
||||
using namespace mesh::ota;
|
||||
|
||||
static std::array<void*, 64> allocations{};
|
||||
static std::array<size_t, 64> allocation_sizes{};
|
||||
static size_t allocated = 0, peak = 0;
|
||||
extern "C" void* __real_malloc(size_t);
|
||||
extern "C" void __real_free(void*);
|
||||
extern "C" void* __wrap_malloc(size_t size) {
|
||||
void* result = __real_malloc(size);
|
||||
if (result) {
|
||||
const auto free_slot = std::find(allocations.begin(), allocations.end(), nullptr);
|
||||
assert(free_slot != allocations.end());
|
||||
const size_t index = free_slot - allocations.begin();
|
||||
allocations[index] = result; allocation_sizes[index] = size;
|
||||
allocated += size; peak = std::max(peak, allocated);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
extern "C" void __wrap_free(void* result) {
|
||||
const auto found = std::find(allocations.begin(), allocations.end(), result);
|
||||
if (result && found != allocations.end()) {
|
||||
const size_t index = found - allocations.begin();
|
||||
allocated -= allocation_sizes[index]; allocations[index] = nullptr;
|
||||
}
|
||||
__real_free(result);
|
||||
}
|
||||
|
||||
struct Wire {
|
||||
bool admit = true;
|
||||
unsigned attempts = 0;
|
||||
std::vector<std::vector<uint8_t>> messages;
|
||||
static bool send(void* raw, const uint8_t* message, uint16_t length, bool) {
|
||||
auto& self = *static_cast<Wire*>(raw);
|
||||
++self.attempts;
|
||||
if (!self.admit) return false;
|
||||
self.messages.emplace_back(message, message + length);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
struct Store : OtaStoreRam<4096> {
|
||||
unsigned plans = 0, begins = 0, writes = 0;
|
||||
bool plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) override { ++plans; return true; }
|
||||
bool begin(uint32_t size) override { ++begins; return OtaStoreRam<4096>::begin(size); }
|
||||
bool write(uint32_t offset, const uint8_t* bytes, uint32_t size) override {
|
||||
++writes; return OtaStoreRam<4096>::write(offset, bytes, size);
|
||||
}
|
||||
};
|
||||
struct TrustedPolicy {
|
||||
SignerAllowlist allow;
|
||||
unsigned calls = 0;
|
||||
static bool admit(void* raw, const MotaManifest& manifest) {
|
||||
auto& policy = *static_cast<TrustedPolicy*>(raw);
|
||||
++policy.calls;
|
||||
return ota_manifest_trusted(manifest, policy.allow);
|
||||
}
|
||||
};
|
||||
static std::vector<uint8_t> signed_manifest(TrustedPolicy& policy) {
|
||||
std::vector<uint8_t> result(SIM_MOTA + 8, SIM_MOTA + 8 + MOTA_MFL);
|
||||
uint8_t secret[32];
|
||||
for (unsigned i = 0; i < sizeof(secret); ++i) secret[i] = uint8_t(i + 1);
|
||||
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, secret, sizeof(secret));
|
||||
assert(key);
|
||||
size_t public_size = 32;
|
||||
assert(EVP_PKEY_get_raw_public_key(key, result.data() + 97, &public_size) == 1);
|
||||
assert(public_size == 32 && policy.allow.add(result.data() + 97));
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
size_t signature_size = 64;
|
||||
assert(context && EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
|
||||
assert(EVP_DigestSign(context, result.data() + 129, &signature_size,
|
||||
result.data(), MOTA_SIGNED_LEN) == 1);
|
||||
assert(signature_size == 64);
|
||||
EVP_MD_CTX_free(context); EVP_PKEY_free(key);
|
||||
return result;
|
||||
}
|
||||
static void deliver_manifest(OtaManager& manager, const uint8_t mid[4], const uint8_t* bytes) {
|
||||
for (unsigned fragment = 0; fragment < 2; ++fragment) {
|
||||
ManifestMsg message{};
|
||||
memcpy(message.manifest_id, mid, 4);
|
||||
message.frag_idx = fragment; message.frag_total = 2;
|
||||
message.bytes = bytes + fragment * OTA_MF_FRAG;
|
||||
message.len = fragment ? MOTA_MFL - OTA_MF_FRAG : OTA_MF_FRAG;
|
||||
uint8_t wire[256];
|
||||
manager.on_message(wire, encode_manifest(wire, sizeof(wire), message));
|
||||
}
|
||||
}
|
||||
static void have(OtaManager& manager, const MotaManifest& manifest, uint32_t source = 1) {
|
||||
uint8_t row[OTA_HAVE_ROW_BYTES]{};
|
||||
memcpy(row, manifest.merkle_root, 4);
|
||||
wr_u32le(row + 4, manifest.target_id); wr_u32le(row + 8, manifest.fw_version);
|
||||
row[12] = manifest.codec_id; row[13] = manifest.flags; wr_u16le(row + 14, 1);
|
||||
HaveMsg message{};
|
||||
wr_u32le(message.seeder_id, source); message.frag_total = 1;
|
||||
message.n_rows = 1; message.rows = row;
|
||||
uint8_t wire[256];
|
||||
manager.on_message(wire, encode_have(wire, sizeof(wire), message));
|
||||
}
|
||||
static MotaManifest parse(const std::vector<uint8_t>& bytes) {
|
||||
MotaManifest manifest{};
|
||||
assert(mota_parse_manifest(bytes.data(), bytes.size(), manifest));
|
||||
return manifest;
|
||||
}
|
||||
static void retain_existing(Store& store) {
|
||||
assert(store.begin(SIM_MOTA_LEN) && store.write(0, SIM_MOTA, SIM_MOTA_LEN));
|
||||
store.plans = store.begins = store.writes = 0;
|
||||
}
|
||||
static void intact(const Store& store) {
|
||||
assert(store.plans == 0 && store.begins == 0 && store.writes == 0);
|
||||
assert(store.staged_size() == SIM_MOTA_LEN);
|
||||
assert(!memcmp(store.data(), SIM_MOTA, SIM_MOTA_LEN));
|
||||
}
|
||||
static void signature_admission() {
|
||||
TrustedPolicy policy;
|
||||
const auto good = signed_manifest(policy);
|
||||
const auto valid = parse(good);
|
||||
assert(ota_manifest_trusted(valid, policy.allow));
|
||||
for (unsigned scenario = 0; scenario < 5; ++scenario) {
|
||||
auto bytes = good;
|
||||
TrustedPolicy configured = policy;
|
||||
if (scenario == 1) configured.allow.clear();
|
||||
if (scenario == 2) bytes[129] ^= 1; // signature corruption
|
||||
if (scenario == 3) bytes[57] ^= 1; // signed hardware identity corruption
|
||||
if (scenario == 4) bytes[1] &= uint8_t(~MFLAG_SIGNED);
|
||||
const auto manifest = parse(bytes);
|
||||
Store store; retain_existing(store);
|
||||
Wire wire; OtaManager manager;
|
||||
manager.begin(valid.target_id, Wire::send, &wire);
|
||||
manager.set_fetch_store(&store);
|
||||
manager.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
||||
if (scenario != 0) manager.set_manifest_admission(TrustedPolicy::admit, &configured);
|
||||
have(manager, valid);
|
||||
assert(manager.fetchState() == OtaManager::WANT_MANIFEST);
|
||||
const unsigned checks = mesh::signature_checks;
|
||||
deliver_manifest(manager, valid.merkle_root, bytes.data());
|
||||
assert(manager.fetchState() == OtaManager::FAILED);
|
||||
assert(manager.fetchError() == OtaManager::FETCH_ERROR_MANIFEST);
|
||||
intact(store);
|
||||
if (scenario == 0 || scenario == 1 || scenario == 4) assert(mesh::signature_checks == checks);
|
||||
else assert(mesh::signature_checks == checks + 1);
|
||||
if (scenario != 4) assert(!ota_manifest_trusted(manifest, configured.allow) || scenario == 0);
|
||||
}
|
||||
// Approval is unsigned local authorization and must not survive peer ingress.
|
||||
auto approved = good;
|
||||
memset(approved.data() + 193, 0xA5, 4);
|
||||
Store store; retain_existing(store); Wire wire; OtaManager manager;
|
||||
manager.begin(valid.target_id, Wire::send, &wire);
|
||||
manager.set_fetch_store(&store);
|
||||
manager.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
||||
manager.set_manifest_admission(TrustedPolicy::admit, &policy);
|
||||
have(manager, valid);
|
||||
deliver_manifest(manager, valid.merkle_root, approved.data());
|
||||
assert(manager.fetchState() == OtaManager::FETCHING);
|
||||
assert(store.plans == 1 && store.begins == 1);
|
||||
MotaManifest staged;
|
||||
assert(mota_parse_manifest(store.data() + 8, MOTA_MFL, staged));
|
||||
assert(ota_manifest_trusted(staged, policy.allow));
|
||||
assert(!staged.is_approved());
|
||||
// NOR flash can only clear bits. The unapproved marker must stay erased so
|
||||
// the separate authorized local installer can program APPROVAL_YES later.
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
assert(staged.approval[i] == 0xFF);
|
||||
assert((staged.approval[i] & APPROVAL_YES[i]) == APPROVAL_YES[i]);
|
||||
}
|
||||
// Automatic reboot resume has the same signature gate; manual capture is
|
||||
// permitted independently and never grants automatic install approval.
|
||||
const uint8_t corrupt_signature = store.data()[8 + 129] ^ 1u;
|
||||
assert(store.write(8 + 129, &corrupt_signature, 1));
|
||||
manager.reset_session();
|
||||
assert(!manager.resumeStaged(nullptr));
|
||||
assert(manager.pull(valid.merkle_root, valid.target_id) == OtaManager::PULL_RESUMED);
|
||||
assert(manager.fetchState() == OtaManager::VERIFYING_STAGED);
|
||||
}
|
||||
static void failure_cooldown(bool rollover) {
|
||||
TrustedPolicy policy;
|
||||
auto bytes = signed_manifest(policy);
|
||||
bytes[129] ^= 1;
|
||||
const auto manifest = parse(bytes);
|
||||
Store store; retain_existing(store); Wire wire; OtaManager manager;
|
||||
manager.begin(manifest.target_id, Wire::send, &wire);
|
||||
manager.set_fetch_store(&store);
|
||||
manager.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
||||
manager.set_manifest_admission(TrustedPolicy::admit, &policy);
|
||||
uint32_t now = rollover ? UINT32_MAX - 30000u : 100u;
|
||||
uint32_t delay = OTA_AUTOFETCH_FAILURE_BACKOFF_MS;
|
||||
for (unsigned attempt = 0; attempt < 8; ++attempt) {
|
||||
manager.set_clock(now);
|
||||
have(manager, manifest);
|
||||
assert(manager.fetchState() == OtaManager::WANT_MANIFEST);
|
||||
deliver_manifest(manager, manifest.merkle_root, bytes.data());
|
||||
assert(manager.fetchState() == OtaManager::FAILED);
|
||||
intact(store);
|
||||
const unsigned admitted = policy.calls;
|
||||
// Neither a repeated source nor rotating a forged MID bypasses cooldown.
|
||||
auto rotated = bytes; rotated[20] ^= 0x42;
|
||||
const auto other = parse(rotated);
|
||||
for (unsigned packet = 0; packet < 100; ++packet) {
|
||||
have(manager, packet % 2 ? other : manifest, packet + 2);
|
||||
assert(manager.fetchState() == OtaManager::FAILED);
|
||||
}
|
||||
assert(policy.calls == admitted);
|
||||
manager.set_clock(now + delay - 1u);
|
||||
have(manager, manifest);
|
||||
assert(manager.fetchState() == OtaManager::FAILED);
|
||||
now += delay;
|
||||
if (delay > OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS / 2u) delay = OTA_AUTOFETCH_FAILURE_MAX_BACKOFF_MS;
|
||||
else delay *= 2u;
|
||||
}
|
||||
const unsigned admitted = policy.calls;
|
||||
// The authorized manual pull works during cooldown, without silently
|
||||
// altering the signed-only automatic policy or installation trust gate.
|
||||
auto manual_bytes = bytes; manual_bytes[20] ^= 0x42;
|
||||
const auto manual_manifest = parse(manual_bytes);
|
||||
assert(manager.pull(manual_manifest.merkle_root, manual_manifest.target_id) == OtaManager::PULL_STARTED);
|
||||
deliver_manifest(manager, manual_manifest.merkle_root, manual_bytes.data());
|
||||
assert(manager.fetchState() == OtaManager::FETCHING && policy.calls == admitted);
|
||||
MotaManifest staged;
|
||||
assert(mota_parse_manifest(store.data() + 8, MOTA_MFL, staged));
|
||||
assert(!ota_manifest_trusted(staged, policy.allow));
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> container(uint32_t blocks) {
|
||||
const uint32_t block_size = 128, payload_size = blocks * block_size;
|
||||
const uint32_t size = 8 + MOTA_MFL + blocks * 4 + payload_size + 5;
|
||||
std::vector<uint8_t> image(size, 0);
|
||||
memcpy(image.data(), MOTA_MAGIC, 4); wr_u32le(image.data() + 4, size);
|
||||
uint8_t* manifest = image.data() + 8;
|
||||
memcpy(manifest, SIM_MOTA + 8, MOTA_MFL);
|
||||
manifest[1] = MFLAG_FULL; manifest[2] = HASH_ALGO_SHA256;
|
||||
manifest[19] = 7;
|
||||
wr_u32le(manifest + 11, payload_size); wr_u32le(manifest + 15, payload_size);
|
||||
// codec is offset56 in the fixed manifest.
|
||||
manifest[56] = CODEC_FULL;
|
||||
uint8_t* leaves = manifest + MOTA_MFL;
|
||||
uint8_t* payload = leaves + blocks * 4;
|
||||
for (uint32_t i = 0; i < payload_size; ++i) payload[i] = uint8_t(i * 17u);
|
||||
for (uint32_t i = 0; i < blocks; ++i) merkle_leaf(leaves + i * 4, payload + i * block_size, block_size);
|
||||
merkle_root(manifest + 20, leaves, blocks);
|
||||
memcpy(image.data() + size - 5, MOTA_TRAILER, 5);
|
||||
MotaManifest parsed; assert(mota_parse(image.data(), size, parsed));
|
||||
assert(parsed.block_count == blocks);
|
||||
return image;
|
||||
}
|
||||
static bool leaves(OtaManager& manager, const uint8_t* mid, uint16_t mask, OtaReplyRoute route = {}) {
|
||||
GetLeavesMsg request{};
|
||||
memcpy(request.manifest_id, mid, 4); request.want_mask = mask;
|
||||
uint8_t wire[32];
|
||||
return manager.on_message(wire, encode_get_leaves(wire, sizeof(wire), request), route);
|
||||
}
|
||||
static bool route_valid(void* current, const OtaReplyRoute& route) {
|
||||
return route == *static_cast<OtaReplyRoute*>(current);
|
||||
}
|
||||
static void leaf_pacing(bool rollover) {
|
||||
const auto image = container(OTA_DIFF_MAX_BLOCKS);
|
||||
MotaManifest manifest; assert(mota_parse(image.data(), image.size(), manifest));
|
||||
Wire wire; OtaManager manager;
|
||||
manager.begin(1, Wire::send, &wire); assert(manager.serve(image.data(), image.size()));
|
||||
uint32_t now = rollover ? UINT32_MAX - 50u : 100u;
|
||||
manager.set_clock(now);
|
||||
OtaReplyRoute route; route.profile = 1; route.generation = 7;
|
||||
assert(leaves(manager, manifest.merkle_root, 0xFFFF, route));
|
||||
assert(manager.pendingManifestJobs() == 1 && wire.messages.empty());
|
||||
for (unsigned i = 0; i < 100; ++i) assert(leaves(manager, manifest.merkle_root, 0xFFFF, route));
|
||||
assert(manager.pendingManifestJobs() == 1 && wire.messages.empty());
|
||||
manager.serviceEgress(route_valid, &route);
|
||||
assert(wire.attempts == 0); // turnaround deadline has not expired
|
||||
now += 100;
|
||||
manager.set_clock(now); wire.admit = false;
|
||||
for (unsigned i = 0; i < 100; ++i) manager.serviceEgress(route_valid, &route);
|
||||
assert(wire.messages.empty() && manager.pendingManifestJobs() == 1);
|
||||
wire.admit = true;
|
||||
for (unsigned fragment = 0; fragment < 16; ++fragment) {
|
||||
const unsigned before = wire.messages.size();
|
||||
manager.serviceEgress(route_valid, &route);
|
||||
assert(wire.messages.size() == before + 1);
|
||||
LeavesMsg reply;
|
||||
assert(decode_leaves(wire.messages.back().data(), wire.messages.back().size(), reply));
|
||||
assert(reply.frag_idx == fragment && reply.frag_total == 16);
|
||||
assert(!memcmp(reply.bytes, manifest.leaves + fragment * OTA_LEAVES_FRAG, reply.len));
|
||||
if (fragment < 15) {
|
||||
assert(leaves(manager, manifest.merkle_root, 0xFFFF, route));
|
||||
manager.serviceEgress(route_valid, &route);
|
||||
assert(wire.messages.size() == before + 1); // duplicates never reset/send emitted bits
|
||||
}
|
||||
now += 100; manager.set_clock(now);
|
||||
}
|
||||
assert(manager.pendingManifestJobs() == 0 && wire.messages.size() == 16);
|
||||
// Retry exactly the requested holes, without synchronous transmission.
|
||||
assert(leaves(manager, manifest.merkle_root, 0x8001, route));
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
now += 100; manager.set_clock(now); manager.serviceEgress(route_valid, &route);
|
||||
LeavesMsg reply;
|
||||
assert(decode_leaves(wire.messages.back().data(), wire.messages.back().size(), reply));
|
||||
assert(reply.frag_idx == (i ? 15 : 0));
|
||||
}
|
||||
assert(manager.pendingManifestJobs() == 0);
|
||||
// Closed temporary radio generations and explicit teardown cannot retain
|
||||
// stale queued work or reply on another profile.
|
||||
assert(leaves(manager, manifest.merkle_root, 1, route));
|
||||
++route.generation;
|
||||
const auto before = wire.messages.size();
|
||||
manager.serviceEgress(route_valid, &route);
|
||||
assert(manager.pendingManifestJobs() == 0 && wire.messages.size() == before);
|
||||
assert(leaves(manager, manifest.merkle_root, 1, route));
|
||||
manager.clearPendingEgress();
|
||||
assert(manager.pendingManifestJobs() == 0);
|
||||
assert(!leaves(manager, manifest.merkle_root, 0, route));
|
||||
const auto oversized = container(OTA_DIFF_MAX_BLOCKS + 1);
|
||||
assert(manager.serve(oversized.data(), oversized.size()));
|
||||
MotaManifest large; assert(mota_parse(oversized.data(), oversized.size(), large));
|
||||
assert(!leaves(manager, large.merkle_root, 0xFFFF, route));
|
||||
assert(manager.pendingManifestJobs() == 0);
|
||||
}
|
||||
static void catalog(OtaManager& manager, uint32_t first, uint32_t last) {
|
||||
for (uint32_t id = first; id < last; ++id) {
|
||||
uint8_t row[OTA_HAVE_ROW_BYTES]{};
|
||||
wr_u32le(row, id + 1); wr_u32le(row + 4, 123);
|
||||
row[12] = CODEC_FULL; row[13] = MFLAG_FULL;
|
||||
HaveMsg message{}; wr_u32le(message.seeder_id, 42);
|
||||
message.frag_total = 1; message.n_rows = 1; message.rows = row;
|
||||
uint8_t wire[256];
|
||||
manager.on_message(wire, encode_have(wire, sizeof(wire), message));
|
||||
}
|
||||
}
|
||||
static void catalog_budget() {
|
||||
assert(allocated == 0);
|
||||
{
|
||||
OtaManager manager; manager.begin(123, nullptr, nullptr);
|
||||
catalog(manager, 0, 255);
|
||||
assert(manager.catalogCount() == OTA_INLINE_CATALOG && allocated == 0);
|
||||
manager.queryAll(); catalog(manager, 0, 255);
|
||||
assert(manager.catalogCount() == OTA_MAX_CATALOG);
|
||||
assert(allocated == sizeof(OtaManager::CatRow) * OTA_MAX_CATALOG && allocated <= 16384);
|
||||
AdvMsg withdrawal{}; wr_u32le(withdrawal.seeder_id, 42);
|
||||
uint8_t wire[64]; manager.on_message(wire, encode_adv(wire, sizeof(wire), withdrawal));
|
||||
assert(manager.catalogCount() == 0 && allocated == 0);
|
||||
catalog(manager, 0, 255);
|
||||
assert(allocated != 0);
|
||||
manager.begin(123, nullptr, nullptr);
|
||||
assert(manager.catalogCount() == 0 && allocated == 0);
|
||||
catalog(manager, 0, 255);
|
||||
assert(manager.catalogCount() == OTA_INLINE_CATALOG && allocated == 0);
|
||||
}
|
||||
assert(allocated == 0);
|
||||
}
|
||||
static void proof_noise() {
|
||||
MotaManifest manifest; assert(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
Store store; Wire wire; OtaManager manager;
|
||||
manager.begin(manifest.target_id, Wire::send, &wire);
|
||||
manager.set_fetch_store(&store); manager.set_clock(100);
|
||||
manager.set_wire_v2_enabled(false);
|
||||
assert(manager.pull(manifest.merkle_root, manifest.target_id) == OtaManager::PULL_STARTED);
|
||||
deliver_manifest(manager, manifest.merkle_root, manifest.manifest_start);
|
||||
uint8_t packet[256];
|
||||
const uint32_t size = manifest.block_size();
|
||||
for (uint32_t offset = 0; offset < size; offset += OTA_FRAG_DATA) {
|
||||
DataMsg data{}; memcpy(data.manifest_id, manifest.merkle_root, 4);
|
||||
data.block_idx = 0; data.frag_off = offset;
|
||||
data.data = manifest.payload + offset; data.data_len = std::min(size - offset, uint32_t(OTA_FRAG_DATA));
|
||||
manager.on_message(packet, encode_data(packet, sizeof(packet), data));
|
||||
}
|
||||
std::array<uint8_t, 128> siblings{};
|
||||
const uint8_t count = merkle_gen_proof(manifest.leaves, manifest.block_count, 0, nullptr, siblings.data());
|
||||
assert(count > 0);
|
||||
ProofMsg proof{}; memcpy(proof.manifest_id, manifest.merkle_root, 4);
|
||||
proof.block_idx = 0; proof.n_proof = count; proof.proof = siblings.data();
|
||||
siblings[0] ^= 1;
|
||||
const unsigned before = wire.messages.size();
|
||||
for (unsigned i = 0; i < 100; ++i) manager.on_message(packet, encode_proof(packet, sizeof(packet), proof));
|
||||
assert(manager.blocksHave() == 0 && wire.messages.size() == before);
|
||||
siblings[0] ^= 1;
|
||||
manager.on_message(packet, encode_proof(packet, sizeof(packet), proof));
|
||||
assert(manager.blocksHave() == 1); // forged proof did not erase valid DATA
|
||||
}
|
||||
int main() {
|
||||
signature_admission();
|
||||
failure_cooldown(false); failure_cooldown(true);
|
||||
leaf_pacing(false); leaf_pacing(true);
|
||||
catalog_budget(); proof_noise();
|
||||
assert(allocated == 0);
|
||||
puts("PASS: authenticated automatic staging, bounded catalog, paced leaves and proof-noise recovery");
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
struct String : std::string {
|
||||
using std::string::string;
|
||||
String(std::string value) : std::string(std::move(value)) {}
|
||||
int indexOf(const char* value) const { auto pos=find(value); return pos==npos ? -1 : int(pos); }
|
||||
String substring(int begin, int end) const { return substr(begin, end-begin); }
|
||||
};
|
||||
static uint32_t now_ms=10, random_word=0x12345678;
|
||||
uint32_t millis() { return now_ms; }
|
||||
uint32_t esp_random() { return ++random_word; }
|
||||
static constexpr uint32_t WEBCONFIG_SESSION_TTL_MS=10000;
|
||||
struct NodeSnapshot { char admin_password[16] = {}; };
|
||||
struct Callback {
|
||||
NodeSnapshot node;
|
||||
void getNodeSnapshot(NodeSnapshot& result) { result=node; }
|
||||
};
|
||||
struct WCLock { explicit WCLock(int) {} };
|
||||
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
|
||||
struct Client { int local_ip=4; int localIP() const { return local_ip; } };
|
||||
struct Header { String cookies; const String& value() const { return cookies; } };
|
||||
struct AsyncWebServerResponse {
|
||||
int status; std::map<std::string, std::string> headers;
|
||||
void addHeader(const char* key, const char* value) { headers[key]=value; }
|
||||
};
|
||||
struct AsyncWebServerRequest {
|
||||
Client socket; bool has_client=true;
|
||||
Header header; std::string body; void* _tempObject=nullptr;
|
||||
int status=0; AsyncWebServerResponse response{};
|
||||
Client* client() { return has_client ? &socket : nullptr; }
|
||||
bool hasHeader(const char*) const { return !header.cookies.empty(); }
|
||||
Header* getHeader(const char*) { return &header; }
|
||||
void send(int code, const char* =nullptr, const char* =nullptr) { status=code; }
|
||||
AsyncWebServerResponse* beginResponse(int code, const char*, const char*) { response.status=code; return &response; }
|
||||
void send(AsyncWebServerResponse* value) { status=value->status; }
|
||||
void password(const char* value) { body=std::string("{\"password\":\"")+value+"\"}"; _tempObject=body.data(); }
|
||||
};
|
||||
struct WebConfigServer {
|
||||
enum { MODE_OFF, MODE_SETUP, MODE_LAN };
|
||||
int _mode=MODE_SETUP, _mux=0;
|
||||
char _wifi_ssid[33]="SavedNet", _session_token[33] = {};
|
||||
bool _initial_setup=false, config_accepted=false;
|
||||
uint32_t _last_activity=0, _session_last_seen=0, _login_lock_until=0;
|
||||
int _login_fails=0; Callback callback; Callback* _cb=&callback;
|
||||
WebConfigServer() { strcpy(callback.node.admin_password, "existing-secret"); }
|
||||
bool checkAuth(AsyncWebServerRequest*);
|
||||
void handleLogin(AsyncWebServerRequest*);
|
||||
void handleLogout(AsyncWebServerRequest*);
|
||||
void checkConfigPost(AsyncWebServerRequest*);
|
||||
};
|
||||
@METHODS@
|
||||
int main() {
|
||||
WebConfigServer portal; AsyncWebServerRequest attacker;
|
||||
attacker.body="{\"set\":{\"password\":\"attacker\",\"wifi.ssid\":\"EvilNet\"}}";
|
||||
attacker._tempObject=attacker.body.data();
|
||||
assert(!portal.checkAuth(&attacker));
|
||||
portal.checkConfigPost(&attacker);
|
||||
assert(attacker.status==401 && !portal.config_accepted);
|
||||
attacker.password("wrong"); portal.handleLogin(&attacker);
|
||||
assert(attacker.status==401 && portal._session_token[0]==0);
|
||||
AsyncWebServerRequest admin; admin.password("existing-secret");
|
||||
portal.handleLogin(&admin); assert(admin.status==200);
|
||||
assert(strlen(portal._session_token)==32);
|
||||
assert(admin.response.headers["Set-Cookie"].find("HttpOnly; SameSite=Lax")!=std::string::npos);
|
||||
admin.header.cookies=std::string("wcs=")+portal._session_token;
|
||||
assert(portal.checkAuth(&admin)); portal.checkConfigPost(&admin);
|
||||
assert(portal.config_accepted);
|
||||
std::string valid_token=portal._session_token;
|
||||
portal.handleLogout(&attacker);
|
||||
assert(attacker.status==401 && valid_token==portal._session_token);
|
||||
admin.socket.local_ip=9;
|
||||
assert(!portal.checkAuth(&admin));
|
||||
admin.password("existing-secret"); portal.handleLogin(&admin);
|
||||
assert(admin.status==401); // recovery must not authorize a recovered STA/LAN
|
||||
admin.socket.local_ip=4;
|
||||
admin.has_client=false; assert(!portal.checkAuth(&admin)); admin.has_client=true;
|
||||
now_ms+=WEBCONFIG_SESSION_TTL_MS+1; assert(!portal.checkAuth(&admin));
|
||||
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
|
||||
admin.header.cookies=std::string("wcs=")+portal._session_token;
|
||||
portal.handleLogout(&admin); assert(admin.status==200 && portal._session_token[0]==0);
|
||||
assert(!portal.checkAuth(&admin));
|
||||
portal._mode=WebConfigServer::MODE_LAN; admin.socket.local_ip=9;
|
||||
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
|
||||
admin.header.cookies=std::string("wcs=")+portal._session_token; assert(portal.checkAuth(&admin));
|
||||
portal._mode=WebConfigServer::MODE_OFF; assert(!portal.checkAuth(&admin));
|
||||
portal.handleLogin(&admin); assert(admin.status==503);
|
||||
// First provisioning remains possible and retains its AP-only privilege
|
||||
// through a save batch that changes SSID before the confirmation arrives.
|
||||
portal._mode=WebConfigServer::MODE_SETUP; portal._wifi_ssid[0]=0;
|
||||
portal._initial_setup=true; assert(portal.checkAuth(&attacker));
|
||||
strcpy(portal._wifi_ssid, "ProvisionedNet"); assert(portal.checkAuth(&attacker));
|
||||
portal.handleLogin(&attacker); assert(attacker.status==200);
|
||||
attacker.socket.local_ip=9; assert(!portal.checkAuth(&attacker));
|
||||
portal.handleLogin(&attacker); assert(attacker.status==401); attacker.socket.local_ip=4;
|
||||
// Companion images deliberately have no administrator password or CLI
|
||||
// password management. Keep that explicit no-password deployment policy.
|
||||
portal._initial_setup=false; portal.callback.node.admin_password[0]=0;
|
||||
assert(portal.checkAuth(&attacker)); portal.handleLogin(&attacker); assert(attacker.status==200);
|
||||
puts("WebConfig recovery authentication regression checks passed");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run both production unknown-config fallbacks with real sanitizer bounds.
|
||||
|
||||
The final CommonCLI setter branch and nested observer MQTT-slot branch are
|
||||
extracted verbatim. Hardware-dependent recognized setters are excluded. Negative
|
||||
controls restore the audited legacy sprintf statement and must cause a genuine
|
||||
AddressSanitizer overflow for the same maximum-size inputs.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/cli_error_bounds/main.cpp"
|
||||
|
||||
|
||||
def fallback(method):
|
||||
marker = method.index('"unknown config"')
|
||||
assert method.count('"unknown config"') == 1
|
||||
start = method.rfind("} else {", 0, marker)
|
||||
assert start >= 0, "unknown-setting branch moved; review the fixture"
|
||||
branch = body(method[start + 2:], "else")
|
||||
assert '"unknown config"' in branch
|
||||
return branch
|
||||
|
||||
|
||||
class CliErrorBoundsTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise RuntimeError("CLI reply bounds require a C++17 sanitizer compiler")
|
||||
common = body((ROOT / "src/helpers/CommonCLI.cpp").read_text(),
|
||||
"void CommonCLI::handleSetCmd(")
|
||||
observer = body((ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text(),
|
||||
"bool CommonCLI::handleObserverSetCmd(")
|
||||
config = re.search(r"const char\* config = &command\[4\];", common)
|
||||
assert config, "production setter command prefix moved"
|
||||
common_branch, observer_branch = fallback(common), fallback(observer)
|
||||
template = FIXTURE.read_text(encoding="ascii")
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-cli-error-bounds-")
|
||||
cls.binaries = {}
|
||||
for legacy in (False, True):
|
||||
source = template.replace("@COMMON_CONFIG@", config.group(0))
|
||||
source = source.replace("@COMMON_FALLBACK@", common_branch)
|
||||
source = source.replace("@OBSERVER_FALLBACK@", observer_branch)
|
||||
if legacy:
|
||||
previous = 'strcpy(reply, "unknown config");'
|
||||
assert source.count(previous) == 2, "update the old formatter negative control"
|
||||
source = source.replace(previous, 'sprintf(reply, "unknown config: %s", config);')
|
||||
directory = Path(cls.work.name)
|
||||
path = directory / ("legacy.cpp" if legacy else "production.cpp")
|
||||
binary = directory / ("legacy" if legacy else "production")
|
||||
path.write_text(source, encoding="ascii")
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
|
||||
str(path), "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
cls.binaries[legacy] = binary
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def run_case(self, scenario, legacy=False):
|
||||
for parser in ("common", "observer"):
|
||||
with self.subTest(parser=parser, legacy=legacy):
|
||||
environment = dict(os.environ, ASAN_OPTIONS="detect_leaks=0:abort_on_error=0")
|
||||
result = subprocess.run([str(self.binaries[legacy]), parser, scenario],
|
||||
capture_output=True, text=True, timeout=10, env=environment)
|
||||
if legacy:
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("AddressSanitizer", result.stderr)
|
||||
self.assertIn("buffer-overflow", result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_maximum_local_command_fits_exact_160_byte_reply_and_preserves_canaries(self):
|
||||
self.run_case("local")
|
||||
|
||||
def test_maximum_radio_command_preserves_reply_path_after_exact_179_byte_window(self):
|
||||
self.run_case("radio")
|
||||
|
||||
def test_error_fits_the_exact_literal_size_even_with_maximum_command(self):
|
||||
self.run_case("minimum")
|
||||
|
||||
def test_input_lengths_through_four_kilobytes_never_change_reply_or_touch_path(self):
|
||||
self.run_case("lengths")
|
||||
|
||||
def test_previous_formatter_fails_sanitizer_for_maximum_local_command(self):
|
||||
self.run_case("local", legacy=True)
|
||||
|
||||
def test_previous_formatter_fails_sanitizer_for_maximum_radio_command(self):
|
||||
self.run_case("radio", legacy=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -508,9 +508,47 @@ static int fragmentScenario(const std::string& scenario){
|
||||
}else if(scenario=="fragments_fresh_retry"){
|
||||
assert(f.feed(a)==0);f.partial();auto newer=envelope(f.publisher,seq+1,f.expected);
|
||||
Packet fresh_first=part(newer,1),fresh_second=part(newer,0);
|
||||
assert(f.feed(fresh_first)==0);f.partial();
|
||||
assert(f.feed(b)==0);f.partial();assert(f.feed(fresh_second)==1&&f.reserved()==seq+1);
|
||||
assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==1&&f.mesh.queued==1);
|
||||
assert(f.feed(fresh_first)==0&&f.stats().find("parts=2")!=std::string::npos);
|
||||
// The unsigned newer metadata does not invalidate an authentic older
|
||||
// envelope. Its signed sequence still passes the durable monotonic gate.
|
||||
assert(f.feed(b)==1&&f.reserved()==seq&&f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(fresh_second)==0&&f.feed(fresh_first)==1&&f.reserved()==seq+1);
|
||||
assert(f.feed(a)==0&&f.feed(b)==0&&f.calls==2&&f.mesh.queued==2);
|
||||
}else if(scenario=="fragments_future_poison"){
|
||||
for(unsigned reverse=0;reverse<2;++reverse){
|
||||
FragmentFixture x;auto encoded=envelope(x.publisher,seq,x.expected);
|
||||
Packet first=part(encoded,reverse?1:0),second=part(encoded,reverse?0:1);
|
||||
Packet poison=changePart(first,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
||||
const auto saved=x.disk.files.at(Path);const unsigned verifies=verify_calls;
|
||||
assert(x.feed(poison,nullptr,0)==0&&x.feed(first,nullptr,0)==0);
|
||||
assert(x.stats().find("parts=2")!=std::string::npos&&x.disk.files.at(Path)==saved);
|
||||
assert(x.feed(poison,nullptr,0)==0&&verify_calls==verifies);
|
||||
assert(x.feed(second,nullptr,0)==1&&x.reserved()==seq&&verify_calls==verifies+1);
|
||||
assert(x.stats().find("parts=0")!=std::string::npos&&x.feed(poison)==0);
|
||||
assert(x.feed(first)==0&&x.feed(second)==0&&x.calls==1);
|
||||
FleetChannel rebooted(&x.disk);unsigned calls=0;
|
||||
for(Packet* packet:{&first,&second}){rebooted.receive(packet,x.mesh);
|
||||
rebooted.service(x.mesh,x.profiles,"node",[&](uint32_t,const char*,char*){++calls;});}
|
||||
assert(calls==0&&x.reserved()==seq);
|
||||
}
|
||||
}else if(scenario=="fragments_oldest_eviction"){
|
||||
Packet poison_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
||||
Packet poison_b=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+59);});
|
||||
assert(f.feed(poison_a)==0&&f.feed(poison_b)==0);
|
||||
assert(f.stats().find("parts=2")!=std::string::npos);
|
||||
// A third transfer replaces the oldest slot despite its higher sequence.
|
||||
assert(f.feed(a)==0&&f.feed(b)==1&&f.reserved()==seq&&f.calls==1);
|
||||
assert(f.stats().find("parts=0")!=std::string::npos);
|
||||
}else if(scenario=="fragments_independent_failure"){
|
||||
Packet poison_a=changePart(a,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
||||
Packet poison_b=changePart(b,[&](uint8_t* frame){storage::writeLE32(frame+4,seq+60);});
|
||||
assert(f.feed(a)==0&&f.feed(poison_a)==0&&f.feed(poison_b)==0);
|
||||
f.partial();assert(f.feed(b)==1&&f.reserved()==seq&&f.calls==1);
|
||||
}else if(scenario=="fragments_budget_partial"){
|
||||
const unsigned verifies=verify_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(f.feed(a,nullptr,0)==0);
|
||||
assert(verify_calls==verifies&&f.feed(b,nullptr,0)==1);
|
||||
assert(verify_calls==verifies+1&&f.reserved()==seq);
|
||||
}else if(scenario=="fragments_tamper"){
|
||||
Packet mac=a;mac.payload[1]^=1;assert(f.feed(mac)==0&&f.stats().find("parts=0")!=std::string::npos);
|
||||
assert(f.feed(a)==0);f.partial();const auto saved=f.disk.files.at(Path);
|
||||
@@ -1037,11 +1075,38 @@ int main(int argc,char** argv){
|
||||
Packet mine=command(publisher,mesh.clock.now,"set radio2 off",target);assert(apply(fleet,fs,mesh,profiles,mine)==1);
|
||||
assert(last_jitter_max==1500);
|
||||
}else if(scenario=="verify_budget"){
|
||||
enroll(fleet,publisher);Packet packet=command(publisher,mesh.clock.now);
|
||||
packet.payload[1]^=1;unsigned before=decrypt_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,packet)==0);
|
||||
assert(decrypt_calls-before==4);fake_ms+=1000;packet=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,packet)==1);
|
||||
enroll(fleet,publisher);LocalIdentity stranger;
|
||||
Packet forged=command(stranger,mesh.clock.now);
|
||||
const unsigned before=verify_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,forged)==0);
|
||||
assert(verify_calls-before==4);fake_ms+=1000;
|
||||
Packet valid=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,valid)==1);
|
||||
}else if(scenario=="invalid_mac_cannot_starve"){
|
||||
enroll(fleet,publisher);Packet valid=command(publisher,mesh.clock.now);
|
||||
Packet garbage=valid;garbage.payload[1]^=1;
|
||||
const unsigned before=verify_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,garbage)==0);
|
||||
assert(verify_calls==before);
|
||||
assert(apply(fleet,fs,mesh,profiles,valid)==1);
|
||||
assert(verify_calls==before+1&&mesh.queued==1);
|
||||
}else if(scenario=="replay_cannot_starve"){
|
||||
enroll(fleet,publisher);const uint32_t seq=mesh.clock.now;
|
||||
Packet previous=command(publisher,seq);
|
||||
assert(apply(fleet,fs,mesh,profiles,previous)==1);
|
||||
fake_ms+=1000;const unsigned before=verify_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,previous)==0);
|
||||
assert(verify_calls==before);
|
||||
Packet next=command(publisher,seq+1);
|
||||
assert(apply(fleet,fs,mesh,profiles,next)==1&&verify_calls==before+1);
|
||||
}else if(scenario=="wrong_target_cannot_starve"){
|
||||
enroll(fleet,publisher);LocalIdentity other;
|
||||
Packet not_mine=command(publisher,mesh.clock.now,"get radio2",publicHex(other).c_str());
|
||||
const unsigned before=verify_calls;
|
||||
for(unsigned i=0;i<20;++i)assert(apply(fleet,fs,mesh,profiles,not_mine)==0);
|
||||
assert(verify_calls==before);
|
||||
Packet valid=command(publisher,mesh.clock.now);
|
||||
assert(apply(fleet,fs,mesh,profiles,valid)==1&&verify_calls==before+1);
|
||||
}else if(scenario=="primary_denied"){
|
||||
assert(!FleetCommand::commandAllowed("set radio 915,500,5,5"));
|
||||
assert(!FleetCommand::commandAllowed("set tempradio 915,500,5,5,10"));
|
||||
@@ -1410,9 +1475,18 @@ class SHA256 : public SHA256Base {{ public:
|
||||
def test_real_signature_mac_padding_and_full_identity_target_checks(self):
|
||||
self.scenario("tamper_padding_target")
|
||||
|
||||
def test_forged_packets_have_bounded_verification_cost(self):
|
||||
def test_forged_mac_authenticated_packets_have_bounded_signature_verification_cost(self):
|
||||
self.scenario("verify_budget")
|
||||
|
||||
def test_invalid_mac_packets_cannot_exhaust_the_signature_verification_budget(self):
|
||||
self.scenario("invalid_mac_cannot_starve")
|
||||
|
||||
def test_recorded_used_command_replays_cannot_spend_signature_budget_before_next_command(self):
|
||||
self.scenario("replay_cannot_starve")
|
||||
|
||||
def test_recorded_commands_for_another_target_cannot_spend_signature_budget(self):
|
||||
self.scenario("wrong_target_cannot_starve")
|
||||
|
||||
def test_fleet_permission_never_allows_primary_radio_or_administrator_changes(self):
|
||||
self.scenario("primary_denied")
|
||||
|
||||
@@ -1479,9 +1553,21 @@ class SHA256 : public SHA256Base {{ public:
|
||||
def test_conflicting_parts_cannot_destroy_a_valid_incomplete_transfer(self):
|
||||
self.scenario("fragments_conflicts")
|
||||
|
||||
def test_fresh_retry_preempts_missing_part_and_delayed_old_parts_cannot_pollute_it(self):
|
||||
def test_unsigned_newer_fragment_cannot_invalidate_an_older_authenticated_envelope(self):
|
||||
self.scenario("fragments_fresh_retry")
|
||||
|
||||
def test_forged_future_sequence_interleaved_with_both_legitimate_fragment_orders_cannot_lock_out(self):
|
||||
self.scenario("fragments_future_poison")
|
||||
|
||||
def test_third_transfer_evicts_oldest_slot_instead_of_highest_unsigned_sequence(self):
|
||||
self.scenario("fragments_oldest_eviction")
|
||||
|
||||
def test_failed_complete_poisoned_assembly_preserves_the_other_legitimate_slot(self):
|
||||
self.scenario("fragments_independent_failure")
|
||||
|
||||
def test_duplicate_partial_fragments_cannot_spend_signature_verification_budget(self):
|
||||
self.scenario("fragments_budget_partial")
|
||||
|
||||
def test_mac_and_last_signature_byte_tampering_never_authorize_partial_commands(self):
|
||||
self.scenario("fragments_tamper")
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise actual folder transactions against malicious/slow host streams."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MotaFolderDeadlineTest(unittest.TestCase):
|
||||
def compile_run(self, production, expect_rejection=False):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-mota-folder-deadline-") as temporary:
|
||||
work = Path(temporary)
|
||||
source = work / "FolderMotaStore.cpp"
|
||||
source.write_text(production)
|
||||
binary = work / "deadline"
|
||||
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
*sanitizers, "-I" + str(ROOT / "src"),
|
||||
"-I" + str(ROOT / "src/helpers/ota"),
|
||||
"-isystem", str(ROOT / "test/mocks"),
|
||||
str(source), str(ROOT / "test/fixtures/mota_folder_deadline.cpp"),
|
||||
"-o", str(binary)], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if expect_rejection:
|
||||
self.assertNotEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("Assertion", checked.stderr)
|
||||
else:
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
def test_single_response_deadline_and_bounded_pre_request_drain(self):
|
||||
self.compile_run((ROOT / "src/helpers/ota/FolderMotaStore.cpp").read_text())
|
||||
|
||||
def test_reintroduced_per_byte_timeout_is_rejected_at_runtime(self):
|
||||
source = (ROOT / "src/helpers/ota/FolderMotaStore.cpp").read_text()
|
||||
old = source.replace("bool FolderMotaStore::readByteT(uint8_t& b, uint32_t started) const {",
|
||||
"bool FolderMotaStore::readByteT(uint8_t& b, uint32_t started) const {\n started = millis();", 1)
|
||||
self.assertNotEqual(old, source)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
def test_reintroduced_unbounded_drain_is_rejected_at_runtime(self):
|
||||
source = (ROOT / "src/helpers/ota/FolderMotaStore.cpp").read_text()
|
||||
old = source.replace(" int pending = _io.available();\n while (pending-- > 0) if (_io.read() < 0) break;",
|
||||
" while (_io.read() >= 0) {}", 1)
|
||||
self.assertNotEqual(old, source)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production custom-broker setup; no CA must mean no connection."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MqttTlsSecurityTest(unittest.TestCase):
|
||||
def compile_run(self, source, portable=False, expect_rejection=False):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-tls-security-") as temporary:
|
||||
work = Path(temporary)
|
||||
generated = work / "security.cpp"
|
||||
generated.write_text(source)
|
||||
binary = work / "security"
|
||||
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", *sanitizers,
|
||||
*(["-DPORTABLE_MQTT_OBSERVER=1"] if portable else []),
|
||||
str(generated), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if expect_rejection:
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def fixture(self):
|
||||
source = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text()
|
||||
setup = extract_braced(source, "bool MQTTBridge::setupSlot(")
|
||||
start = setup.index(" // Custom broker slot - build persistent URI")
|
||||
# This is the entire production custom setup branch and the final
|
||||
# connection attempt, including both real build preprocessor paths.
|
||||
custom = "bool MQTTBridge::setupCustom(int index) {\nMQTTSlot& slot=_slots[index];\n{\n" + setup[start:]
|
||||
return (ROOT / "test/fixtures/mqtt_tls_security.cpp").read_text().replace("@METHOD@", custom)
|
||||
|
||||
def test_custom_tls_refuses_missing_ca_before_credentials_or_connect(self):
|
||||
self.compile_run(self.fixture())
|
||||
|
||||
def test_portable_custom_tls_refuses_missing_ca_but_plaintext_policy_is_preserved(self):
|
||||
self.compile_run(self.fixture(), portable=True)
|
||||
|
||||
def test_old_no_bundle_fallback_fails_executable_regression(self):
|
||||
fixture = self.fixture()
|
||||
old = fixture.replace(" slot.initial_connect_done = false;\n"
|
||||
" slot.last_reconnect_attempt = millis();\n"
|
||||
" return false;\n", "", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
def test_old_portable_fallback_fails_executable_regression(self):
|
||||
fixture = self.fixture()
|
||||
old = re.sub(r' if \(needs_tls\) \{\n MQTT_DEBUG_PRINTLN\("MQTT%d TLS refused: custom brokers require a CA bundle"[\s\S]*? return false;\n }',
|
||||
" (void)needs_tls;", fixture, count=1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, portable=True, expect_rejection=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4598,6 +4598,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
|
||||
TEST(OtaCatalog, RetainsProtocolMaximumRows) {
|
||||
OtaManager client;
|
||||
client.begin(SIM_TARGET_ID, nullptr, nullptr);
|
||||
client.queryAll(); // operator-requested browse opts into the bounded large cache
|
||||
const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES);
|
||||
const uint8_t total = (uint8_t)((255 + per - 1) / per);
|
||||
uint8_t sid[4] = {1, 2, 3, 4}, digest[4] = {5, 6, 7, 8};
|
||||
@@ -4763,6 +4764,7 @@ TEST(OtaTransfer, BootResumeHonorsCurrentAutofetchTargetAndVersionPolicy) {
|
||||
newer.begin(manifest.target_id, nullptr, nullptr);
|
||||
newer.set_fetch_store(&newer_store);
|
||||
newer.set_autofetch(OtaManager::AUTOFETCH_SIGNED);
|
||||
newer.set_manifest_admission([](void*, const MotaManifest&) { return true; }, nullptr);
|
||||
newer.set_auto_version_floor(manifest.fw_version - 1u, true);
|
||||
EXPECT_TRUE(newer.resumeStaged(nullptr));
|
||||
EXPECT_EQ(newer.fetchState(), OtaManager::VERIFYING_STAGED);
|
||||
|
||||
@@ -475,7 +475,9 @@ class DeviceDeflateManagerTest(unittest.TestCase):
|
||||
"c++", "-std=c++17", "-Os", *flags, *defines,
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
str(source), *[str(ROOT / "src/helpers/ota" / name) for name in sources],
|
||||
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
|
||||
str(ROOT / "src/Utils.cpp"), str(tinf),
|
||||
str(ROOT / "test/fixtures/ota_security/identity_verify.cpp"),
|
||||
"-lcrypto", "-o", str(binary),
|
||||
], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for scenario in scenarios:
|
||||
|
||||
@@ -272,7 +272,9 @@ class OtaIdentityPolicyTest(unittest.TestCase):
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
str(source),
|
||||
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
|
||||
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
|
||||
str(ROOT / "src/Utils.cpp"), str(tinf),
|
||||
str(ROOT / "test/fixtures/ota_security/identity_verify.cpp"),
|
||||
"-lcrypto", "-o", str(binary),
|
||||
], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run real OTA ingress/egress policy with real Ed25519 and bounded resources."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class OtaSecurityTests(unittest.TestCase):
|
||||
def test_manifest_authentication_cooldown_catalog_and_paced_metadata(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="ota-security-") as directory:
|
||||
binary = Path(directory) / "security"
|
||||
sources = ("OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp", "MerkleTree.cpp")
|
||||
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-pie", "-no-pie"]
|
||||
compiled = subprocess.run([
|
||||
compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-Wno-sign-compare", *flags,
|
||||
"-I", str(ROOT / "test/fixtures/ota_security"),
|
||||
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
|
||||
"-I", str(ROOT / "test"),
|
||||
str(ROOT / "test/fixtures/ota_security/test.cpp"),
|
||||
str(ROOT / "test/fixtures/ota_security/identity_verify.cpp"),
|
||||
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
|
||||
str(ROOT / "src/Utils.cpp"), "-lcrypto", "-Wl,--wrap=malloc",
|
||||
"-Wl,--wrap=free", "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=90)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("PASS: authenticated automatic staging", checked.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -89,6 +89,10 @@ typedef bool (*OtaSend)(void*, const uint8_t*, uint16_t, bool);
|
||||
struct SelfFwInfo { bool valid = false; uint32_t target_id = 0, fw_version = 0; char hw_id[33] = {}; };
|
||||
static bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; }
|
||||
static int ota_transport_inflate;
|
||||
struct MotaManifest {};
|
||||
static bool ota_manifest_trusted(const MotaManifest&, int) { return false; }
|
||||
struct Context;
|
||||
using OtaContext = Context;
|
||||
enum { CODEC_DETOOLS_INPLACE = 1, CODEC_DETOOLS_SEQUENTIAL = 2 };
|
||||
struct Store {
|
||||
bool external = false;
|
||||
@@ -100,6 +104,7 @@ struct Manager {
|
||||
bool accept_full = false;
|
||||
void begin(uint32_t, OtaSend, void*) {}
|
||||
void set_transport_deflate_decoder(int) {}
|
||||
template<class Callback> void set_manifest_admission(Callback, void*) {}
|
||||
void set_auto_version_floor(uint32_t, bool) {}
|
||||
void set_accept_full(bool value) { accept_full = value; }
|
||||
void set_autofetch(uint8_t) {}
|
||||
@@ -112,6 +117,7 @@ struct Manager {
|
||||
using OtaManager = Manager;
|
||||
struct Context {
|
||||
Manager manager;
|
||||
int allow = 0;
|
||||
Store fetch_store;
|
||||
bool self_serve_supported = false, fetch_to_folder = false;
|
||||
static const uint8_t AUTOINSTALL_OFF = 0;
|
||||
|
||||
@@ -177,18 +177,27 @@ int main() {
|
||||
assert(node.packet.radio_generation==primary.generation);
|
||||
node.ota_manager.clearPendingEgress();
|
||||
|
||||
// LEAVES remains an immediate response and inherits the live receive scope.
|
||||
// Deferred affinity cannot bleed into that callback or a later local send.
|
||||
// LEAVES is paced and retains its request scope after the receive stack
|
||||
// unwinds. Backpressure must not consume the pending descriptor.
|
||||
node.masks.clear();
|
||||
mesh::ota::GetLeavesMsg leaves{};
|
||||
memcpy(leaves.manifest_id,EXP_MERKLE_ROOT,4); leaves.want_mask=1;
|
||||
uint8_t leaves_wire[MAX_PACKET_PAYLOAD];
|
||||
const auto leaves_len=mesh::ota::encode_get_leaves(leaves_wire,sizeof(leaves_wire),leaves);
|
||||
node.receiving=true; node.receive_profile=1;
|
||||
node.ota_manager.on_message(leaves_wire,leaves_len,replacement);
|
||||
assert(node.ota_manager.on_message(leaves_wire,leaves_len,replacement));
|
||||
assert(node.masks.empty() && node.ota_manager.pendingManifestJobs()==1);
|
||||
node.receiving=false; node.receive_profile=0;
|
||||
node.manager.free=4;
|
||||
node.ota_manager.set_clock(2000000);
|
||||
node.service();
|
||||
assert(node.masks.empty() && node.ota_manager.pendingManifestJobs()==1);
|
||||
node.manager.free=40;
|
||||
node.service();
|
||||
assert(node.masks.size()==1 && node.masks[0]==2);
|
||||
assert(node.packet.radio_reply && !node.packet.radio_local);
|
||||
node.receiving=false; node.receive_profile=0;
|
||||
assert(node.ota_manager.pendingManifestJobs()==0);
|
||||
// Deferred affinity cannot bleed into a later local send.
|
||||
const uint8_t data=mesh::ota::OTA_DATA;
|
||||
assert(node.otaSendAdapter(&node,&data,1,false));
|
||||
assert(node.masks.size()==2 && node.masks[1]==1 && node.packet.radio_local);
|
||||
@@ -286,7 +295,8 @@ int main() {
|
||||
assert(node.was_reply==reply);
|
||||
}
|
||||
const uint8_t data=mesh::ota::OTA_DATA, proof=mesh::ota::OTA_PROOF;
|
||||
for (const uint8_t type : {data,proof}) {
|
||||
const uint8_t manifest=mesh::ota::OTA_MANIFEST, leaves=mesh::ota::OTA_LEAVES;
|
||||
for (const uint8_t type : {data,proof,manifest,leaves}) {
|
||||
for (uint8_t mask : {1,2,3}) {
|
||||
for (int queued=0;queued<=mesh::OTA_EGRESS_QUEUE_CREDIT+1;++queued) {
|
||||
for (int free=0;free<=mesh::OTA_EGRESS_MIN_FREE+3;++free) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute actual recovery/login/logout gates with real JSON and fake sockets."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WebConfigRecoveryAuthTest(unittest.TestCase):
|
||||
def compile_run(self, source, expect_rejection=False):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
json_header = next((ROOT / ".pio/libdeps").glob("*/ArduinoJson/src/ArduinoJson.h"), None)
|
||||
self.assertIsNotNone(json_header, "native dependency headers are required")
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-webconfig-auth-") as temporary:
|
||||
work = Path(temporary)
|
||||
generated = work / "auth.cpp"
|
||||
generated.write_text(source)
|
||||
binary = work / "auth"
|
||||
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
|
||||
*sanitizers, "-I" + str(json_header.parent),
|
||||
str(generated), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if expect_rejection:
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"static bool fixedTimeEquals(", "bool WebConfigServer::checkAuth(",
|
||||
"void WebConfigServer::handleLogin(", "void WebConfigServer::handleLogout("))
|
||||
# The actual config POST entry gate must reject a replacement password
|
||||
# before parsing or queueing it; exercise its exact production prefix.
|
||||
post = extract_braced(source, "void WebConfigServer::handleConfigPost(")
|
||||
gate = post[post.index("{") + 1:post.index(" if (req->contentLength()")]
|
||||
methods += "\nvoid WebConfigServer::checkConfigPost(AsyncWebServerRequest* req) {" + gate + "\nconfig_accepted=true;\n}"
|
||||
return (ROOT / "test/fixtures/webconfig_recovery_auth.cpp").read_text().replace("@METHODS@", methods)
|
||||
|
||||
def test_saved_network_fallback_requires_current_admin_session(self):
|
||||
self.compile_run(self.fixture())
|
||||
|
||||
def test_old_interface_only_fallback_fails_executable_regression(self):
|
||||
fixture = self.fixture()
|
||||
old = fixture.replace("if (_initial_setup || _wifi_ssid[0] == 0) return true;", "return true;", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
def test_old_unauthenticated_logout_fails_executable_regression(self):
|
||||
fixture = self.fixture()
|
||||
signature = "void WebConfigServer::handleLogout("
|
||||
start = fixture.index(signature, fixture.index("@METHODS@") if "@METHODS@" in fixture else 0)
|
||||
prefix, logout = fixture[:start], fixture[start:]
|
||||
gate = ' if (!checkAuth(req)) { req->send(401, "application/json", "{\\"error\\":\\"auth\\"}"); return; }\n'
|
||||
old = prefix + logout.replace(gate, "", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,51 @@ def embedded_page():
|
||||
|
||||
|
||||
class WebConfigUiRuntimeTest(unittest.TestCase):
|
||||
def test_saved_network_recovery_requires_login_before_setup_wizard(self):
|
||||
status, config = self.setup_values()
|
||||
status.update(auth=False, mode="setup", needs_setup=False,
|
||||
needs_password=False, password_supported=True)
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,configReads=0,logins=0;
|
||||
window.fetch=function(path,options){
|
||||
var value=config;
|
||||
if(path==="/api/status")value=status;
|
||||
else if(path==="/api/config")configReads++;
|
||||
else if(path==="/api/login"){
|
||||
logins++;
|
||||
if(JSON.parse(options.body).password!=="existing-secret")throw new Error("wrong password submitted");
|
||||
value={ok:true};
|
||||
}else if(path.indexOf("/api/scan")===0)value={networks:[]};
|
||||
return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}});
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){
|
||||
var body=document.body;
|
||||
body.setAttribute("data-test-recovery-login-visible",!document.getElementById("v-login").classList.contains("hide"));
|
||||
body.setAttribute("data-test-recovery-wizard-before",!document.getElementById("v-wizard").classList.contains("hide"));
|
||||
body.setAttribute("data-test-recovery-config-before",configReads);
|
||||
document.getElementById("login-pwd").value="existing-secret";
|
||||
document.getElementById("login-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));
|
||||
},200);
|
||||
setTimeout(function(){
|
||||
var body=document.body;
|
||||
body.setAttribute("data-test-recovery-wizard-after",!document.getElementById("v-wizard").classList.contains("hide"));
|
||||
body.setAttribute("data-test-recovery-app-after",!document.getElementById("v-app").classList.contains("hide"));
|
||||
body.setAttribute("data-test-recovery-logins",logins);
|
||||
body.setAttribute("data-test-recovery-config-after",configReads);
|
||||
body.setAttribute("data-test-recovery-password-cleared",document.getElementById("login-pwd").value==="");
|
||||
},800);
|
||||
});
|
||||
})();
|
||||
</script>''' % (json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude)
|
||||
for attribute, value in (("login-visible", "true"), ("wizard-before", "false"),
|
||||
("config-before", "0"), ("wizard-after", "true"),
|
||||
("app-after", "false"), ("logins", "1"),
|
||||
("config-after", "1"), ("password-cleared", "true")):
|
||||
self.assertIn('data-test-recovery-%s="%s"' % (attribute, value), dom)
|
||||
|
||||
def test_four_display_controls_and_pairing_capability(self):
|
||||
status, config = self.setup_values()
|
||||
config["display"] = {
|
||||
|
||||
Reference in new issue
Block a user