// 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; // @ std::string impu; // sip:@ std::string regUri; // sip: }; // 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 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 ParseCardStatus(std::string_view text) { int curSlot = 0; bool inUsim = false; bool ready = false; std::optional 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(std::toupper(static_cast(c)))); out = SimSelection{curSlot, aid}; } inUsim = false; } if (nl == std::string_view::npos) break; pos = nl + 1; } return out; } // "primary sim path: " from `mmcli -m any`. std::optional 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 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: " (15 digits) from `mmcli -m any`. std::optional 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 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 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=" // (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 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::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(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= 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 rand16, std::span 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 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 res; std::vector ck; std::vector 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 ParseAkaResponse(std::span resp) { if (resp.size() < 3) return std::nullopt; std::span 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& 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; } }