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.
250 lines
12 KiB
Python
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()
|