mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 17:22:01 +00:00
1205 lines
71 KiB
Python
1205 lines
71 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise the production fleet envelope with real Ed25519, never native mocks."""
|
|
|
|
import hashlib
|
|
import math
|
|
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>
|
|
|
|
inline unsigned fleet_sign_calls = 0;
|
|
|
|
// 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 {
|
|
++fleet_sign_calls;
|
|
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';
|
|
}
|
|
|
|
struct RegionNames {
|
|
std::vector<std::string> configured, home;
|
|
static bool match(void* context, mesh::FleetCommand::RegionTarget kind,
|
|
const char* name, size_t length) {
|
|
const auto& regions = *static_cast<RegionNames*>(context);
|
|
const auto& names = kind == mesh::FleetCommand::RegionTarget::Configured
|
|
? regions.configured : regions.home;
|
|
for (const auto& candidate : names) {
|
|
const char* canonical = candidate.c_str() + (!candidate.empty() && candidate[0] == '#');
|
|
if (strlen(canonical) == length && !memcmp(canonical, name, length)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
struct GeoLocation {
|
|
bool known = false;
|
|
int32_t latitude = 0, longitude = 0;
|
|
static bool match(void* context, int32_t latitude, int32_t longitude, uint32_t radius) {
|
|
const auto& location = *static_cast<GeoLocation*>(context);
|
|
return location.known && mesh::FleetCommand::withinRadius(latitude, longitude, radius,
|
|
location.latitude, location.longitude);
|
|
}
|
|
};
|
|
|
|
int main(int argc, char** argv) {
|
|
using Fleet = mesh::FleetCommand;
|
|
const std::string operation = argc > 1 ? argv[1] : "";
|
|
if (argc == 7 && (operation == "encode" || operation == "encode-wide")) {
|
|
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::MaxEnvelopeLength + 64> output;
|
|
output.fill(0xA5);
|
|
const unsigned signed_before = fleet_sign_calls;
|
|
const size_t capacity = operation == "encode-wide" ? output.size() : Fleet::MaxEnvelopeLength;
|
|
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(), capacity);
|
|
assert(size <= Fleet::MaxEnvelopeLength);
|
|
if (!size) {
|
|
assert(fleet_sign_calls == signed_before);
|
|
for (uint8_t byte : output) assert(byte == 0xA5);
|
|
std::cout << "reject\n";
|
|
} else {
|
|
assert(fleet_sign_calls == signed_before + 1);
|
|
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
|
hex(output.data(), size);
|
|
}
|
|
return 0;
|
|
}
|
|
if (argc == 7 && (operation == "encode2" || operation == "encode2-wide")) {
|
|
FixedRng rng; mesh::LocalIdentity publisher(&rng);
|
|
const auto key = unhex(argv[2]); assert(key.size() == Fleet::KeySize);
|
|
Fleet::Targets targets;
|
|
memset(&targets, 0xA5, sizeof(targets));
|
|
if (!Fleet::parseTargets(argv[3], strlen(argv[3]), targets)) {
|
|
assert(!targets.count && !targets.length);
|
|
for (uint8_t byte : targets.data) assert(byte == 0);
|
|
assert(fleet_sign_calls == 0);
|
|
std::cout << "reject\n"; return 0;
|
|
}
|
|
std::array<uint8_t, Fleet::MaxEnvelopeLength + 64> output;
|
|
output.fill(0xA5);
|
|
const unsigned signed_before = fleet_sign_calls;
|
|
const size_t capacity = operation == "encode2-wide" ? output.size() : Fleet::MaxEnvelopeLength;
|
|
const size_t size = Fleet::encode(publisher, key.data(), uint32_t(std::stoul(argv[4])),
|
|
uint32_t(std::stoul(argv[5])), targets, argv[6], output.data(), capacity);
|
|
assert(size <= Fleet::MaxEnvelopeLength);
|
|
if (!size) {
|
|
assert(fleet_sign_calls == signed_before);
|
|
for (uint8_t byte : output) assert(byte == 0xA5);
|
|
std::cout << "reject\n";
|
|
} else {
|
|
assert(fleet_sign_calls == signed_before + 1);
|
|
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
|
hex(output.data(), size);
|
|
}
|
|
return 0;
|
|
}
|
|
if (argc == 5 && operation == "fragment") {
|
|
const auto envelope = unhex(argv[2]);
|
|
const unsigned index = unsigned(std::stoul(argv[3]));
|
|
assert(index <= 255);
|
|
const size_t capacity = std::stoul(argv[4]);
|
|
std::array<uint8_t, Fleet::MaxPayloadLength + 64> output;
|
|
assert(capacity <= output.size()); output.fill(0xA5);
|
|
const unsigned signed_before = fleet_sign_calls;
|
|
const size_t size = Fleet::fragment(envelope.data(), envelope.size(), uint8_t(index),
|
|
output.data(), capacity);
|
|
assert(fleet_sign_calls == signed_before && size <= Fleet::MaxPayloadLength);
|
|
if (!size) {
|
|
for (uint8_t byte : output) assert(byte == 0xA5);
|
|
std::cout << "reject\n";
|
|
} else {
|
|
for (size_t i = size; i < output.size(); ++i) assert(output[i] == 0xA5);
|
|
hex(output.data(), size);
|
|
}
|
|
return 0;
|
|
}
|
|
if (argc == 3 && operation == "parse-fragment") {
|
|
const auto payload = unhex(argv[2]);
|
|
Fleet::Fragment output{123, 300, 1, payload.data(), 100};
|
|
if (!Fleet::parseFragment(payload.data(), payload.size(), output)) {
|
|
assert(!output.sequence && !output.total_length && !output.index && !output.data && !output.length);
|
|
std::cout << "reject\n";
|
|
} else {
|
|
std::cout << output.sequence << '\n' << output.total_length << '\n' << unsigned(output.index) << '\n';
|
|
hex(output.data, output.length);
|
|
}
|
|
return 0;
|
|
}
|
|
if (argc == 7 && operation == "within-radius") {
|
|
std::cout << int(Fleet::withinRadius(int32_t(std::stol(argv[2])), int32_t(std::stol(argv[3])),
|
|
uint32_t(std::stoul(argv[4])), int32_t(std::stol(argv[5])), int32_t(std::stol(argv[6])))) << '\n';
|
|
return 0;
|
|
}
|
|
if ((argc == 7 && (operation == "decode" || operation == "decode2"))
|
|
|| (argc == 8 && operation == "decode-regions")
|
|
|| (argc == 11 && operation == "decode-targets")) {
|
|
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;
|
|
RegionNames regions;
|
|
if (operation == "decode-regions" || operation == "decode-targets") {
|
|
const std::string selected(argv[7]);
|
|
size_t cursor = 0;
|
|
while (cursor < selected.size()) {
|
|
const size_t end = selected.find(',', cursor);
|
|
const std::string name = selected.substr(cursor, end == std::string::npos ? end : end - cursor);
|
|
if (!name.compare(0, 7, "region:")) regions.configured.push_back(name.substr(7));
|
|
else { assert(!name.compare(0, 5, "home:")); regions.home.push_back(name.substr(5)); }
|
|
if (end == std::string::npos) break;
|
|
cursor = end + 1;
|
|
}
|
|
}
|
|
GeoLocation location;
|
|
if (operation == "decode-targets") {
|
|
assert(!strcmp(argv[8], "0") || !strcmp(argv[8], "1"));
|
|
location.known = !strcmp(argv[8], "1");
|
|
location.latitude = int32_t(std::stol(argv[9]));
|
|
location.longitude = int32_t(std::stol(argv[10]));
|
|
}
|
|
if (!Fleet::decode(publisher, key.data(), bytes.data(), bytes.size(),
|
|
uint32_t(std::stoul(argv[5])), self.data(), output,
|
|
operation == "decode-regions" || operation == "decode-targets"
|
|
? RegionNames::match : nullptr, ®ions,
|
|
operation == "decode-targets" ? GeoLocation::match : nullptr, &location)) {
|
|
assert(output.sequence == 0 && output.expires == 0 && !output.broadcast && output.command[0] == 0);
|
|
std::cout << "reject\n";
|
|
} else {
|
|
std::cout << output.sequence << '\n' << output.expires << '\n' << output.command << '\n';
|
|
if (operation != "decode") std::cout << int(output.broadcast) << '\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)
|
|
|
|
|
|
def envelope2(entries=(), command="get radio2", sequence=NOW, expires=NOW + 120,
|
|
channel=CHANNEL, publisher=PUBLISHER, count=None):
|
|
command = command.encode("ascii") if isinstance(command, str) else command
|
|
records = b"".join(bytes([kind]) + (bytes([len(value)]) if kind in (0x20, 0x21) else b"")
|
|
+ value for kind, value in entries)
|
|
count = len(entries) if count is None else count
|
|
unsigned = (b"FMC2" + struct.pack("<II", sequence, expires) + bytes([count])
|
|
+ records + bytes([len(command)]) + command)
|
|
return unsigned + publisher.sign(DOMAIN + channel + unsigned)
|
|
|
|
|
|
def fragment_frame(payload, index):
|
|
"""Independent wire framing; the signature stays in the whole envelope."""
|
|
sequence = struct.unpack("<I", payload[4:8])[0]
|
|
return (b"FMP1" + struct.pack("<IHB", sequence, len(payload), index)
|
|
+ payload[index * 154:(index + 1) * 154])
|
|
|
|
|
|
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, metadata=False):
|
|
return self.run_tool("decode2" if metadata else "decode",
|
|
publisher.hex(), channel.hex(), self_key.hex(), now, payload.hex())
|
|
|
|
def decode_regions(self, payload, configured=(), home=(), channel=CHANNEL,
|
|
publisher=PUBLIC, self_key=PUBLIC, now=NOW):
|
|
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
|
|
return self.run_tool("decode-regions", publisher.hex(), channel.hex(), self_key.hex(),
|
|
now, payload.hex(), ",".join(names))
|
|
|
|
def decode_geo(self, payload, latitude_e6=47606200, longitude_e6=-122332100, known=True,
|
|
configured=(), home=(), channel=CHANNEL, publisher=PUBLIC, self_key=PUBLIC, now=NOW):
|
|
names = ["region:" + name for name in configured] + ["home:" + name for name in home]
|
|
return self.run_tool("decode-targets", publisher.hex(), channel.hex(), self_key.hex(),
|
|
now, payload.hex(), ",".join(names), int(known), latitude_e6, longitude_e6)
|
|
|
|
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_scheduled_radio_commands_have_cross_language_real_signatures(self):
|
|
for command in [
|
|
"set radioat 910.5,500,5,5,+1,auto",
|
|
"set radioat 910.5,500,5,5,1800000000,65528",
|
|
"set tempradioat 910.5,500,5,5,+1,+2,32",
|
|
"set tempradioat 910.5,500,5,5,+1,1800000600,auto",
|
|
"set radioat2 910.5,500,5,5,rx,+1,0",
|
|
"set tempradioat2 910.5,500,5,5,rxtx,1800000000,1800000600,auto"]:
|
|
with self.subTest(command=command):
|
|
expected = envelope(command)
|
|
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command)
|
|
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}\n{command}")
|
|
|
|
def test_schedule_inspection_and_deletion_are_bounded_and_exact(self):
|
|
for family in ["radioat", "tempradioat", "radioat2", "tempradioat2"]:
|
|
for verb in ["get", "del"]:
|
|
maximum = 4 if family.endswith("2") else 255
|
|
for selector in ["", " all", " 1", f" {maximum}"]:
|
|
command = f"{verb} {family}{selector}"
|
|
with self.subTest(command=command):
|
|
self.assertEqual(self.decode(envelope(command)), f"{NOW}\n{NOW + 120}\n{command}")
|
|
for selector in [" 0", " 01", " +1", " -1", " ALL", " 1 all", f" {maximum + 1}"]:
|
|
command = f"{verb} {family}{selector}"
|
|
with self.subTest(command=command):
|
|
self.assertEqual(self.decode(envelope(command)), "reject")
|
|
|
|
def test_signed_malformed_schedule_and_alias_commands_are_rejected(self):
|
|
for command in [
|
|
"set radioat off", "set tempradioat off", "set radioat2 off", "set tempradioat2 off",
|
|
"set radioat 910.5,500,5,5", "set tempradioat 910.5,500,5,5,+1",
|
|
"set radioat2 910.5,500,5,5,+1", "set tempradioat2 910.5,500,5,5,rx,+1",
|
|
"set radioat 910.5,500,5,5,rx,+1", "set radioat2 910.5,500,5,5,rx&tx,+1",
|
|
"set radioat 910.5,500,5,5,+0", "set radioat 910.5,500,5,5,++1",
|
|
"set radioat 910.5,500,5,5,+1m", "set radioat 910.5,500,5,5,+1.5",
|
|
"set radioat 910.5,500,5,5,-1", "set radioat 910.5,500,5,5,1e9",
|
|
"set radioat 910.5,500,5,5,4294967296", "set radioat 910.5,500,5,5,+71582789",
|
|
"set radioat 910.5,500,256,5,+1", "set radioat 910.5,500,5,5,+1,7",
|
|
"set radioat 910.5,500,5,5,+1,65529", "set radioat 910.5,500,5,5,+1,AUTO",
|
|
"set radioat 910.5,500,5,5,+1,+8", "set radioat 910.5,500,5,5,+1,auto,8",
|
|
"set radioat.extra 910.5,500,5,5,+1", "get radioat.all", "set radioat 910.5,500,5,5, +1",
|
|
"set tempradioat2 910.5,500,5,5,rx,+1,+2;reboot",
|
|
"set tempradioat2 910.5,500,5,5,rx,+1,+2\nreboot",
|
|
"set radio 910.5,500,5,5", "set tempradio 910.5,500,5,5,10"]:
|
|
with self.subTest(command=command):
|
|
self.assertEqual(self.decode(envelope(command)), "reject")
|
|
|
|
def test_multitarget_scheduled_request_is_signed_and_body_bounded(self):
|
|
command = "set tempradioat2 910.5,500,5,5,rxtx,+1,+2,auto"
|
|
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])]
|
|
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()}"
|
|
expected = envelope2(entries, command)
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, command)
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertEqual(self.decode(expected, metadata=True), f"{NOW}\n{NOW + 120}\n{command}\n1")
|
|
self.assertNotEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
|
|
changed_command = command.replace("+2", "+3")
|
|
self.assertEqual(self.decode(envelope2(entries, changed_command)[:-64] + expected[-64:]), "reject")
|
|
prefix, suffix = "set tempradioat2 910.5,500,5,5,rxtx,+", "1,+2,auto"
|
|
maximum = prefix + "0" * (72 - len(prefix) - len(suffix)) + suffix
|
|
self.assertEqual(len(maximum), 72)
|
|
self.assertNotEqual(self.decode(envelope(maximum)), "reject")
|
|
self.assertNotEqual(self.decode(envelope2(entries, maximum)), "reject")
|
|
larger = prefix + "0" + maximum[len(prefix):]
|
|
self.assertNotEqual(self.decode(envelope(larger)), "reject")
|
|
self.assertNotEqual(self.decode(envelope2(entries, larger)), "reject")
|
|
target_maximum = prefix + "0" * (218 - len(prefix) - len(suffix)) + suffix
|
|
self.assertNotEqual(self.decode(envelope2(entries, target_maximum)), "reject")
|
|
too_large = prefix + "0" + target_maximum[len(prefix):]
|
|
self.assertEqual(self.decode(envelope2(entries, too_large)), "reject")
|
|
broadcast_maximum = prefix + "0" * (230 - len(prefix) - len(suffix)) + suffix
|
|
expected = envelope2(command=broadcast_maximum)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
|
|
broadcast_maximum), expected.hex())
|
|
self.assertNotEqual(self.decode(expected), "reject")
|
|
|
|
def test_clock_management_has_real_signatures_and_no_prefix_aliases(self):
|
|
for command in ["clock", "clock sync", "time 1735689600", "time 1800000000", "time 4294967295"]:
|
|
with self.subTest(command=command):
|
|
expected = envelope(command)
|
|
actual = self.run_tool("encode", CHANNEL.hex(), "all", NOW, NOW + 120, command)
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertEqual(self.decode(expected), f"{NOW}\n{NOW + 120}\n{command}")
|
|
self.assertEqual(self.decode(expected, publisher=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode(expected, channel=bytes(range(17, 33))), "reject")
|
|
for command in [
|
|
"get clock", "set clock 1800000000", "clock now", "clock sync now", "clock.sync",
|
|
"clkreboot", "clock sync;reboot", "clock sync\nreboot", "clock sync", "time",
|
|
"time 1800000000", "time +1800000000", "time -1800000000", "time 1735689599",
|
|
"time 0", "time 4294967296", "time 9999999999999999999", "time 1800000000.0",
|
|
"time 1e9", "time 1800000000 now", "time 1800000000;reboot", "TIME 1800000000"]:
|
|
with self.subTest(command=command):
|
|
self.assertEqual(self.decode(envelope(command)), "reject")
|
|
|
|
def test_signed_clock_requests_cannot_bootstrap_or_bypass_replay_time_boundaries(self):
|
|
for command in ["clock", "clock sync", "time 1800000000"]:
|
|
with self.subTest(command=command):
|
|
for sequence, expires, now in [
|
|
(NOW, NOW + 120, 0), (NOW, NOW + 120, 1735689599),
|
|
(NOW, NOW + 120, NOW - 61), (NOW, NOW + 120, NOW + 121),
|
|
(NOW, NOW + 601, NOW), (1735689599, 1735689600, 1735689600)]:
|
|
self.assertEqual(self.decode(envelope(command, sequence, expires), now=now), "reject")
|
|
original = envelope(command)
|
|
forged = envelope(command, sequence=NOW + 1)[:-64] + original[-64:]
|
|
self.assertEqual(self.decode(forged), "reject")
|
|
self.assertNotEqual(self.decode(original, now=NOW + 120), "reject")
|
|
|
|
def test_fixed_target_exact_limit_cannot_be_raised_by_destination_capacity(self):
|
|
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
|
|
accepted = envelope(maximum)
|
|
self.assertEqual(len(accepted), 308)
|
|
self.assertNotEqual(self.decode(accepted), "reject")
|
|
self.assertEqual(self.decode(envelope(maximum + "a")), "reject")
|
|
for operation in ["encode", "encode-wide"]:
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, maximum),
|
|
accepted.hex())
|
|
for length in [216, 230, 231]:
|
|
command = "set flood.rule.1 " + "a" * (length - len("set flood.rule.1 "))
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), "all", NOW, NOW + 120, command), "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")
|
|
|
|
def test_compact_broadcast_is_signed_without_target_bytes_and_legacy_all_still_decodes(self):
|
|
expected = envelope2()
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, "get radio2")
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertEqual(expected[:4], b"FMC2")
|
|
self.assertEqual(expected[12], 0)
|
|
self.assertEqual(len(expected), 14 + 10 + 64)
|
|
self.assertEqual(len(envelope()) - len(expected), 15)
|
|
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
|
for node in [PUBLIC, OTHER_PUBLIC, hashlib.sha256(b"unrelated broadcast receiver").digest()]:
|
|
with self.subTest(node=node.hex()):
|
|
self.assertEqual(self.decode(expected, self_key=node, metadata=True),
|
|
f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
|
self.assertEqual(self.decode(envelope(), self_key=node, metadata=True),
|
|
f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
|
|
|
def test_new_sender_preserves_fmc1_for_single_complete_key(self):
|
|
expected = envelope(target=hashlib.sha256(PUBLIC).digest()[:16])
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), PUBLIC.hex(), NOW, NOW + 120, "get radio2")
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertTrue(self.decode(bytes.fromhex(actual), metadata=True).endswith("\n0"))
|
|
|
|
def test_compact_broadcast_signature_binds_no_target_count_command_and_times(self):
|
|
original = envelope2()
|
|
# All of these are valid, matching requests on their own. Attaching
|
|
# the old zero-target signature must not authorize the changed data.
|
|
for changed in [
|
|
envelope2([(4, PUBLIC[:4])]),
|
|
envelope2([(16, hashlib.sha256(PUBLIC).digest()[:16])]),
|
|
envelope2(command="get tempradio2"),
|
|
envelope2(sequence=NOW + 1), envelope2(expires=NOW + 121)]:
|
|
with self.subTest(changed=changed.hex()):
|
|
self.assertNotEqual(self.decode(changed), "reject")
|
|
self.assertEqual(self.decode(changed[:-64] + original[-64:]), "reject")
|
|
for offset in range(len(original) - 64, len(original)):
|
|
with self.subTest(signature_byte=offset):
|
|
changed = bytearray(original)
|
|
changed[offset] ^= 1
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
changed = bytearray(original)
|
|
changed[12] = 1
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
|
|
|
|
def test_compact_broadcast_fits_230_byte_signed_command_and_framing_limits(self):
|
|
prefix = "set flood.rule.1 "
|
|
maximum = prefix + "a" * (230 - len(prefix))
|
|
expected = envelope2(command=maximum)
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, maximum)
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertEqual(len(expected), 308)
|
|
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
|
for node in [PUBLIC, OTHER_PUBLIC]:
|
|
self.assertEqual(self.decode(expected, self_key=node, metadata=True),
|
|
f"{NOW}\n{NOW + 120}\n{maximum}\n1")
|
|
oversized = maximum + "a"
|
|
self.assertEqual(self.decode(envelope2(command=oversized)), "reject")
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
|
|
self.assertEqual(self.run_tool("encode2-wide", CHANNEL.hex(), "all", NOW, NOW + 120, oversized), "reject")
|
|
for invalid in [b"\n", b";", b"\x80", b"\0"]:
|
|
malformed = maximum[:-1].encode("ascii") + invalid
|
|
self.assertEqual(self.decode(envelope2(command=malformed)), "reject")
|
|
for length in [0, 12, 13, 14, len(expected) - 64, len(expected) - 1]:
|
|
with self.subTest(length=length):
|
|
self.assertEqual(self.decode(expected[:length]), "reject")
|
|
self.assertEqual(self.decode(expected + b"\0"), "reject")
|
|
|
|
def test_each_target_form_has_exact_packet_budget_and_atomic_oversize_rejection(self):
|
|
short, long = PUBLIC[:4].hex(), PUBLIC[:6].hex()
|
|
digest = hashlib.sha256(PUBLIC).digest()[:16]
|
|
for token, entries, maximum_length in [
|
|
("all", [], 230), (short, [(4, PUBLIC[:4])], 225),
|
|
(long, [(6, PUBLIC[:6])], 223), (PUBLIC.hex(), None, 215),
|
|
(f"{short};{short}", [(4, PUBLIC[:4])] * 2, 220),
|
|
(f"{short};{long}", [(4, PUBLIC[:4]), (6, PUBLIC[:6])], 218),
|
|
(f"{PUBLIC.hex()};{short}", [(16, digest), (4, PUBLIC[:4])], 208),
|
|
(f"{PUBLIC.hex()};{PUBLIC.hex()}", [(16, digest)] * 2, 196)]:
|
|
with self.subTest(token=token, maximum=maximum_length):
|
|
prefix = "set flood.rule.1 "
|
|
maximum = prefix + "a" * (maximum_length - len(prefix))
|
|
expected = (envelope(maximum, target=digest) if entries is None
|
|
else envelope2(entries, maximum))
|
|
self.assertEqual(len(expected), 308)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum),
|
|
expected.hex())
|
|
self.assertNotEqual(self.decode(expected), "reject")
|
|
# The fixture verifies no writes and no signature calls on
|
|
# rejection, even when its output buffer exceeds packet size.
|
|
oversized = maximum + "a"
|
|
for operation in ["encode2", "encode2-wide"]:
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
|
|
oversized), "reject")
|
|
signed_oversized = (envelope(oversized, target=digest) if entries is None
|
|
else envelope2(entries, oversized))
|
|
self.assertEqual(self.decode(signed_oversized), "reject")
|
|
altered = bytearray(expected)
|
|
altered[-65] = ord("b")
|
|
self.assertEqual(self.decode(altered), "reject")
|
|
|
|
def test_maximum_broadcast_signature_covers_final_byte_and_230_byte_length(self):
|
|
prefix = "set flood.rule.1 "
|
|
maximum = prefix + "a" * (230 - len(prefix))
|
|
original = envelope2(command=maximum)
|
|
self.assertNotEqual(self.decode(original), "reject")
|
|
for command in [maximum[:-1] + "b", maximum[:-1]]:
|
|
changed = envelope2(command=command)
|
|
self.assertNotEqual(self.decode(changed), "reject")
|
|
self.assertEqual(self.decode(changed[:-64] + original[-64:]), "reject")
|
|
for length_byte in [0, 87, 229, 231, 255]:
|
|
changed = bytearray(original)
|
|
changed[13] = length_byte
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
for offset in range(len(original) - 64, len(original)):
|
|
with self.subTest(signature_byte=offset):
|
|
changed = bytearray(original)
|
|
changed[offset] ^= 1
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
|
|
def test_single_packet_boundaries_and_first_two_fragment_command(self):
|
|
digest = hashlib.sha256(PUBLIC).digest()[:16]
|
|
for token, entries, maximum in [
|
|
("all", [], 87), (PUBLIC[:4].hex(), [(4, PUBLIC[:4])], 82),
|
|
(PUBLIC[:6].hex(), [(6, PUBLIC[:6])], 80),
|
|
(PUBLIC.hex(), None, 72)]:
|
|
with self.subTest(token=token):
|
|
prefix = "set flood.rule.1 "
|
|
command = prefix + "a" * (maximum - len(prefix))
|
|
single = (envelope(command, target=digest) if entries is None
|
|
else envelope2(entries, command))
|
|
self.assertEqual(len(single), 165)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
command), single.hex())
|
|
self.assertNotEqual(self.decode(single), "reject")
|
|
for index in [0, 1]:
|
|
self.assertEqual(self.run_tool("fragment", single.hex(), index, 165), "reject")
|
|
longer = (envelope(command + "a", target=digest) if entries is None
|
|
else envelope2(entries, command + "a"))
|
|
self.assertEqual(len(longer), 166)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
command + "a"), longer.hex())
|
|
self.assertNotEqual(self.decode(longer), "reject")
|
|
for index, size in [(0, 165), (1, 23)]:
|
|
actual = bytes.fromhex(self.run_tool("fragment", longer.hex(), index, 165))
|
|
self.assertEqual(actual, fragment_frame(longer, index))
|
|
self.assertEqual(len(actual), size)
|
|
|
|
def test_two_fragments_reassemble_out_of_order_with_one_real_signature(self):
|
|
for size in [166, 167, 307, 308]:
|
|
with self.subTest(size=size):
|
|
prefix = "set flood.rule.1 "
|
|
command = prefix + "a" * (size - 78 - len(prefix))
|
|
original = envelope2(command=command)
|
|
self.assertEqual(len(original), size)
|
|
# The fixture requires exactly one signature call for encode
|
|
# and no signature calls while cutting either fragment.
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "all", NOW, NOW + 120,
|
|
command), original.hex())
|
|
parts = [bytes.fromhex(self.run_tool("fragment", original.hex(), index, 165))
|
|
for index in [0, 1]]
|
|
for index, part in enumerate(parts):
|
|
self.assertEqual(part, fragment_frame(original, index))
|
|
self.assertLessEqual(len(part), 165)
|
|
self.assertEqual(self.run_tool("parse-fragment", part.hex()),
|
|
f"{NOW}\n{size}\n{index}\n{part[11:].hex()}")
|
|
self.assertEqual(self.decode(part), "reject")
|
|
for order in [(0, 1), (1, 0), (0, 0, 1), (1, 1, 0)]:
|
|
with self.subTest(order=order):
|
|
assembly = bytearray(size)
|
|
for index in order:
|
|
part = parts[index]
|
|
assembly[index * 154:index * 154 + len(part) - 11] = part[11:]
|
|
assembly = bytes(assembly)
|
|
self.assertEqual(assembly, original)
|
|
PUBLISHER.public_key().verify(assembly[-64:], DOMAIN + CHANNEL + assembly[:-64])
|
|
for node in [PUBLIC, OTHER_PUBLIC]:
|
|
self.assertEqual(self.decode(assembly, self_key=node, metadata=True),
|
|
f"{NOW}\n{NOW + 120}\n{command}\n1")
|
|
|
|
def test_fragment_codec_bounds_and_exact_shapes_reject_without_output_writes(self):
|
|
prefix = "set flood.rule.1 "
|
|
original = envelope2(command=prefix + "a" * (88 - len(prefix)))
|
|
parts = [fragment_frame(original, index) for index in [0, 1]]
|
|
for source in [b"", original[:165], original + b"a" * (309 - len(original)),
|
|
b"FMX1" + original[4:], b"FMP1" + original[4:]]:
|
|
with self.subTest(source_length=len(source), magic=source[:4]):
|
|
self.assertEqual(self.run_tool("fragment", source.hex(), 0, 229), "reject")
|
|
for index in [2, 255]:
|
|
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), "reject")
|
|
for index, required in [(0, 165), (1, 23)]:
|
|
for capacity in [0, 10, required - 1]:
|
|
self.assertEqual(self.run_tool("fragment", original.hex(), index, capacity), "reject")
|
|
self.assertEqual(self.run_tool("fragment", original.hex(), index, 229), parts[index].hex())
|
|
malformed = [b"", b"FMP1", parts[0][:10], parts[0][:11],
|
|
parts[0][:-1], parts[0] + b"\0", parts[1][:-1], parts[1] + b"\0",
|
|
b"FMX1" + parts[0][4:]]
|
|
for total in [0, 154, 165, 309, 65535]:
|
|
malformed.append(parts[0][:8] + struct.pack("<H", total) + parts[0][10:])
|
|
for index in [1, 2, 255]:
|
|
malformed.append(parts[0][:10] + bytes([index]) + parts[0][11:])
|
|
malformed.append(parts[1][:10] + b"\0" + parts[1][11:])
|
|
for part in malformed:
|
|
with self.subTest(part=part.hex()):
|
|
self.assertEqual(self.run_tool("parse-fragment", part.hex()), "reject")
|
|
|
|
def test_fragment_tampering_and_mixed_commands_cannot_forge_complete_signature(self):
|
|
prefix = "set flood.rule.1 "
|
|
command = prefix + "a" * (230 - len(prefix))
|
|
original = envelope2(command=command)
|
|
parts = [fragment_frame(original, index) for index in [0, 1]]
|
|
self.assertNotEqual(self.decode(original), "reject")
|
|
# Every chunk contains signed data. Matching frame metadata and an
|
|
# allowed changed command cannot substitute for the full signature.
|
|
for index, data_offset in [(0, 14 + len(prefix)), (1, 0), (1, 153)]:
|
|
changed = bytearray(parts[index])
|
|
changed[11 + data_offset] ^= 1
|
|
self.assertNotEqual(self.run_tool("parse-fragment", changed.hex()), "reject")
|
|
chunks = [parts[0][11:], parts[1][11:]]
|
|
chunks[index] = changed[11:]
|
|
self.assertEqual(self.decode(b"".join(chunks)), "reject")
|
|
different = envelope2(command=prefix + "b" * (230 - len(prefix)))
|
|
self.assertNotEqual(self.decode(different), "reject")
|
|
different_parts = [fragment_frame(different, index) for index in [0, 1]]
|
|
for combined in [parts[0][11:] + different_parts[1][11:],
|
|
different_parts[0][11:] + parts[1][11:]]:
|
|
self.assertEqual(self.decode(combined), "reject")
|
|
# Frame metadata is deliberately unauthenticated and only admits a
|
|
# bounded assembly. Runtime must compare it to the signed sequence.
|
|
changed_sequence = parts[0][:4] + struct.pack("<I", NOW + 1) + parts[0][8:]
|
|
self.assertTrue(self.run_tool("parse-fragment", changed_sequence.hex()).startswith(f"{NOW + 1}\n"))
|
|
self.assertEqual(self.decode(changed_sequence), "reject")
|
|
|
|
def test_real_signature_round_trip_for_mixed_prefixes_and_full_keys(self):
|
|
entries = [(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]), (16, hashlib.sha256(PUBLIC).digest()[:16])]
|
|
token = f"{PUBLIC[:4].hex()};{OTHER_PUBLIC[:6].hex()};{PUBLIC.hex()}"
|
|
expected = envelope2(entries)
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
|
|
self.assertTrue(self.decode(expected, self_key=OTHER_PUBLIC, metadata=True).endswith("\n1"))
|
|
unrelated = hashlib.sha256(b"unrelated fleet node").digest()
|
|
self.assertEqual(self.decode(expected, self_key=unrelated), "reject")
|
|
|
|
def test_region_and_home_targets_have_distinct_cross_language_signed_records(self):
|
|
entries = [(0x20, b"sea"), (0x20, b"pdx"), (0x21, b"Sea"),
|
|
(0x20, b"$Private"), (4, PUBLIC[:4])]
|
|
token = f"sea;pdx;home:#Sea;region:$Private;{PUBLIC[:4].hex()}"
|
|
expected = envelope2(entries)
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
|
self.assertEqual(actual, expected.hex())
|
|
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
|
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
|
|
# Each target is OR: configured and home matches select the intended
|
|
# callback domain independently of whether the public-key prefix fits.
|
|
for configured, home in [(["sea"], []), (["#pdx"], []), ([], ["#Sea"]),
|
|
(["$Private"], [])]:
|
|
with self.subTest(configured=configured, home=home):
|
|
self.assertEqual(self.decode_regions(expected, configured, home, self_key=OTHER_PUBLIC),
|
|
f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
|
self.assertEqual(self.decode(expected, self_key=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode_regions(expected, ["Sea"], ["sea"], self_key=OTHER_PUBLIC), "reject")
|
|
|
|
def test_region_exact_matching_preserves_case_private_names_and_hash_aliases(self):
|
|
for token, kind, canonical in [
|
|
("sea", 0x20, "sea"), ("#Sea", 0x20, "Sea"),
|
|
("region:#sea", 0x20, "sea"), ("home:#Sea", 0x21, "Sea"),
|
|
("region:$Private", 0x20, "$Private"), ("home:$Private", 0x21, "$Private")]:
|
|
with self.subTest(token=token):
|
|
expected = envelope2([(kind, canonical.encode("ascii"))])
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertEqual(self.decode(expected), "reject")
|
|
configured, home = ([canonical], []) if kind == 0x20 else ([], [canonical])
|
|
self.assertTrue(self.decode_regions(expected, configured, home).endswith("\n1"))
|
|
configured, home = ([], [canonical]) if kind == 0x20 else ([canonical], [])
|
|
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
|
|
for wrong in [canonical.swapcase(), canonical + "-other", canonical[:-1]]:
|
|
configured, home = ([wrong], []) if kind == 0x20 else ([], [wrong])
|
|
self.assertEqual(self.decode_regions(expected, configured, home), "reject")
|
|
canonical = self.run_tool("encode2", CHANNEL.hex(), "home:sea", NOW, NOW + 120, "get radio2")
|
|
alias = self.run_tool("encode2", CHANNEL.hex(), "home:#sea", NOW, NOW + 120, "get radio2")
|
|
self.assertEqual(canonical, alias)
|
|
|
|
def test_named_target_parser_never_reinterprets_malformed_keys_or_command_verbs(self):
|
|
for token in ["zzzzzzzz", "z" * 12, "g" * 64, "a", "dead", "a" * 16,
|
|
"all;sea", "sea;all", "region:", "home:", "#", "region:#", "home:#",
|
|
"region:*", "home:*", "region:bad.name", "home:bad:name", "sea;pdx;",
|
|
"region:bad space", "region:bad\n", "region:bad\x7f", "region:badé",
|
|
"region:" + "z" * 31, "home:" + "z" * 31,
|
|
"get", "set", "del", "time", "clock"]:
|
|
with self.subTest(token=token):
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), "reject")
|
|
# Explicit selectors remove any ambiguity with public-key formats and
|
|
# CLI verbs. Uppercase names retain the RegionMap's case sensitivity.
|
|
for token, kind, name in [
|
|
("region:all", 0x20, "all"), ("region:dead", 0x20, "dead"),
|
|
("region:zzzzzzzz", 0x20, "zzzzzzzz"), ("home:a", 0x21, "a"),
|
|
("region:get", 0x20, "get"), ("home:get", 0x21, "get"),
|
|
("#get", 0x20, "get"), ("region:clock", 0x20, "clock"),
|
|
("home:time", 0x21, "time"), ("ALL", 0x20, "ALL")]:
|
|
with self.subTest(token=token):
|
|
expected = envelope2([(kind, name.encode("ascii"))])
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertEqual(self.decode(expected), "reject")
|
|
configured, home = ([name], []) if kind == 0x20 else ([], [name])
|
|
self.assertNotEqual(self.decode_regions(expected, configured, home), "reject")
|
|
|
|
def test_region_record_schema_is_validated_after_an_earlier_matching_key(self):
|
|
matching = (4, PUBLIC[:4])
|
|
for kind in [0x20, 0x21]:
|
|
for name in [b"", b"z" * 31, b"*", b"bad.name", b"bad:name", b"bad/name",
|
|
b"bad\0", b"bad\n", b"bad\x7f", b"bad\x80", b"bad;name", b"bad name"]:
|
|
with self.subTest(kind=kind, name=name):
|
|
signed = envelope2([matching, (kind, name)])
|
|
self.assertEqual(self.decode_regions(signed, ["sea"], ["pdx"]), "reject")
|
|
expected = envelope2([matching, (0x20, b"sea"), (0x21, b"pdx")])
|
|
self.assertNotEqual(self.decode_regions(expected, ["sea"], ["pdx"]), "reject")
|
|
# The declared length is signed as part of the record, and it must
|
|
# still frame every later entry and the command exactly.
|
|
for value in [0, 1, 4, 30, 31, 255]:
|
|
changed = bytearray(expected)
|
|
changed[19] = value
|
|
self.assertEqual(self.decode_regions(changed, ["sea"], ["pdx"]), "reject")
|
|
unknown = envelope2([matching, (0x22, b"\3sea")])
|
|
self.assertEqual(self.decode_regions(unknown, ["sea"], ["pdx"]), "reject")
|
|
|
|
def test_signature_binds_region_name_and_configured_versus_home_selector(self):
|
|
original = envelope2([(0x20, b"sea")])
|
|
for changed in [envelope2([(0x20, b"pdx")]), envelope2([(0x21, b"sea")]),
|
|
envelope2([(0x20, b"Sea")]), envelope2([(0x20, b"sea"), (0x21, b"sea")])]:
|
|
with self.subTest(changed=changed.hex()):
|
|
self.assertNotEqual(self.decode_regions(changed, ["sea", "pdx", "Sea"], ["sea"]), "reject")
|
|
self.assertEqual(self.decode_regions(changed[:-64] + original[-64:],
|
|
["sea", "pdx", "Sea"], ["sea"]), "reject")
|
|
self.assertEqual(self.decode_regions(original, ["sea"], publisher=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode_regions(original, ["sea"], channel=bytes(range(17, 33))), "reject")
|
|
self.assertEqual(self.decode_regions(original, ["sea"], now=0), "reject")
|
|
self.assertEqual(self.decode_regions(original, ["sea"], now=NOW + 121), "reject")
|
|
|
|
def test_named_targets_share_byte_count_and_fragment_budgets_without_partial_output(self):
|
|
for count in [1, 16, 17]:
|
|
token = ";".join(["sea"] * count)
|
|
expected = envelope2([(0x20, b"sea")] * count)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertTrue(self.decode_regions(expected, ["sea"]).endswith("\n1"))
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join(["sea"] * 18), NOW,
|
|
NOW + 120, "get radio2"), "reject")
|
|
records = [(0x20, b"z" * 30), (0x21, b"Y" * 30), (0x20, b"x" * 20)]
|
|
token = "region:" + "z" * 30 + ";home:" + "Y" * 30 + ";region:" + "x" * 20
|
|
prefix = "set flood.rule.1 "
|
|
maximum = prefix + "a" * (144 - len(prefix))
|
|
expected = envelope2(records, maximum)
|
|
self.assertEqual(len(expected), 308) # Exactly86 bytes of target records.
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
maximum), expected.hex())
|
|
self.assertTrue(self.decode_regions(expected, ["z" * 30]).endswith("\n1"))
|
|
for operation in ["encode2", "encode2-wide"]:
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
|
|
maximum + "a"), "reject")
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token + "x", NOW, NOW + 120,
|
|
"get radio2"), "reject")
|
|
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
|
|
for index, part in enumerate(parts):
|
|
self.assertEqual(part, fragment_frame(expected, index))
|
|
self.assertEqual(len(part), 165)
|
|
self.assertEqual(self.decode_regions(part, ["z" * 30]), "reject")
|
|
assembled = parts[0][11:] + parts[1][11:]
|
|
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
|
|
self.assertTrue(self.decode_regions(assembled, ["z" * 30]).endswith("\n1"))
|
|
# Tamper with a later home name while the first configured name still
|
|
# matches. The full reconstructed signature must reject this change.
|
|
changed = bytearray(parts[0])
|
|
changed[11 + 13 + 32 + 2] = ord("Z")
|
|
self.assertEqual(self.decode_regions(changed[11:] + parts[1][11:], ["z" * 30]), "reject")
|
|
|
|
def test_gps_cross_language_signatures_use_microdegrees_and_metres_exactly(self):
|
|
for token, latitude, longitude, radius in [
|
|
("gps:47.6062,-122.3321:10", 47606200, -122332100, 10000),
|
|
("gps:+90,-180:0.001", 90000000, -180000000, 1),
|
|
("gps:-90,+180:20050.000", -90000000, 180000000, 20050000),
|
|
("gps:0.000001,-0.000001:1.001", 1, -1, 1001),
|
|
("gps:0,0:1", 0, 0, 1000)]:
|
|
with self.subTest(token=token):
|
|
record = struct.pack("<iiI", latitude, longitude, radius)
|
|
expected = envelope2([(0x22, record)])
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertEqual(expected[13:26], b"\x22" + record)
|
|
self.assertEqual(len(expected), 101)
|
|
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
|
self.assertEqual(self.decode(expected), "reject")
|
|
self.assertEqual(self.decode_geo(expected, latitude, longitude),
|
|
f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
|
self.assertEqual(self.decode_geo(expected, latitude, longitude, known=False), "reject")
|
|
|
|
def test_semicolon_target_lists_preserve_gps_at_first_middle_and_last(self):
|
|
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
|
|
circle = "gps:47.6062,-122.3321:25"
|
|
for token, entries in [
|
|
(f"{circle};sea;home:pdx", [gps, (0x20, b"sea"), (0x21, b"pdx")]),
|
|
(f"sea;{circle};home:pdx", [(0x20, b"sea"), gps, (0x21, b"pdx")]),
|
|
(f"sea;home:pdx;{circle}", [(0x20, b"sea"), (0x21, b"pdx"), gps]),
|
|
(f"{circle};{circle}", [gps, gps])]:
|
|
with self.subTest(token=token):
|
|
expected = envelope2(entries)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
PUBLISHER.public_key().verify(expected[-64:], DOMAIN + CHANNEL + expected[:-64])
|
|
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
|
|
if len(entries) == 3:
|
|
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"],
|
|
home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
|
|
else:
|
|
self.assertEqual(self.decode_geo(expected, known=False), "reject")
|
|
|
|
def test_truncated_gps_tokens_only_accept_a_complete_nonempty_radius(self):
|
|
token = "gps:47.6062,-122.3321:25"
|
|
for length in range(4, len(token) + 1):
|
|
with self.subTest(length=length):
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), token[:length], NOW, NOW + 120,
|
|
"get radio2")
|
|
if length in (len(token) - 1, len(token)):
|
|
radius = int(token[:length].rsplit(":", 1)[1]) * 1000
|
|
expected = envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, radius))])
|
|
self.assertEqual(actual, expected.hex())
|
|
else:
|
|
self.assertEqual(actual, "reject")
|
|
|
|
def test_comma_lists_and_malformed_gps_delimiters_cannot_send_partial_commands(self):
|
|
for token in [
|
|
"sea,pdx", f"{PUBLIC[:4].hex()},{OTHER_PUBLIC[:4].hex()}",
|
|
"region:sea,home:pdx", "sea;home:pdx,sea", "gps:47.6062:-122.3321:25",
|
|
"gps:47.6062;-122.3321:25", "gps:47.6062,-122.3321,25",
|
|
"gps:47.6062,,-122.3321:25", "gps:47.6062,-122.3321,:25",
|
|
"gps:47.6062,-122.3321:25,sea", ";gps:47.6062,-122.3321:25",
|
|
"gps:47.6062,-122.3321:25;", "sea;;gps:47.6062,-122.3321:25",
|
|
"sea;gps:47.6062,-122.3321:25;;home:pdx", "sea;gps:47.6062;home:pdx",
|
|
"sea;gps:47.6062,-122.3321;home:pdx", "sea;gps:47.6062,-122.3321:;home:pdx",
|
|
"sea;gps:47.6062,-122.3321:25;home:pdx,sea"]:
|
|
with self.subTest(token=token):
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), "reject")
|
|
|
|
def test_semicolon_mixed_geographic_lists_keep_record_and_byte_limits(self):
|
|
circle = "gps:47.6062,-122.3321:25"
|
|
gps = (0x22, struct.pack("<iiI", 47606200, -122332100, 25000))
|
|
for token, entries in [
|
|
(";".join([circle] + ["z"] * 16), [gps] + [(0x20, b"z")] * 16),
|
|
(";".join([circle] + ["sea"] * 14), [gps] + [(0x20, b"sea")] * 14)]:
|
|
with self.subTest(token=token):
|
|
expected = envelope2(entries)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
|
|
for names in [["z"] * 17, ["sea"] * 15]:
|
|
token = ";".join([circle] + names)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), "reject")
|
|
|
|
def test_malformed_gps_decimal_tokens_never_fall_back_to_region_names(self):
|
|
for token in [
|
|
"gps:", "gps:0", "gps:0:0", "gps:0,0:", "gps:,0:1", "gps:0,:1", "gps:0,0:1:2",
|
|
"gps:0,0:0", "gps:0,0:0.000", "gps:0,0:0.0001", "gps:0,0:20050.001",
|
|
"gps:90.000001,0:1", "gps:-90.000001,0:1", "gps:0,180.000001:1",
|
|
"gps:0,-180.000001:1", "gps:0.0000001,0:1", "gps:0,0.0000001:1",
|
|
"gps:0,0:1.0001", "gps:0,0:+1", "gps:0,0:-1", "gps:NaN,0:1", "gps:0,Inf:1",
|
|
"gps:0,0:1e3", "gps:1e1,0:1", "gps:.1,0:1", "gps:1.,0:1", "gps:0,0:.1",
|
|
"gps:0,0:1.", "gps:01,0:1", "gps:0,00:1", "gps:0,0:01", "gps:--1,0:1",
|
|
"gps:++1,0:1", "gps:+,0:1", "gps:0,0:4294967296", "gps:999999999999999999,0:1",
|
|
"gps:0,0:1m", "gps:0,0:1\n", "gps: 0:0:1", "gps:0,0:1;all"]:
|
|
with self.subTest(token=token):
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), "reject")
|
|
|
|
def test_geographic_math_matches_independent_distance_at_boundaries(self):
|
|
cases = [
|
|
(0, 0, 0, 0), (0, 0, 0, 1000000),
|
|
(0, 179999999, 0, -179999999), (90000000, 0, 90000000, 180000000),
|
|
(0, 0, 0, 180000000), (47606200, -122332100, 47610000, -122330000),
|
|
(47606200, -122332100, 45515200, -122678400),
|
|
(-89999999, -180000000, -89999999, 180000000)]
|
|
for latitude, longitude, local_latitude, local_longitude in cases:
|
|
center = math.radians(latitude / 1000000)
|
|
local = math.radians(local_latitude / 1000000)
|
|
half_latitude = math.radians((local_latitude - latitude) / 1000000) / 2
|
|
half_longitude = math.radians((local_longitude - longitude) / 1000000) / 2
|
|
value = (math.sin(half_latitude) ** 2 + math.cos(center) * math.cos(local)
|
|
* math.sin(half_longitude) ** 2)
|
|
value = max(0, min(1, value))
|
|
distance = 2 * 6371008.8 * math.atan2(math.sqrt(value), math.sqrt(1 - value))
|
|
radii = {1, max(1, int(math.floor(distance))), max(1, int(math.floor(distance)) + 1), 20050000}
|
|
for radius in radii:
|
|
with self.subTest(center=(latitude, longitude), local=(local_latitude, local_longitude), radius=radius):
|
|
actual = self.run_tool("within-radius", latitude, longitude, radius,
|
|
local_latitude, local_longitude)
|
|
self.assertEqual(actual, str(int(distance <= radius)))
|
|
for latitude, longitude, radius, local_latitude, local_longitude in [
|
|
(90000001, 0, 1, 0, 0), (0, 180000001, 1, 0, 0),
|
|
(0, 0, 1, -90000001, 0), (0, 0, 1, 0, -180000001),
|
|
(0, 0, 0, 0, 0), (0, 0, 20050001, 0, 0),
|
|
(2147483647, -2147483648, 4294967295, 0, 0)]:
|
|
self.assertEqual(self.run_tool("within-radius", latitude, longitude, radius,
|
|
local_latitude, local_longitude), "0")
|
|
|
|
def test_geographic_region_home_and_key_records_are_independent_signed_or_matches(self):
|
|
record = struct.pack("<iiI", 47606200, -122332100, 1000)
|
|
entries = [(0x22, record), (0x20, b"sea"), (0x21, b"pdx"), (4, PUBLIC[:4])]
|
|
token = f"gps:47.6062,-122.3321:1;sea;home:pdx;{PUBLIC[:4].hex()}"
|
|
expected = envelope2(entries)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
"get radio2"), expected.hex())
|
|
self.assertTrue(self.decode(expected, metadata=True).endswith("\n1"))
|
|
self.assertTrue(self.decode_geo(expected, self_key=OTHER_PUBLIC).endswith("\n1"))
|
|
self.assertTrue(self.decode_geo(expected, known=False, configured=["sea"], self_key=OTHER_PUBLIC).endswith("\n1"))
|
|
self.assertTrue(self.decode_geo(expected, known=False, home=["pdx"], self_key=OTHER_PUBLIC).endswith("\n1"))
|
|
self.assertEqual(self.decode_geo(expected, known=False, self_key=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode_geo(expected, 45515200, -122678400, self_key=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode_geo(expected, 90000001, 0, self_key=OTHER_PUBLIC), "reject")
|
|
# Invalid later geographic records reject even after a matching key.
|
|
for latitude, longitude, radius in [
|
|
(90000001, 0, 1000), (-90000001, 0, 1000), (0, 180000001, 1000),
|
|
(0, -180000001, 1000), (0, 0, 0), (0, 0, 20050001), (0, 0, 4294967295)]:
|
|
invalid = envelope2([(4, PUBLIC[:4]), (0x22, struct.pack("<iiI", latitude, longitude, radius))])
|
|
self.assertEqual(self.decode_geo(invalid), "reject")
|
|
for missing in range(12):
|
|
invalid = envelope2([(4, PUBLIC[:4]), (0x22, record[:missing])])
|
|
self.assertEqual(self.decode_geo(invalid), "reject")
|
|
|
|
def test_signature_binds_every_geographic_field_and_fragmented_circle_command(self):
|
|
record = struct.pack("<iiI", 47606200, -122332100, 10000)
|
|
original = envelope2([(0x22, record)])
|
|
for changed in [
|
|
envelope2([(0x22, struct.pack("<iiI", 47606201, -122332100, 10000))]),
|
|
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332101, 10000))]),
|
|
envelope2([(0x22, struct.pack("<iiI", 47606200, -122332100, 10001))]),
|
|
envelope2([(0x22, record), (0x20, b"sea")])]:
|
|
with self.subTest(changed=changed.hex()):
|
|
self.assertNotEqual(self.decode_geo(changed), "reject")
|
|
self.assertEqual(self.decode_geo(changed[:-64] + original[-64:]), "reject")
|
|
self.assertEqual(self.decode_geo(original, publisher=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode_geo(original, channel=bytes(range(17, 33))), "reject")
|
|
self.assertEqual(self.decode_geo(original, now=0), "reject")
|
|
self.assertEqual(self.decode_geo(original, now=NOW + 121), "reject")
|
|
token = "gps:47.6062,-122.3321:10"
|
|
prefix = "set flood.rule.1 "
|
|
maximum = prefix + "a" * (217 - len(prefix))
|
|
expected = envelope2([(0x22, record)], maximum)
|
|
self.assertEqual(len(expected), 308)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120,
|
|
maximum), expected.hex())
|
|
self.assertTrue(self.decode_geo(expected).endswith("\n1"))
|
|
for operation in ["encode2", "encode2-wide"]:
|
|
self.assertEqual(self.run_tool(operation, CHANNEL.hex(), token, NOW, NOW + 120,
|
|
maximum + "a"), "reject")
|
|
parts = [bytes.fromhex(self.run_tool("fragment", expected.hex(), index, 165)) for index in [0, 1]]
|
|
assembled = parts[0][11:] + parts[1][11:]
|
|
self.assertEqual(assembled, expected)
|
|
PUBLISHER.public_key().verify(assembled[-64:], DOMAIN + CHANNEL + assembled[:-64])
|
|
self.assertNotEqual(self.decode_geo(assembled), "reject")
|
|
for part in parts:
|
|
self.assertEqual(self.decode_geo(part), "reject")
|
|
for count in [1, 6]:
|
|
token_list = ";".join([token] * count)
|
|
signed = envelope2([(0x22, record)] * count)
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token_list, NOW, NOW + 120,
|
|
"get radio2"), signed.hex())
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), ";".join([token] * 7), NOW,
|
|
NOW + 120, "get radio2"), "reject")
|
|
|
|
def test_single_raw_prefix_is_a_broadcast_capability_for_matching_collisions(self):
|
|
first = PUBLIC
|
|
second = PUBLIC[:4] + bytes([PUBLIC[4] ^ 1]) + PUBLIC[5:]
|
|
broad = envelope2([(4, PUBLIC[:4])])
|
|
self.assertTrue(self.decode(broad, self_key=first, metadata=True).endswith("\n1"))
|
|
self.assertTrue(self.decode(broad, self_key=second, metadata=True).endswith("\n1"))
|
|
narrow = envelope2([(6, PUBLIC[:6])])
|
|
self.assertNotEqual(self.decode(narrow, self_key=first), "reject")
|
|
self.assertEqual(self.decode(narrow, self_key=second), "reject")
|
|
full = envelope2([(16, hashlib.sha256(first).digest()[:16])])
|
|
self.assertTrue(self.decode(full, self_key=first, metadata=True).endswith("\n0"))
|
|
self.assertEqual(self.decode(full, self_key=second), "reject")
|
|
|
|
def test_fmc2_all_is_accepted_and_duplicate_matches_decode_once(self):
|
|
self.assertTrue(self.decode(envelope2(), metadata=True).endswith("\n1"))
|
|
duplicates = envelope2([(16, hashlib.sha256(PUBLIC).digest()[:16])] * 2)
|
|
result = self.decode(duplicates, metadata=True)
|
|
self.assertEqual(result, f"{NOW}\n{NOW + 120}\nget radio2\n1")
|
|
|
|
def test_legitimate_zero_prefixes_are_accepted_but_zero_complete_identity_is_not(self):
|
|
node = b"\0" * 6 + b"\1" * 26
|
|
for width in [4, 6]:
|
|
with self.subTest(width=width):
|
|
self.assertNotEqual(self.decode(envelope2([(width, b"\0" * width)]), self_key=node), "reject")
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), "0" * 64, NOW, NOW + 120, "get radio2"), "reject")
|
|
|
|
def test_multitarget_signature_binds_every_target_entry_and_count(self):
|
|
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6]),
|
|
(16, hashlib.sha256(PUBLIC).digest()[:16])])
|
|
# The first prefix still matches. Every later record byte nevertheless
|
|
# remains signed, so modifying a nonmatching or duplicate target fails.
|
|
for offset in range(18, 13 + 5 + 7 + 17):
|
|
with self.subTest(target_byte=offset):
|
|
changed = bytearray(original)
|
|
changed[offset] ^= 1
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
changed = bytearray(original)
|
|
changed[12] = 2
|
|
self.assertEqual(self.decode(changed), "reject")
|
|
|
|
def test_malformed_target_after_matching_record_is_rejected_even_when_signed(self):
|
|
matching = (4, PUBLIC[:4])
|
|
malformed = [
|
|
[(3, b"abc")], [(0, b"")], [(5, b"abcde")], [(32, PUBLIC)],
|
|
[(16, b"x" * 15)], [(6, b"x" * 5)], [(255, b"")]]
|
|
for suffix in malformed:
|
|
with self.subTest(suffix=suffix):
|
|
self.assertEqual(self.decode(envelope2([matching] + suffix)), "reject")
|
|
self.assertEqual(self.decode(envelope2([matching], count=18)), "reject")
|
|
self.assertEqual(self.decode(envelope2([matching], count=0)), "reject")
|
|
self.assertEqual(self.decode(envelope2([matching], count=2)), "reject")
|
|
|
|
def test_new_target_parser_refuses_partial_or_mixed_all_lists(self):
|
|
for token in ["", "all;" + PUBLIC[:4].hex(), PUBLIC[:4].hex() + ";all",
|
|
PUBLIC[:4].hex() + ";", ";" + PUBLIC[:4].hex(),
|
|
PUBLIC[:4].hex() + ";;" + OTHER_PUBLIC[:4].hex(),
|
|
PUBLIC[:4].hex() + ";zzzzzzzz", "1" * 10, "1" * 16,
|
|
";".join([PUBLIC.hex()] * 6), ";".join([PUBLIC[:4].hex()] * 18)]:
|
|
with self.subTest(token=token):
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2"), "reject")
|
|
|
|
def test_fmc2_packet_capacity_is_checked_before_signing_or_sending(self):
|
|
maximum = "set flood.rule.1 " + "a" * (215 - len("set flood.rule.1 "))
|
|
entries = [(4, PUBLIC[:4])] * 3
|
|
expected = envelope2(entries, maximum)
|
|
self.assertEqual(len(expected), 308)
|
|
token = ";".join([PUBLIC[:4].hex()] * 3)
|
|
actual = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum)
|
|
self.assertEqual(actual, expected.hex())
|
|
self.assertNotEqual(self.decode(expected), "reject")
|
|
token += ";" + PUBLIC[:4].hex()
|
|
self.assertEqual(self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, maximum), "reject")
|
|
self.assertEqual(self.decode(envelope2(entries * 2, maximum)), "reject")
|
|
for count in [15, 16, 17]:
|
|
token = ";".join([PUBLIC[:4].hex()] * count)
|
|
result = self.run_tool("encode2", CHANNEL.hex(), token, NOW, NOW + 120, "get radio2")
|
|
self.assertNotEqual(result, "reject")
|
|
|
|
def test_fmc2_rejects_short_trailing_and_signed_forbidden_commands(self):
|
|
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
|
|
for length in [0, 12, 13, 14, 18, 19, len(original) - 1]:
|
|
self.assertEqual(self.decode(original[:length]), "reject")
|
|
self.assertEqual(self.decode(original + b"\0"), "reject")
|
|
for command in ["get prv.key", "set fleet.controller off", "get radio2\nreboot", b"get radio2\0"]:
|
|
self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], command)), "reject")
|
|
|
|
def test_fmc2_uses_same_signed_sequence_and_clock_rules(self):
|
|
entries = [(4, PUBLIC[:4])]
|
|
original = envelope2(entries)
|
|
self.assertEqual(struct.unpack("<I", original[4:8])[0], NOW)
|
|
forged = envelope2(entries, sequence=NOW + 1)[:-64] + original[-64:]
|
|
self.assertEqual(self.decode(forged), "reject")
|
|
for sequence, expires, now in [(NOW + 61, NOW + 120, NOW),
|
|
(NOW, NOW + 601, NOW), (NOW, NOW + 120, NOW + 121)]:
|
|
self.assertEqual(self.decode(envelope2(entries, sequence=sequence, expires=expires), now=now), "reject")
|
|
|
|
def test_fmc2_signature_cannot_move_between_channels_or_publishers(self):
|
|
original = envelope2([(4, PUBLIC[:4]), (6, OTHER_PUBLIC[:6])])
|
|
self.assertEqual(self.decode(original, publisher=OTHER_PUBLIC), "reject")
|
|
self.assertEqual(self.decode(original, channel=bytes(range(17, 33))), "reject")
|
|
self.assertEqual(self.decode(envelope2([(4, PUBLIC[:4])], publisher=OTHER)), "reject")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|