diff --git a/README.md b/README.md index 3d808618..44dd3f71 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Here is a listing of files in the configs folder in this repo that pertain to FN - `adj_site_map.example.yml` - This is an example configuration file configuring adjacent site mappings for trunked `dvmhost` instances. - `fne-config.example.yml` - This is the main/primary example configuration file for an FNE instance. - `peer_list.example.dat` - This is a simple CSV-style file containing access control permissions for peers allowed to connect to the FNE (this includes both downstream peers (like `dvmhost` or `dvmbridge`) and other `dvmfne` instances connecting *to* the FNE instance). -- `rid_acl.example.dat` - This is a simple CSV-style file containing the access control permissions for radio ID (RID)s allowed to use a configured system/network. +- `rid_acl.example.dat` - This is a simple CSV-style file containing the access control permissions for radio ID (RID)s allowed to use a configured system/network. It also carries per-RID key policy for whether a RID can request keys, whether it can be OTAR rekeyed, and an optional allowed key ID list. - `talkgroup_rules.example.yml` - This is the second most important configuration file for an FNE, this file describes all the talkgroups and their related access control and configuration parameters. There is another file that is attributed to the FNE that an example is not provided for and that is the `key-container.ekc` file. This file provides cryptographic material needed for providing keyloading functionality across a configured system/network. diff --git a/configs/rid_acl.example.dat b/configs/rid_acl.example.dat index d4294133..7484ca7f 100644 --- a/configs/rid_acl.example.dat +++ b/configs/rid_acl.example.dat @@ -7,7 +7,11 @@ # * ENABLED [REQUIRED] - Flag indicating whether or not this radio ID entry is enabled and valid. # * ALIAS [OPTIONAL] - Textual string representing an alias for this radio ID entry. # * IP ADDRESS [OPTIONAL] - IP Address assigned to this radio ID. +# * CAN REQUEST KEYS [OPTIONAL] - Flag indicating whether this RID can request keys. (1 = Enabled / 0 = Disabled) +# * CAN REKEY [OPTIONAL] - Flag indicating whether this RID can receive OTAR rekey payloads. (1 = Enabled / 0 = Disabled) +# * ALLOWED KIDS [OPTIONAL] - Pipe-delimited list of allowed 16-bit key IDs for this RID in hexadecimal. (e.g. 0064|0101|0A50) +# If this list is left empty/blank, the RID is assumed to be allowed to request any/all keys. # -# Entry Format: "RID,Enabled (1 = Enabled / 0 = Disabled),Optional Alias,Optional IP Address," +# Entry Format: "RID,Enabled (1 = Enabled / 0 = Disabled),Optional Alias,Optional IP Address,Can Request Keys,Can Rekey,Allowed KIDs," # Example: -#1234,1,RID Alias,IP Address, +#1234,1,RID Alias,IP Address,1,1,0064|0101, diff --git a/src/common/lookups/RadioIdLookup.cpp b/src/common/lookups/RadioIdLookup.cpp index 7a8de404..2d0d2bbc 100644 --- a/src/common/lookups/RadioIdLookup.cpp +++ b/src/common/lookups/RadioIdLookup.cpp @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2016 Jonathan Naylor, G4KLX - * Copyright (C) 2017-2022,2025 Bryan Biedenkapp, N2PLL + * Copyright (C) 2017-2022,2025-2026 Bryan Biedenkapp, N2PLL * Copyright (c) 2024 Patrick McDonnell, W3AXL * */ @@ -16,9 +16,11 @@ using namespace lookups; #include +#include #include #include #include +#include // --------------------------------------------------------------------------- // Static Class Members @@ -75,27 +77,30 @@ void RadioIdLookup::clear() void RadioIdLookup::toggleEntry(uint32_t id, bool enabled) { RadioId rid = find(id); - addEntry(id, enabled, rid.radioAlias()); + addEntry(id, enabled, rid.radioAlias(), rid.radioIPAddress(), rid.canRequestKeys(), rid.canRekey(), rid.allowedKIds()); } /* Adds a new entry to the lookup table by the specified unique ID. */ -void RadioIdLookup::addEntry(uint32_t id, bool enabled, const std::string& alias, const std::string& ipAddress) +void RadioIdLookup::addEntry(uint32_t id, bool enabled, const std::string& alias, const std::string& ipAddress, + bool canRequestKeys, bool canRekey, const std::vector& allowedKIds) { if ((id == p25::defines::WUID_ALL) || (id == p25::defines::WUID_FNE)) { return; } - RadioId entry = RadioId(enabled, false, alias, ipAddress); + RadioId entry = RadioId(enabled, false, alias, ipAddress, canRequestKeys, canRekey, allowedKIds); __LOCK_TABLE(); try { RadioId _entry = m_table.at(id); - // if either the alias or the enabled flag doesn't match, update the entry - if (_entry.radioEnabled() != enabled || _entry.radioAlias() != alias) { + // if any configured parameter doesn't match, update the entry + if (_entry.radioEnabled() != enabled || _entry.radioAlias() != alias || + _entry.radioIPAddress() != ipAddress || _entry.canRequestKeys() != canRequestKeys || + _entry.canRekey() != canRekey || _entry.allowedKIds() != allowedKIds) { //LogDebug(LOG_HOST, "Updating existing RID %d (%s) in ACL", id, alias.c_str()); - _entry = RadioId(enabled, false, alias, ipAddress); + _entry = RadioId(enabled, false, alias, ipAddress, canRequestKeys, canRekey, allowedKIds); m_table[id] = _entry; } else { //LogDebug(LOG_HOST, "No changes made to RID %d (%s) in ACL", id, alias.c_str()); @@ -212,6 +217,9 @@ bool RadioIdLookup::load() bool radioEnabled = ::atoi(parsed[1].c_str()) == 1; std::string alias = ""; std::string ipAddress = ""; + bool canRequestKeys = false; + bool canRekey = false; + std::vector allowedKIds; // check for an optional alias field if (parsed.size() >= 3) { @@ -223,10 +231,33 @@ bool RadioIdLookup::load() ipAddress = parsed[3]; } - m_table[id] = RadioId(radioEnabled, false, alias, ipAddress); + // check for optional key-request permission + if (parsed.size() >= 5) { + canRequestKeys = ::atoi(parsed[4].c_str()) == 1; + } + + // check for optional rekey permission + if (parsed.size() >= 6) { + canRekey = ::atoi(parsed[5].c_str()) == 1; + } + + // check for optional allowed key IDs list (pipe-delimited) + if (parsed.size() >= 7) { + allowedKIds = parseKIdList(parsed[6]); + } + + m_table[id] = RadioId(radioEnabled, false, alias, ipAddress, canRequestKeys, canRekey, allowedKIds); if (m_verbose) { - LogInfoEx(LOG_HOST, "Radio NAME: %s RID: %u ENABLED: %u IPADDR: %s", alias.c_str(), id, radioEnabled, ipAddress.c_str()); + std::string kIdList; + if (allowedKIds.empty()) { + kIdList = "ALL"; + } else { + kIdList = serializeKIdList(allowedKIds); + } + + LogInfoEx(LOG_HOST, "Radio NAME: %s RID: %u ENABLED: %u IPADDR: %s CANREQKEYS: %u CANREKEY: %u ALLOWEDKIDS: %s", + alias.c_str(), id, radioEnabled, ipAddress.c_str(), canRequestKeys, canRekey, kIdList.c_str()); } } } @@ -278,21 +309,22 @@ bool RadioIdLookup::save(bool quiet) bool enabled = entry.second.radioEnabled(); std::string alias = entry.second.radioAlias(); std::string ipAddress = entry.second.radioIPAddress(); + bool canRequestKeys = entry.second.canRequestKeys(); + bool canRekey = entry.second.canRekey(); + std::string allowedKIds = serializeKIdList(entry.second.allowedKIds()); // format into a string line = std::to_string(rid) + "," + std::to_string(enabled) + ","; - - // add the alias if we have one - if (alias.length() > 0) { - line += alias; - line += ","; - } - - // add the IP address if we have one - if (ipAddress.length() > 0) { - line += ipAddress; - line += ","; - } + line += alias; + line += ","; + line += ipAddress; + line += ","; + line += std::to_string(canRequestKeys); + line += ","; + line += std::to_string(canRekey); + line += ","; + line += allowedKIds; + line += ","; line += "\n"; file << line; @@ -309,3 +341,59 @@ bool RadioIdLookup::save(bool quiet) return true; } + +/* Parses a string of KIDs from the lookup file into a vector of uint16_t KIDs. */ + +std::vector RadioIdLookup::parseKIdList(const std::string& input) +{ + std::vector kids; + + if (input.empty()) { + return kids; + } + + // tokenize the input string by pipe delimiter and parse each token as a hex value for a KID, ensuring no + // duplicates and that each KID is in the valid range of 0x0000 to 0xFFFF + std::stringstream ss(input); + std::string token; + while (std::getline(ss, token, '|')) { + if (token.empty()) { + continue; + } + + // parse token as hex value for KID + uint32_t value = (uint32_t)::strtoul(token.c_str(), nullptr, 16); + if (value > 0xFFFFU) { + continue; + } + + uint16_t kid = (uint16_t)value; + + // check for duplicates before adding to the list + if (std::find(kids.begin(), kids.end(), kid) == kids.end()) { + kids.push_back(kid); + } + } + + return kids; +} + +/* Serializes a list of KIDs into a string for storage in the lookup file. */ + +std::string RadioIdLookup::serializeKIdList(const std::vector& kids) +{ + if (kids.empty()) { + return ""; + } + + // serialize the list of KIDs into a pipe-delimited string + std::stringstream ss; + for (size_t i = 0U; i < kids.size(); i++) { + ss << std::uppercase << std::hex << std::setw(4) << std::setfill('0') << kids[i]; + if (i + 1U < kids.size()) { + ss << "|"; + } + } + + return ss.str(); +} diff --git a/src/common/lookups/RadioIdLookup.h b/src/common/lookups/RadioIdLookup.h index b917772f..b9636cd7 100644 --- a/src/common/lookups/RadioIdLookup.h +++ b/src/common/lookups/RadioIdLookup.h @@ -5,7 +5,7 @@ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * Copyright (C) 2016 Jonathan Naylor, G4KLX - * Copyright (C) 2017-2022,2024,2025 Bryan Biedenkapp, N2PLL + * Copyright (C) 2017-2022,2024,2025-2026 Bryan Biedenkapp, N2PLL * Copyright (c) 2024 Patrick McDonnell, W3AXL * */ @@ -27,6 +27,7 @@ #include #include +#include namespace lookups { @@ -46,7 +47,11 @@ namespace lookups RadioId() : m_radioEnabled(false), m_radioDefault(false), - m_radioAlias("") + m_radioAlias(""), + m_radioIPAddress(""), + m_canRequestKeys(false), + m_canRekey(false), + m_allowedKIds() { /* stub */ } @@ -60,7 +65,10 @@ namespace lookups m_radioEnabled(radioEnabled), m_radioDefault(radioDefault), m_radioAlias(""), - m_radioIPAddress("") + m_radioIPAddress(""), + m_canRequestKeys(false), + m_canRekey(false), + m_allowedKIds() { /* stub */ } @@ -72,11 +80,15 @@ namespace lookups * @param radioAlias Textual alias for the radio. * @param ipAddress Textual IP Address for the radio. */ - RadioId(bool radioEnabled, bool radioDefault, const std::string& radioAlias, const std::string& ipAddress = "") : + RadioId(bool radioEnabled, bool radioDefault, const std::string& radioAlias, const std::string& ipAddress = "", + bool canRequestKeys = false, bool canRekey = false, const std::vector& allowedKIds = std::vector()) : m_radioEnabled(radioEnabled), m_radioDefault(radioDefault), m_radioAlias(radioAlias), - m_radioIPAddress(ipAddress) + m_radioIPAddress(ipAddress), + m_canRequestKeys(canRequestKeys), + m_canRekey(canRekey), + m_allowedKIds(allowedKIds) { /* stub */ } @@ -92,6 +104,9 @@ namespace lookups m_radioDefault = data.m_radioDefault; m_radioAlias = data.m_radioAlias; m_radioIPAddress = data.m_radioIPAddress; + m_canRequestKeys = data.m_canRequestKeys; + m_canRekey = data.m_canRekey; + m_allowedKIds = data.m_allowedKIds; } return *this; @@ -104,12 +119,16 @@ namespace lookups * @param radioAlias Textual alias for the radio. * @param ipAddress Textual IP Address for the radio. */ - void set(bool radioEnabled, bool radioDefault, const std::string& radioAlias, const std::string& ipAddress = "") + void set(bool radioEnabled, bool radioDefault, const std::string& radioAlias, const std::string& ipAddress = "", + bool canRequestKeys = false, bool canRekey = false, const std::vector& allowedKIds = std::vector()) { m_radioEnabled = radioEnabled; m_radioDefault = radioDefault; m_radioAlias = radioAlias; m_radioIPAddress = ipAddress; + m_canRequestKeys = canRequestKeys; + m_canRekey = canRekey; + m_allowedKIds = allowedKIds; } public: @@ -129,6 +148,18 @@ namespace lookups * @brief IP Address for the radio. */ DECLARE_RO_PROPERTY_PLAIN(std::string, radioIPAddress); + /** + * @brief Flag indicating if the radio can request keys from FNE. + */ + DECLARE_RO_PROPERTY_PLAIN(bool, canRequestKeys); + /** + * @brief Flag indicating if the radio can receive OTAR rekey command payloads. + */ + DECLARE_RO_PROPERTY_PLAIN(bool, canRekey); + /** + * @brief Allowed encryption key IDs for this radio. + */ + DECLARE_RO_PROPERTY_PLAIN(std::vector, allowedKIds); }; // --------------------------------------------------------------------------- @@ -170,7 +201,8 @@ namespace lookups * @param alias Alias for the radio ID * @param ipAddress IP Address for Radio */ - void addEntry(uint32_t id, bool enabled, const std::string& alias, const std::string& ipAddress = ""); + void addEntry(uint32_t id, bool enabled, const std::string& alias, const std::string& ipAddress = "", + bool canRequestKeys = false, bool canRekey = false, const std::vector& allowedKIds = std::vector()); /** * @brief Erases an existing entry from the lookup table by the specified unique ID. * @param id Unique ID to erase. @@ -226,6 +258,19 @@ namespace lookups private: static std::mutex s_mutex; //!< Mutex used for change locking. static bool s_locked; //!< Flag used for read locking (prevents find lookups), should be used when atomic operations (add/erase/etc) are being used. + + /** + * @brief Parses a string of KIDs from the lookup file into a vector of uint16_t KIDs. + * @param input String representation of KID list from the lookup file. + * @returns Vector of KIDs parsed from the input string. + */ + std::vector parseKIdList(const std::string& input); + /** + * @brief Serializes a list of KIDs into a string for storage in the lookup file. + * @param kids List of KIDs to serialize. + * @returns String representation of the KID list. + */ + std::string serializeKIdList(const std::vector& kids); }; } // namespace lookups diff --git a/src/fne/network/P25OTARService.cpp b/src/fne/network/P25OTARService.cpp index 71c91ed3..b0a34764 100644 --- a/src/fne/network/P25OTARService.cpp +++ b/src/fne/network/P25OTARService.cpp @@ -4,7 +4,7 @@ * GPLv2 Open Source. Use is subject to license terms. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * - * Copyright (C) 2025 Bryan Biedenkapp, N2PLL + * Copyright (C) 2025-2026 Bryan Biedenkapp, N2PLL * */ #include "fne/Defines.h" @@ -28,6 +28,7 @@ using namespace p25::kmm; #include #include +#include // --------------------------------------------------------------------------- // Macros @@ -456,6 +457,22 @@ UInt8Array P25OTARService::processKMM(const uint8_t* data, uint32_t len, uint32_ else { if (kmm->getFlag() == KMM_HelloFlag::REKEY_REQUEST_UKEK || (kmm->getFlag() == KMM_HelloFlag::REKEY_REQUEST_NO_UKEK && m_allowNoUKEKRekey)) { + lookups::RadioId ridEntry = m_network->m_ridLookup->find(kmm->getSrcLLId()); + if (ridEntry.radioDefault()) { + LogInfoEx(LOG_P25, P25_KMM_STR ", %s, rekey denied; RID %u has no key policy entry", kmm->toString().c_str(), kmm->getSrcLLId()); + return write_KMM_NoService(llId, kmm->getSrcLLId(), payloadSize); + } + + if (!ridEntry.radioEnabled()) { + LogInfoEx(LOG_P25, P25_KMM_STR ", %s, rekey denied; RID %u disabled", kmm->toString().c_str(), kmm->getSrcLLId()); + return write_KMM_NoService(llId, kmm->getSrcLLId(), payloadSize); + } + + if (!ridEntry.canRekey()) { + LogInfoEx(LOG_P25, P25_KMM_STR ", %s, rekey denied; RID %u not rekeyable", kmm->toString().c_str(), kmm->getSrcLLId()); + return write_KMM_NoService(llId, kmm->getSrcLLId(), payloadSize); + } + // send rekey-command EKCKeyItem keyItem = m_network->m_cryptoLookup->findUKEK(llId); if (keyItem.isInvalid()) { @@ -615,6 +632,15 @@ UInt8Array P25OTARService::write_KMM_Rekey_Command(uint32_t llId, uint32_t kmmRS outKmm.setAlgId(kekAlgId); outKmm.setKId(kekKId); + lookups::RadioId ridEntry = m_network->m_ridLookup->find(kmmRSI); + std::vector allowedKIds; + if (ridEntry.radioDefault()) { + LogWarning(LOG_P25, P25_KMM_STR ", %s, aborting rekey, RID %u has no key policy entry", outKmm.toString().c_str(), kmmRSI); + return nullptr; + } + + allowedKIds = ridEntry.allowedKIds(); + KeysetItem ks; ks.keysetId(1U); ks.algId(ALGO_AES_256); // we currently can only OTAR AES256 keys @@ -630,6 +656,15 @@ UInt8Array P25OTARService::write_KMM_Rekey_Command(uint32_t llId, uint32_t kmmRS continue; } + // check if this keyItem is allowed for the RID + if (!allowedKIds.empty() && std::find(allowedKIds.begin(), allowedKIds.end(), (uint16_t)keyItem.kId()) == allowedKIds.end()) { + if (m_verbose) { + LogInfoEx(LOG_P25, P25_KMM_STR ", %s, skipping kId = %u; not allowed for RID %u", outKmm.toString().c_str(), + keyItem.kId(), kmmRSI); + } + continue; + } + uint8_t key[P25DEF::MAX_WRAPPED_ENC_KEY_LENGTH_BYTES]; ::memset(key, 0x00U, P25DEF::MAX_WRAPPED_ENC_KEY_LENGTH_BYTES); uint8_t keyLength = keyItem.getKey(key); diff --git a/src/fne/network/TrafficNetwork.cpp b/src/fne/network/TrafficNetwork.cpp index ea7850a4..b6be0e07 100644 --- a/src/fne/network/TrafficNetwork.cpp +++ b/src/fne/network/TrafficNetwork.cpp @@ -37,6 +37,7 @@ using namespace compress; #include // --------------------------------------------------------------------------- +#include // Constants // --------------------------------------------------------------------------- @@ -1807,6 +1808,35 @@ void TrafficNetwork::taskNetworkRx(NetPacketRequest* req) { KMMModifyKey* modifyKey = static_cast(frame.get()); if (modifyKey->getAlgId() > 0U && modifyKey->getKId() > 0U) { + uint32_t requestingRid = modifyKey->getSrcLLId(); + lookups::RadioId ridEntry = network->m_ridLookup->find(requestingRid); + if (ridEntry.radioDefault()) { + LogError(LOG_MASTER, "PEER %u (%s) requested enc. key but RID %u has no key policy entry, no response", + peerId, connection->identWithQualifier().c_str(), requestingRid); + break; + } + + if (!ridEntry.radioEnabled()) { + LogError(LOG_MASTER, "PEER %u (%s) requested enc. key but RID %u is disabled, no response", + peerId, connection->identWithQualifier().c_str(), requestingRid); + break; + } + + if (!ridEntry.canRequestKeys()) { + LogError(LOG_MASTER, "PEER %u (%s) requested enc. key but RID %u cannot request keys, no response", + peerId, connection->identWithQualifier().c_str(), requestingRid); + break; + } + + std::vector allowedKIds = ridEntry.allowedKIds(); + + // check if this RID is allowed to request the KID in question + if (!allowedKIds.empty() && std::find(allowedKIds.begin(), allowedKIds.end(), modifyKey->getKId()) == allowedKIds.end()) { + LogError(LOG_MASTER, "PEER %u (%s) requested enc. key kID = $%04X but RID %u is not permitted for that key, no response", + peerId, connection->identWithQualifier().c_str(), modifyKey->getKId(), requestingRid); + break; + } + LogInfoEx(LOG_MASTER, "PEER %u (%s) requested enc. key, algId = $%02X, kID = $%04X", peerId, connection->identWithQualifier().c_str(), modifyKey->getAlgId(), modifyKey->getKId()); ::EKCKeyItem keyItem = network->m_cryptoLookup->find(modifyKey->getKId()); diff --git a/src/fne/restapi/RESTAPI.cpp b/src/fne/restapi/RESTAPI.cpp index a3aed956..b1419b75 100644 --- a/src/fne/restapi/RESTAPI.cpp +++ b/src/fne/restapi/RESTAPI.cpp @@ -1025,6 +1025,16 @@ void RESTAPI::restAPI_GetRIDQuery(const HTTPPayload& request, HTTPPayload& reply ridObj["enabled"].set(enabled); std::string alias = entry.second.radioAlias(); ridObj["alias"].set(alias); + bool canRequestKeys = entry.second.canRequestKeys(); + ridObj["canRequestKeys"].set(canRequestKeys); + bool canRekey = entry.second.canRekey(); + ridObj["canRekey"].set(canRekey); + json::array allowedKIds = json::array(); + std::vector kIds = entry.second.allowedKIds(); + for (uint16_t kId : kIds) { + allowedKIds.push_back(json::value((double)kId)); + } + ridObj["allowedKIds"].set(allowedKIds); rids.push_back(json::value(ridObj)); } @@ -1070,10 +1080,54 @@ void RESTAPI::restAPI_PutRIDAdd(const HTTPPayload& request, HTTPPayload& reply, alias = req["alias"].get(); } + bool canRequestKeys = false; + if (req.find("canRequestKeys") != req.end()) { + if (!req["canRequestKeys"].is()) { + errorPayload(reply, "canRequestKeys was not a valid boolean"); + return; + } + + canRequestKeys = req["canRequestKeys"].get(); + } + + bool canRekey = false; + if (req.find("canRekey") != req.end()) { + if (!req["canRekey"].is()) { + errorPayload(reply, "canRekey was not a valid boolean"); + return; + } + + canRekey = req["canRekey"].get(); + } + + std::vector allowedKIds; + if (req.find("allowedKIds") != req.end()) { + if (!req["allowedKIds"].is()) { + errorPayload(reply, "allowedKIds was not a valid JSON array"); + return; + } + + json::array kIdArray = req["allowedKIds"].get(); + for (auto entry : kIdArray) { + if (!entry.is()) { + errorPayload(reply, "allowedKIds entry was not a valid number"); + return; + } + + uint32_t value = entry.get(); + if (value > 0xFFFFU) { + errorPayload(reply, "allowedKIds entry exceeded 16-bit key id range"); + return; + } + + allowedKIds.push_back((uint16_t)value); + } + } + LogInfoEx(LOG_REST, "request to add RID ACL, rid = %u", rid); // The addEntry function will automatically update an existing entry, so no need to check for an exisitng one here - m_ridLookup->addEntry(rid, enabled, alias); + m_ridLookup->addEntry(rid, enabled, alias, "", canRequestKeys, canRekey, allowedKIds); /* if (m_network != nullptr) { m_network->m_forceListUpdate = true;