Files
MeshCore/test/test_fleet_command_crypto.py
T
mikecarper d83fa7cf8e 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.
2026-10-08 16:45:19 -07:00

250 lines
12 KiB
Python

#!/usr/bin/env python3
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
import hashlib
from pathlib import Path
import struct
import subprocess
import tempfile
import unittest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
ROOT = Path(__file__).resolve().parents[1]
NOW = 1791500000
CHANNEL = bytes(range(1, 17))
DOMAIN = b"MeshCoreFleet1"
PUBLISHER = Ed25519PrivateKey.from_private_bytes(bytes(range(1, 33)))
PUBLIC = PUBLISHER.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
OTHER = Ed25519PrivateKey.from_private_bytes(bytes(range(33, 65)))
OTHER_PUBLIC = OTHER.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
FIXTURE = r'''
#include <helpers/FleetCommand.h>
#include <openssl/evp.h>
#include <array>
#include <cassert>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
// The production Identity interface is retained. OpenSSL implements its
// signatures here so these tests cannot inherit native's always-true mock.
namespace mesh {
Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); }
LocalIdentity::LocalIdentity(RNG* rng) {
memset(prv_key, 0, sizeof(prv_key)); rng->random(prv_key, 32);
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
assert(key); size_t length = sizeof(pub_key);
assert(EVP_PKEY_get_raw_public_key(key, pub_key, &length) == 1 && length == sizeof(pub_key));
EVP_PKEY_free(key);
}
void LocalIdentity::sign(uint8_t* signature, const uint8_t* message, int length) const {
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr, prv_key, 32);
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
assert(EVP_DigestSignInit(context, nullptr, nullptr, nullptr, key) == 1);
size_t size = SIGNATURE_SIZE;
assert(EVP_DigestSign(context, signature, &size, message, size_t(length)) == 1);
assert(size == SIGNATURE_SIZE); EVP_MD_CTX_free(context); EVP_PKEY_free(key);
}
bool Identity::verify(const uint8_t* signature, const uint8_t* message, int length) const {
EVP_PKEY* key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, pub_key, sizeof(pub_key));
EVP_MD_CTX* context = EVP_MD_CTX_new(); assert(key && context);
assert(EVP_DigestVerifyInit(context, nullptr, nullptr, nullptr, key) == 1);
const bool valid = EVP_DigestVerify(context, signature, SIGNATURE_SIZE, message, size_t(length)) == 1;
EVP_MD_CTX_free(context); EVP_PKEY_free(key); return valid;
}
} // namespace mesh
struct FixedRng : mesh::RNG {
void random(uint8_t* output, size_t size) override {
for (size_t i = 0; i < size; ++i) output[i] = uint8_t(i + 1);
}
};
std::vector<uint8_t> unhex(const char* input) {
const std::string text(input); assert(text.size() % 2 == 0);
std::vector<uint8_t> bytes(text.size() / 2);
for (size_t i = 0; i < bytes.size(); ++i)
bytes[i] = uint8_t(std::stoul(text.substr(i * 2, 2), nullptr, 16));
return bytes;
}
void hex(const uint8_t* bytes, size_t size) {
constexpr char digits[] = "0123456789abcdef";
for (size_t i = 0; i < size; ++i) std::cout << digits[bytes[i] >> 4] << digits[bytes[i] & 15];
std::cout << '\n';
}
int main(int argc, char** argv) {
using Fleet = mesh::FleetCommand;
if (argc == 7 && std::string(argv[1]) == "encode") {
FixedRng rng; mesh::LocalIdentity publisher(&rng);
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
std::array<uint8_t, Fleet::TargetSize> target;
if (!Fleet::parseTarget(argv[3], target.data())) { std::cout << "reject\n"; return 0; }
std::array<uint8_t, Fleet::MaxPayloadLength> output;
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
uint32_t(std::stoul(argv[5])), target.data(), argv[6], output.data(), output.size());
if (!size) std::cout << "reject\n"; else hex(output.data(), size);
return 0;
}
if (argc == 7 && std::string(argv[1]) == "decode") {
const auto public_key = unhex(argv[2]), key = unhex(argv[3]), self = unhex(argv[4]), bytes = unhex(argv[6]);
assert(public_key.size() == PUB_KEY_SIZE && self.size() == PUB_KEY_SIZE && key.size() == Fleet::KeySize);
mesh::Identity publisher(public_key.data()); Fleet::Decoded output;
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
uint32_t(std::stoul(argv[5])), self.data(), output)) {
assert(output.sequence == 0 && output.expires == 0 && output.command[0] == 0);
std::cout << "reject\n";
} else std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
return 0;
}
return 2;
}
'''
def envelope(command="get radio2", sequence=NOW, expires=NOW + 120,
target=b"\0" * 16, channel=CHANNEL, publisher=PUBLISHER):
command = command.encode("ascii") if isinstance(command, str) else command
unsigned = b"FMC1" + struct.pack("<II", sequence, expires) + target + bytes([len(command)]) + command
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
class FleetCommandCryptoTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-fleet-crypto-")
cls.addClassCleanup(cls.directory.cleanup)
path = Path(cls.directory.name)
source = path / "fleet-crypto.cpp"
source.write_text(FIXTURE)
cls.binary = path / "fleet-crypto"
build = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-sign-compare",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(source), str(ROOT / "src/helpers/FleetCommand.cpp"),
"-lcrypto", "-o", str(cls.binary)], capture_output=True, text=True)
if build.returncode:
raise AssertionError(build.stdout + build.stderr)
def run_tool(self, *arguments):
result = subprocess.run([str(self.binary), *map(str, arguments)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("runtime error:", result.stderr)
self.assertNotIn("AddressSanitizer", result.stderr)
return result.stdout.strip()
def decode(self, payload, channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
return self.run_tool("decode", publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
def test_cpp_and_python_signatures_match_exactly(self):
expected = envelope()
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\nget radio2")
def test_python_signature_accepted_for_complete_hashed_target(self):
target = hashlib.sha256(PUBLIC).digest()[:16]
expected = envelope(target=target)
actual = self.run_tool("encode", CHANNEL.hex(), PUBLIC.hex(), NOW, NOW + 120, "get radio2")
self.assertEqual(actual, expected.hex())
self.assertNotEqual(self.decode(expected), "reject")
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
def test_all_targets_reaches_another_receiver_without_other_private_key(self):
self.assertNotEqual(self.decode(envelope(), self_key=OTHER_PUBLIC), "reject")
def test_wrong_publisher_and_forged_publisher_signature_rejected(self):
self.assertEqual(self.decode(envelope(), publisher=OTHER_PUBLIC), "reject")
self.assertEqual(self.decode(envelope(publisher=OTHER)), "reject")
self.assertNotEqual(self.decode(envelope(publisher=OTHER), publisher=OTHER_PUBLIC), "reject")
def test_signature_is_bound_to_the_encryption_channel(self):
different = bytes(range(17, 33))
original = envelope()
self.assertEqual(self.decode(original, channel=different), "reject")
self.assertEqual(self.decode(envelope(channel=different)), "reject")
self.assertNotEqual(self.decode(envelope(channel=different), channel=different), "reject")
def test_every_signature_byte_is_verified(self):
original = envelope()
for offset in range(len(original) - 64, len(original)):
with self.subTest(signature_byte=offset):
altered = bytearray(original)
altered[offset] ^= 1
self.assertEqual(self.decode(altered), "reject")
def test_tampered_command_that_remains_allowed_is_rejected(self):
original = envelope(command="set flood.max 4")
# Equal-length valid commands exercise cryptographic rejection, rather
# than relying on the allowlist rejecting the changed text.
other = envelope(command="set flood.max 5")
forged = other[:-64] + original[-64:]
self.assertEqual(self.decode(forged), "reject")
def test_every_header_field_and_target_is_signed(self):
for changed in [
envelope(sequence=NOW + 1)[:-64],
envelope(expires=NOW + 121)[:-64],
envelope(target=hashlib.sha256(PUBLIC).digest()[:16])[:-64],
envelope(command="get tempradio2")[:-64]]:
with self.subTest(header=changed.hex()):
self.assertEqual(self.decode(changed + envelope()[-64:]), "reject")
def test_signed_forbidden_commands_and_multistatement_text_rejected(self):
for command in ["get prv.key", "setperm 0102 3", "start ota", "reboot",
"get radio2\nreboot", "xy|set radio2 off", "get radio2;reboot",
"get radio2\0", "get flood.filter.secret", "get radio2.scan"]:
with self.subTest(command=command):
self.assertEqual(self.decode(envelope(command)), "reject")
def test_exact_and_oversized_command_boundaries(self):
maximum = "set flood.rule.1 " + "a" * (72 - len("set flood.rule.1 "))
accepted = envelope(maximum)
self.assertEqual(len(accepted), 165)
self.assertNotEqual(self.decode(accepted), "reject")
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
def test_valid_signature_does_not_override_clock_policy(self):
for sequence, expires, now in [
(NOW, NOW - 1, NOW), (NOW, NOW + 601, NOW),
(NOW + 61, NOW + 120, NOW), (NOW, NOW + 120, NOW + 121),
(1735689599, 1735689600, 1735689600), (NOW, NOW + 120, 0)]:
with self.subTest(sequence=sequence, expires=expires, now=now):
self.assertEqual(self.decode(envelope(sequence=sequence, expires=expires), now=now), "reject")
self.assertNotEqual(self.decode(envelope(sequence=NOW + 60, expires=NOW + 120)), "reject")
self.assertNotEqual(self.decode(envelope(), now=NOW + 120), "reject")
def test_truncation_extra_padding_and_changed_length_rejected(self):
original = envelope()
for length in [0, 1, 28, 29, 92, len(original) - 1]:
with self.subTest(length=length):
self.assertEqual(self.decode(original[:length]), "reject")
self.assertEqual(self.decode(original + b"\0"), "reject")
altered = bytearray(original)
altered[28] = 0
self.assertEqual(self.decode(altered), "reject")
def test_default_public_key_cannot_form_fleet_privacy_channel(self):
public_channel = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
for channel in [b"\0" * 16, public_channel]:
with self.subTest(channel=channel.hex()):
self.assertEqual(self.decode(envelope(channel=channel), channel=channel), "reject")
self.assertEqual(self.run_tool("encode", channel.hex(), "all", NOW, NOW + 120, "get radio2"), "reject")
if __name__ == "__main__":
unittest.main()