Add signed private fleet controls for filtering and secondary radio

Enroll a private channel and publisher public key on repeaters and room servers. Local Companion fleet sends sign targeted or whole-fleet commands, with expiry, durable replay protection, and a restricted command allowlist. Preserve exact transmitted-reply barriers for secondary-radio changes, bound validation and replies, and qualify crypto, persistence, transport scope, role permissions, and compact-profile exclusions with regression tests.
This commit is contained in:
mikecarper committed 2026-10-08 16:45:19 -07:00
1 parent d0cffc46eb
commit 39394a255a
27 files changed
+3028 -2

No files matched your search

+262
View File
@@ -0,0 +1,262 @@
#include "FleetCommand.h"
#include <SHA256.h>
#include <string.h>
namespace mesh {
namespace {
constexpr char Domain[] = "MeshCoreFleet1";
constexpr size_t SignedMessageCapacity = sizeof(Domain) - 1
+ FleetCommand::KeySize + FleetCommand::HeaderSize
+ FleetCommand::MaxCommandLength;
constexpr uint8_t PublicKey[FleetCommand::KeySize] = {
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72
};
bool zero(const uint8_t* value, size_t size) {
uint8_t combined = 0;
for (size_t i = 0; i < size; ++i) combined |= value[i];
return combined == 0;
}
void erase(uint8_t* bytes, size_t length) {
volatile uint8_t* destination = bytes;
while (length--) *destination++ = 0;
}
void write32(uint8_t* dest, uint32_t value) {
for (unsigned i = 0; i < 4; ++i) dest[i] = value >> (i * 8);
}
uint32_t read32(const uint8_t* source) {
uint32_t value = 0;
for (unsigned i = 0; i < 4; ++i) value |= uint32_t(source[i]) << (i * 8);
return value;
}
void targetHash(const uint8_t* full_key, uint8_t* target) {
SHA256 hash;
hash.update(full_key, PUB_KEY_SIZE);
hash.finalize(target, FleetCommand::TargetSize);
}
int hexDigit(char digit) {
if (digit >= '0' && digit <= '9') return digit - '0';
if (digit >= 'a' && digit <= 'f') return digit - 'a' + 10;
if (digit >= 'A' && digit <= 'F') return digit - 'A' + 10;
return -1;
}
bool lifetimeAllowed(uint32_t sequence, uint32_t expires) {
return sequence >= FleetCommand::MinEpoch && expires >= sequence
&& expires - sequence <= FleetCommand::MaxLifetime;
}
size_t signedMessage(const uint8_t* key, const uint8_t* payload,
size_t length, uint8_t* output) {
const size_t prefix = sizeof(Domain) - 1 + FleetCommand::KeySize;
memcpy(output, Domain, sizeof(Domain) - 1);
memcpy(output + sizeof(Domain) - 1, key, FleetCommand::KeySize);
memcpy(output + prefix, payload, length);
return prefix + length;
}
bool keyEquals(const char* key, size_t length, const char* candidate) {
return strlen(candidate) == length && !memcmp(key, candidate, length);
}
// The CLI handler owns board-specific range checks. Authorization admits
// only the exact namespace and a strictly numeric optional row suffix.
bool rowKey(const char* key, size_t length, const char* family) {
const size_t family_length = strlen(family);
if (length < family_length || memcmp(key, family, family_length)) return false;
if (length == family_length) return true;
if (key[family_length] != '.') return false;
size_t cursor = family_length + 1;
if (cursor == length || key[cursor] < '1' || key[cursor] > '9') return false;
unsigned row = 0;
for (; cursor < length; ++cursor) {
if (key[cursor] < '0' || key[cursor] > '9') return false;
row = row * 10 + unsigned(key[cursor] - '0');
if (row > 255) return false;
}
return true;
}
bool decimalToken(const char* text, size_t length) {
if (!length) return false;
bool dot = false, digit = false;
for (size_t i = 0; i < length; ++i) {
if (text[i] >= '0' && text[i] <= '9') digit = true;
else if (text[i] == '.' && !dot) dot = true;
else return false;
}
return digit;
}
bool integerToken(const char* text, size_t length) {
if (!length) return false;
for (size_t i = 0; i < length; ++i)
if (text[i] < '0' || text[i] > '9') return false;
return true;
}
bool radioTuple(const char* arguments, bool temporary) {
if (!strcmp(arguments, "off")) return true;
unsigned fields = 0;
const unsigned expected = temporary ? 6 : 5;
const char* cursor = arguments;
for (;;) {
const char* end = strchr(cursor, ',');
const size_t length = end ? size_t(end - cursor) : strlen(cursor);
bool valid;
if (fields < 2) valid = decimalToken(cursor, length);
else if (fields < 4 || (temporary && fields == 5))
valid = integerToken(cursor, length);
else if (fields == 4) valid = keyEquals(cursor, length, "rx")
|| keyEquals(cursor, length, "rxtx");
else valid = fields == expected
&& (integerToken(cursor, length) || keyEquals(cursor, length, "auto"));
if (!valid || ++fields > expected + 1) return false;
if (!end) return fields == expected || fields == expected + 1;
cursor = end + 1;
}
}
} // namespace
bool FleetCommand::privateKeyAllowed(const uint8_t* key) {
return key && !zero(key, KeySize) && memcmp(key, PublicKey, KeySize) != 0;
}
bool FleetCommand::parseTarget(const char* text, uint8_t* target) {
if (!text || !target) return false;
if (!strcmp(text, "all")) {
memset(target, 0, TargetSize);
return true;
}
if (strlen(text) != PUB_KEY_SIZE * 2) return false;
uint8_t full_key[PUB_KEY_SIZE];
for (size_t i = 0; i < PUB_KEY_SIZE; ++i) {
const int upper = hexDigit(text[i * 2]);
const int lower = hexDigit(text[i * 2 + 1]);
if (upper < 0 || lower < 0) return false;
full_key[i] = uint8_t((upper << 4) | lower);
}
// An invalid zero identity must not masquerade as the reserved all target.
if (zero(full_key, PUB_KEY_SIZE)) return false;
targetHash(full_key, target);
return !zero(target, TargetSize);
}
bool FleetCommand::commandAllowed(const char* command) {
if (!command) return false;
size_t length = 0;
while (command[length]) {
const unsigned char byte = command[length];
if (++length > MaxCommandLength || byte < 0x20 || byte > 0x7e
|| byte == ';' || byte == '|' || byte == '&' || byte == '`'
|| byte == '\\') return false;
}
if (!length || command[0] == ' ' || command[length - 1] == ' ') return false;
bool get = false, set = false, del = false;
if (!strncmp(command, "get ", 4)) get = true;
else if (!strncmp(command, "set ", 4)) set = true;
else if (!strncmp(command, "del ", 4)) del = true;
else return false;
const char* key = command + 4;
const char* separator = strchr(key, ' ');
const size_t key_length = separator ? size_t(separator - key) : strlen(key);
const char* arguments = separator ? separator + 1 : "";
if (!key_length || (separator && (!*arguments || *arguments == ' '))) return false;
if (keyEquals(key, key_length, "radio2")
|| keyEquals(key, key_length, "tempradio2")) {
return get ? !*arguments : set && radioTuple(arguments, key_length == 10);
}
if (keyEquals(key, key_length, "radio2.status")
|| keyEquals(key, key_length, "radio2.timing")) return get && !*arguments;
if (keyEquals(key, key_length, "radio2.cross")) {
return get ? !*arguments : set && (!strcmp(arguments, "on")
|| !strcmp(arguments, "off") || !strcmp(arguments, "auto"));
}
const bool rows = rowKey(key, key_length, "flood.filter")
|| rowKey(key, key_length, "flood.filter.blacklist")
|| rowKey(key, key_length, "flood.rule")
|| rowKey(key, key_length, "flood.moderation")
|| rowKey(key, key_length, "flood.channel.scope")
|| rowKey(key, key_length, "flood.channel.scope.require");
if (rows) return get ? !*arguments
: set ? *arguments != 0 : del && (!*arguments || !strcmp(arguments, "all"));
const bool settings = keyEquals(key, key_length, "flood.channel.data")
|| keyEquals(key, key_length, "flood.channel.data.hops")
|| keyEquals(key, key_length, "flood.max")
|| keyEquals(key, key_length, "flood.max.unscoped")
|| keyEquals(key, key_length, "flood.max.advert");
return settings && (get ? !*arguments : set && *arguments);
}
size_t FleetCommand::encode(const LocalIdentity& publisher, const uint8_t* key,
uint32_t sequence, uint32_t expires,
const uint8_t* target, const char* command,
uint8_t* output, size_t capacity) {
if (!privateKeyAllowed(key) || !target || !output
|| !lifetimeAllowed(sequence, expires) || !commandAllowed(command)
|| zero(publisher.pub_key, PUB_KEY_SIZE)) return 0;
const size_t command_length = strlen(command);
const size_t unsigned_length = HeaderSize + command_length;
const size_t length = unsigned_length + SignatureSize;
if (capacity < length) return 0;
memcpy(output, "FMC1", 4);
write32(output + 4, sequence);
write32(output + 8, expires);
memcpy(output + 12, target, TargetSize);
output[28] = uint8_t(command_length);
memcpy(output + HeaderSize, command, command_length);
uint8_t message[SignedMessageCapacity];
const size_t message_length = signedMessage(key, output, unsigned_length, message);
publisher.sign(output + unsigned_length, message, int(message_length));
erase(message, sizeof(message));
return length;
}
bool FleetCommand::decode(const Identity& publisher, const uint8_t* key,
const uint8_t* payload, size_t length, uint32_t now,
const uint8_t* self_public_key, Decoded& output) {
memset(&output, 0, sizeof(output));
if (!privateKeyAllowed(key) || !payload || !self_public_key
|| zero(publisher.pub_key, PUB_KEY_SIZE) || zero(self_public_key, PUB_KEY_SIZE)
|| now < MinEpoch || length < HeaderSize + SignatureSize
|| length > MaxPayloadLength || memcmp(payload, "FMC1", 4)) return false;
const size_t command_length = payload[28];
if (!command_length || command_length > MaxCommandLength
|| length != HeaderSize + command_length + SignatureSize) return false;
const uint32_t sequence = read32(payload + 4), expires = read32(payload + 8);
if (!lifetimeAllowed(sequence, expires) || expires < now
|| (sequence > now && sequence - now > MaxClockLead)) return false;
if (!zero(payload + 12, TargetSize)) {
uint8_t target[TargetSize];
targetHash(self_public_key, target);
if (memcmp(target, payload + 12, TargetSize)) return false;
}
// Reject embedded NUL bytes, which would authorize a different text from
// the signed byte string when passed to the CLI parser.
if (memchr(payload + HeaderSize, 0, command_length)) return false;
char command[MaxCommandLength + 1];
memcpy(command, payload + HeaderSize, command_length);
command[command_length] = 0;
if (!commandAllowed(command)) return false;
const size_t unsigned_length = HeaderSize + command_length;
uint8_t message[SignedMessageCapacity];
const size_t message_length = signedMessage(key, payload, unsigned_length, message);
const bool valid = publisher.verify(payload + unsigned_length, message, int(message_length));
erase(message, sizeof(message));
if (!valid) return false;
output.sequence = sequence;
output.expires = expires;
memcpy(output.command, command, command_length + 1);
return true;
}
} // namespace mesh