297 lines
13 KiB
Text
297 lines
13 KiB
Text
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
// lint-disable-file fixed-width-types
|
||
|
|
/*
|
||
|
|
Imsd:Aka — USIM AKA (3GPP TS 33.102) glue for IMS registration:
|
||
|
|
|
||
|
|
* the AKAv1-MD5 HTTP-digest response (RFC 3310) the protected REGISTER
|
||
|
|
carries in its Authorization header — MD5 over the binary RES;
|
||
|
|
* the home-network identity (IMPI/IMPU/registration URI/realm) derived
|
||
|
|
from IMSI + MCC/MNC;
|
||
|
|
* text parsers for the SIM facts the daemon shell scrapes from qmicli
|
||
|
|
(`--uim-get-card-status`: which slot holds a ready USIM and its AID) and
|
||
|
|
ModemManager (`mmcli`: the primary SIM path, then IMSI + operator id);
|
||
|
|
* the ISO 7816 APDU for the AUTHENTICATE(AKA) command and the parser for
|
||
|
|
its 0xDB success response (RES / CK / IK TLV).
|
||
|
|
|
||
|
|
All pure std C++: the actual card I/O (open channel, transmit APDU) is the
|
||
|
|
shell's job — this module only shapes the bytes and reads the answers back.
|
||
|
|
Digest and parser behavior is pinned by tests/Aka.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export module Imsd:Aka;
|
||
|
|
import std;
|
||
|
|
import :Util;
|
||
|
|
|
||
|
|
export namespace imsd::aka {
|
||
|
|
|
||
|
|
// The home-network identity a USIM registers under.
|
||
|
|
struct Identity {
|
||
|
|
std::string imsi;
|
||
|
|
std::string mcc;
|
||
|
|
std::string mnc; // already zero-padded to 3 digits
|
||
|
|
std::string domain; // ims.mncXXX.mccYYY.3gppnetwork.org
|
||
|
|
std::string impi; // <imsi>@<domain>
|
||
|
|
std::string impu; // sip:<imsi>@<domain>
|
||
|
|
std::string regUri; // sip:<domain>
|
||
|
|
};
|
||
|
|
|
||
|
|
// Build the identity from IMSI + MCC + a (2- or 3-digit) MNC. The MNC is
|
||
|
|
// zero-padded to 3 digits, matching the Python detect_sim()/mnc.zfill(3)
|
||
|
|
// — KPN's operator id 20408 becomes mnc008 in the domain.
|
||
|
|
Identity MakeIdentity(std::string_view imsi, std::string_view mcc, std::string_view mnc) {
|
||
|
|
std::string mnc3(mnc);
|
||
|
|
while (mnc3.size() < 3)
|
||
|
|
mnc3.insert(mnc3.begin(), '0');
|
||
|
|
Identity id;
|
||
|
|
id.imsi = imsi;
|
||
|
|
id.mcc = mcc;
|
||
|
|
id.mnc = mnc3;
|
||
|
|
id.domain = std::format("ims.mnc{}.mcc{}.3gppnetwork.org", mnc3, mcc);
|
||
|
|
id.impi = std::format("{}@{}", imsi, id.domain);
|
||
|
|
id.impu = std::format("sip:{}@{}", imsi, id.domain);
|
||
|
|
id.regUri = std::format("sip:{}", id.domain);
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
// AKAv1-MD5 digest-response (RFC 3310 §3.2): HA1 = MD5(IMPI:realm:RES)
|
||
|
|
// where RES is the *binary* AKA result, HA2 = MD5(method:uri), and the
|
||
|
|
// response = MD5(HA1:nonce:nc:cnonce:qop:HA2). realm is the home domain.
|
||
|
|
std::string DigestAkav1(std::string_view nonce,
|
||
|
|
std::span<const std::uint8_t> res,
|
||
|
|
std::string_view cnonce, std::string_view uri,
|
||
|
|
std::string_view impi, std::string_view realm,
|
||
|
|
std::string_view nc = "00000001",
|
||
|
|
std::string_view qop = "auth",
|
||
|
|
std::string_view method = "REGISTER") {
|
||
|
|
imsd::util::Md5 h1;
|
||
|
|
h1.Update(impi);
|
||
|
|
h1.Update(":");
|
||
|
|
h1.Update(realm);
|
||
|
|
h1.Update(":");
|
||
|
|
h1.Update(res);
|
||
|
|
std::string ha1 = imsd::util::ToHex(h1.Digest());
|
||
|
|
std::string ha2 = imsd::util::Md5::HexDigest(std::format("{}:{}", method, uri));
|
||
|
|
return imsd::util::Md5::HexDigest(std::format("{}:{}:{}:{}:{}:{}", ha1, nonce, nc, cnonce, qop, ha2));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Which USIM the modem should authenticate against.
|
||
|
|
struct SimSelection {
|
||
|
|
int slot = 0;
|
||
|
|
std::string aid; // uppercase, colons stripped
|
||
|
|
};
|
||
|
|
|
||
|
|
// Parse qmicli `--uim-get-card-status`: the first ready USIM application,
|
||
|
|
// its slot number, and its Application ID (the hex line under
|
||
|
|
// "Application ID:"). nullopt if no ready USIM is present. Mirrors the
|
||
|
|
// Python detect_sim() scan exactly.
|
||
|
|
std::optional<SimSelection> ParseCardStatus(std::string_view text) {
|
||
|
|
int curSlot = 0;
|
||
|
|
bool inUsim = false;
|
||
|
|
bool ready = false;
|
||
|
|
std::optional<SimSelection> out;
|
||
|
|
std::size_t pos = 0;
|
||
|
|
auto isAidLine = [](std::string_view s) {
|
||
|
|
// trimmed line is HH(:HH)+ — two-or-more colon-separated hex pairs
|
||
|
|
std::size_t i = 0;
|
||
|
|
int pairs = 0;
|
||
|
|
auto hex = [](char c) {
|
||
|
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
|
||
|
|
(c >= 'A' && c <= 'F');
|
||
|
|
};
|
||
|
|
while (i < s.size()) {
|
||
|
|
if (i + 2 > s.size() || !hex(s[i]) || !hex(s[i + 1])) return false;
|
||
|
|
i += 2;
|
||
|
|
pairs++;
|
||
|
|
if (i == s.size()) break;
|
||
|
|
if (s[i] != ':') return false;
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
return pairs >= 2;
|
||
|
|
};
|
||
|
|
while (pos <= text.size()) {
|
||
|
|
std::size_t nl = text.find('\n', pos);
|
||
|
|
std::string_view line = text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
|
||
|
|
std::string_view t = line;
|
||
|
|
while (!t.empty() && (t.front() == ' ' || t.front() == '\t'))
|
||
|
|
t.remove_prefix(1);
|
||
|
|
while (!t.empty() && (t.back() == ' ' || t.back() == '\t' || t.back() == '\r'))
|
||
|
|
t.remove_suffix(1);
|
||
|
|
|
||
|
|
if (t.starts_with("Slot [")) {
|
||
|
|
std::size_t lb = t.find('[');
|
||
|
|
std::size_t rb = t.find(']', lb);
|
||
|
|
if (lb != std::string_view::npos && rb != std::string_view::npos) {
|
||
|
|
int v = 0;
|
||
|
|
std::from_chars(t.data() + lb + 1, t.data() + rb, v);
|
||
|
|
curSlot = v;
|
||
|
|
}
|
||
|
|
inUsim = ready = false;
|
||
|
|
} else if (t.contains("Application type") && t.contains("usim")) {
|
||
|
|
inUsim = true;
|
||
|
|
} else if (inUsim && t.contains("Application state") && t.contains("ready")) {
|
||
|
|
ready = true;
|
||
|
|
} else if (inUsim && isAidLine(t)) {
|
||
|
|
if (ready && !out) {
|
||
|
|
std::string aid;
|
||
|
|
for (char c : t)
|
||
|
|
if (c != ':') aid.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
|
||
|
|
out = SimSelection{curSlot, aid};
|
||
|
|
}
|
||
|
|
inUsim = false;
|
||
|
|
}
|
||
|
|
if (nl == std::string_view::npos) break;
|
||
|
|
pos = nl + 1;
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
// "primary sim path: <path>" from `mmcli -m any`.
|
||
|
|
std::optional<std::string> ParsePrimarySimPath(std::string_view text) {
|
||
|
|
std::size_t at = text.find("primary sim path:");
|
||
|
|
if (at == std::string_view::npos) return std::nullopt;
|
||
|
|
std::string_view rest = text.substr(at + std::string_view("primary sim path:").size());
|
||
|
|
std::size_t nl = rest.find('\n');
|
||
|
|
std::string_view line = rest.substr(0, nl == std::string_view::npos ? rest.size() : nl);
|
||
|
|
while (!line.empty() && (line.front() == ' ' || line.front() == '\t'))
|
||
|
|
line.remove_prefix(1);
|
||
|
|
while (!line.empty() && (line.back() == ' ' || line.back() == '\t' || line.back() == '\r'))
|
||
|
|
line.remove_suffix(1);
|
||
|
|
if (line.empty()) return std::nullopt;
|
||
|
|
return std::string(line);
|
||
|
|
}
|
||
|
|
|
||
|
|
struct SimInfo {
|
||
|
|
std::string imsi;
|
||
|
|
std::string mcc;
|
||
|
|
std::string mnc; // as printed (operator id minus mcc), not padded
|
||
|
|
};
|
||
|
|
|
||
|
|
// Grab a labelled numeric value ("imsi:" / "operator id:") from mmcli -i
|
||
|
|
// output. Values are digit runs.
|
||
|
|
namespace detail {
|
||
|
|
inline std::optional<std::string> Digits(std::string_view text, std::string_view label) {
|
||
|
|
std::size_t at = text.find(label);
|
||
|
|
if (at == std::string_view::npos) return std::nullopt;
|
||
|
|
std::size_t i = at + label.size();
|
||
|
|
while (i < text.size() && (text[i] == ' ' || text[i] == '\t'))
|
||
|
|
i++;
|
||
|
|
std::size_t start = i;
|
||
|
|
while (i < text.size() && text[i] >= '0' && text[i] <= '9')
|
||
|
|
i++;
|
||
|
|
if (i == start) return std::nullopt;
|
||
|
|
return std::string(text.substr(start, i - start));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// "equipment id: <imei>" (15 digits) from `mmcli -m any`.
|
||
|
|
std::optional<std::string> ParseEquipmentId(std::string_view text) {
|
||
|
|
return detail::Digits(text, "equipment id:");
|
||
|
|
}
|
||
|
|
|
||
|
|
// RFC 7254 IMEI URN from a 15-digit IMEI (TAC-SNR-check digit) — the
|
||
|
|
// +sip.instance value a UE registers with; nullopt for anything else.
|
||
|
|
std::optional<std::string> ImeiUrn(std::string_view imei) {
|
||
|
|
if (imei.size() != 15) return std::nullopt;
|
||
|
|
for (char c : imei)
|
||
|
|
if (c < '0' || c > '9') return std::nullopt;
|
||
|
|
return std::format("urn:gsma:imei:{}-{}-{}", imei.substr(0, 8), imei.substr(8, 6), imei.substr(14));
|
||
|
|
}
|
||
|
|
|
||
|
|
// IMSI + operator id (MCC = first 3 digits, MNC = the rest) from `mmcli -i`.
|
||
|
|
std::optional<SimInfo> ParseSimInfo(std::string_view text) {
|
||
|
|
auto imsi = detail::Digits(text, "imsi:");
|
||
|
|
auto op = detail::Digits(text, "operator id:");
|
||
|
|
if (!imsi || !op || op->size() < 4) return std::nullopt;
|
||
|
|
SimInfo s;
|
||
|
|
s.imsi = *imsi;
|
||
|
|
s.mcc = op->substr(0, 3);
|
||
|
|
s.mnc = op->substr(3);
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
// P-Access-Network-Info value from `mmcli -m any --location-get`:
|
||
|
|
// "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=<mcc><mnc><tac:4hex><eci:7hex>"
|
||
|
|
// (TS 24.229 7.2A.4: E-UTRAN cell id = MCC + MNC as broadcast + 16-bit
|
||
|
|
// TAC + 28-bit ECI; mmcli prints both hex fields zero-padded wider, so
|
||
|
|
// take the low digits). The stock modem sends exactly this shape in
|
||
|
|
// every REGISTER/INVITE — the TAS reads it to see which access the UE
|
||
|
|
// is reachable on. FDD is a constant: every KPN LTE band in use is FDD
|
||
|
|
// and MM does not expose the duplex mode. nullopt when the fields are
|
||
|
|
// missing or zero (no serving cell) — callers then omit the header
|
||
|
|
// rather than fabricate one.
|
||
|
|
std::optional<std::string> ParsePani(std::string_view text) {
|
||
|
|
auto mcc = detail::Digits(text, "operator mcc:");
|
||
|
|
auto mnc = detail::Digits(text, "operator mnc:");
|
||
|
|
auto hexAfter = [&](std::string_view label) -> std::optional<std::string> {
|
||
|
|
std::size_t at = text.find(label);
|
||
|
|
if (at == std::string_view::npos) return std::nullopt;
|
||
|
|
std::size_t i = at + label.size();
|
||
|
|
while (i < text.size() && (text[i] == ' ' || text[i] == '\t'))
|
||
|
|
i++;
|
||
|
|
std::size_t start = i;
|
||
|
|
while (i < text.size() && std::isxdigit(static_cast<unsigned char>(text[i])))
|
||
|
|
i++;
|
||
|
|
if (i == start) return std::nullopt;
|
||
|
|
return std::string(text.substr(start, i - start));
|
||
|
|
};
|
||
|
|
auto tac = hexAfter("tracking area code:");
|
||
|
|
auto ci = hexAfter("cell id:");
|
||
|
|
if (!mcc || !mnc || !tac || !ci) return std::nullopt;
|
||
|
|
if (tac->size() > 4) *tac = tac->substr(tac->size() - 4);
|
||
|
|
while (tac->size() < 4) tac->insert(tac->begin(), '0');
|
||
|
|
if (ci->size() > 7) *ci = ci->substr(ci->size() - 7);
|
||
|
|
while (ci->size() < 7) ci->insert(ci->begin(), '0');
|
||
|
|
if (tac->find_first_not_of('0') == std::string::npos && ci->find_first_not_of('0') == std::string::npos) return std::nullopt;
|
||
|
|
return std::format("3GPP-E-UTRAN-FDD;utran-cell-id-3gpp={}{}{}{}", *mcc, *mnc, *tac, *ci);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- AUTHENTICATE(AKA) APDU (ISO 7816-4 over the qmicli UIM channel) ----
|
||
|
|
|
||
|
|
// The command APDU (hex, no separators) for a 3G AKA authentication:
|
||
|
|
// CLA=<channel byte> INS=88 P1=00 P2=81 Lc [10 RAND(16) 10 AUTN(16)]
|
||
|
|
// `channel` is the logical-channel number qmicli returned; the CLA low
|
||
|
|
// nibble carries it (Python: cla = "%02x" % ch).
|
||
|
|
std::string BuildAkaApdu(int channel, std::span<const std::uint8_t> rand16, std::span<const std::uint8_t> autn16) {
|
||
|
|
std::string data = std::format("10{}10{}", imsd::util::ToHex(rand16), imsd::util::ToHex(autn16));
|
||
|
|
std::size_t lc = data.size() / 2;
|
||
|
|
// CLA INS=88 P1=00 P2=81 Lc <data>
|
||
|
|
return std::format("{:02x}880081{:02x}{}", channel, lc, data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// GET RESPONSE APDU for when the card answers 61xx (xx bytes available).
|
||
|
|
std::string BuildGetResponseApdu(int channel, int le) {
|
||
|
|
return std::format("{:02x}c00000{:02x}", channel, le & 0xFF);
|
||
|
|
}
|
||
|
|
|
||
|
|
struct AkaResult {
|
||
|
|
std::vector<std::uint8_t> res;
|
||
|
|
std::vector<std::uint8_t> ck;
|
||
|
|
std::vector<std::uint8_t> ik;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Parse the card's AUTHENTICATE response body. `resp` is the raw APDU
|
||
|
|
// response INCLUDING the trailing SW1 SW2 (two status bytes), which are
|
||
|
|
// stripped here. A successful 3G AKA body is:
|
||
|
|
// DB L_RES RES.. L_CK CK.. L_IK IK..
|
||
|
|
// (tag 0xDB = "AKA success"; 0xDC would be sync-failure). nullopt on a
|
||
|
|
// short buffer, a non-DB tag, or a length that runs past the end.
|
||
|
|
std::optional<AkaResult> ParseAkaResponse(std::span<const std::uint8_t> resp) {
|
||
|
|
if (resp.size() < 3) return std::nullopt;
|
||
|
|
std::span<const std::uint8_t> body = resp.subspan(0, resp.size() - 2);
|
||
|
|
if (body.empty() || body[0] != 0xDB) return std::nullopt;
|
||
|
|
std::size_t i = 1;
|
||
|
|
auto take = [&](std::vector<std::uint8_t>& out) -> bool {
|
||
|
|
if (i >= body.size()) return false;
|
||
|
|
std::size_t n = body[i++];
|
||
|
|
if (i + n > body.size()) return false;
|
||
|
|
out.assign(body.begin() + i, body.begin() + i + n);
|
||
|
|
i += n;
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
AkaResult r;
|
||
|
|
if (!take(r.res) || !take(r.ck) || !take(r.ik)) return std::nullopt;
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
}
|