imsd/interfaces/Imsd-Util.cppm

376 lines
17 KiB
Text
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Util — small self-contained primitives the rest of the engine builds on:
random SIP tokens, IPv6 detection + host/port formatting, the RFC 5952
canonical IPv6 text and the address-list parser, hex/base64 decoding, and MD5
(RFC 1321). None of the standard library ships these last three, and
the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to
unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto
dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util.
*/
export module Imsd:Util;
import std;
export namespace imsd::util {
// The UE's protected client/server ports (TS 33.203 sec-agree). Single
// source of truth: Imsd:Messages puts them in Security-Client and every
// protected Via/Contact, Imsd:Ipsec selects on them in the SA policies,
// and the daemon binds them. They are also mirrored in the packaged
// firewall rules (packaging/ims-pdn-up.sh) — changing them here breaks
// an installed package's nftables accept rules.
constexpr int PortUc = 45061;
constexpr int PortUs = 45062;
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// The client port a reregistration proposes for the SA pairs a
// re-authentication would set up: TS 33.203 7.4.0 keeps the server
// ports (port_us, port_ps) and changes the client ports (port_uc,
// port_pc). imsd answers such a challenge with a fresh registration, so
// nothing is bound here and the firewall rules need not open it.
constexpr int PortUcNext = 45063;
// An address is IPv6 iff it contains a colon (matches the Python is6()).
inline bool Is6(std::string_view addr) { return addr.contains(':'); }
// "[addr]:port" for IPv6, "addr:port" for IPv4 — the SIP host:port form.
inline std::string HostPort(std::string_view addr, int port) {
return Is6(addr) ? std::format("[{}]:{}", addr, port)
: std::format("{}:{}", addr, port);
}
// The RFC 5952 §4 text of an IPv6 address: lowercase hex, no leading
// zeros, the longest run of two or more zero fields as "::" (the first
// on a tie), never a single zero field. This is the form iproute2 and
// inet_ntop print, so addresses canonicalised here compare as text
// against `ip xfrm` output whatever spelling they arrived in (an
// upper-case or uncompressed PCSCF= in /etc/imsd.env never matched the
// kernel's text before). Surrounding brackets are accepted. nullopt for
// anything that is not a plain IPv6 literal: a zone ("%dev"), a "]:port",
// a dotted-quad tail ("::ffff:1.2.3.4" — RFC 5952 §5 keeps that dotted and
// an IPv6-only PDN never produces it; the same address spelled in hex,
// "::ffff:a00:1", is accepted and comes back in hex — §4 form, where
// inet_ntop prints it dotted), two "::", more than eight fields,
// a field over four digits, an IPv4 address, a name.
inline std::optional<std::string> CanonicalIp6(std::string_view s) {
if (s.size() >= 2 && s.front() == '[' && s.back() == ']') s = s.substr(1, s.size() - 2);
if (s.empty()) return std::nullopt;
auto nibble = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
for (char c : s) if (c != ':' && nibble(c) < 0) return std::nullopt;
// "a:b" fields of one part (no "::" inside); false on an empty or
// over-long field
auto fields = [&](std::string_view part, std::vector<std::uint16_t>& out) -> bool {
if (part.empty()) return true;
std::size_t start = 0;
while (true) {
std::size_t colon = part.find(':', start);
std::string_view f = part.substr(start, colon == std::string_view::npos ? std::string_view::npos : colon - start);
if (f.empty() || f.size() > 4) return false;
unsigned v = 0;
for (char c : f) v = v * 16 + static_cast<unsigned>(nibble(c));
out.push_back(static_cast<std::uint16_t>(v));
if (colon == std::string_view::npos) return true;
start = colon + 1;
}
};
std::vector<std::uint16_t> head;
std::vector<std::uint16_t> tail;
std::size_t dc = s.find("::");
if (dc == std::string_view::npos) {
if (!fields(s, head) || head.size() != 8) return std::nullopt;
} else {
if (s.find("::", dc + 1) != std::string_view::npos) return std::nullopt;
if (!fields(s.substr(0, dc), head) || !fields(s.substr(dc + 2), tail)) return std::nullopt;
if (head.size() + tail.size() > 7) return std::nullopt;
}
std::array<std::uint16_t, 8> g{};
std::copy(head.begin(), head.end(), g.begin());
std::copy(tail.begin(), tail.end(), g.end() - static_cast<std::ptrdiff_t>(tail.size()));
int bestStart = -1;
int bestLen = 1; // a run must beat one field to be compressed
for (int i = 0; i < 8;) {
if (g[static_cast<std::size_t>(i)] != 0) { i++; continue; }
int j = i;
while (j < 8 && g[static_cast<std::size_t>(j)] == 0) j++;
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
i = j;
}
std::string out;
for (int i = 0; i < 8;) {
if (i == bestStart) {
out += "::";
i += bestLen;
continue;
}
if (!out.empty() && out.back() != ':') out += ':';
out += std::format("{:x}", g[static_cast<std::size_t>(i)]);
i++;
}
return out;
}
// The canonical text of any address imsd compares or logs: an IPv6
// literal in RFC 5952 form, anything else (an IPv4 address, a malformed
// IPv6 that the caller's own checks will refuse) unchanged.
inline std::string CanonicalAddr(std::string_view s) {
if (!Is6(s)) return std::string(s);
return CanonicalIp6(s).value_or(std::string(s));
}
// A comma-separated address list (PCSCF_DISCOVERED=) → the canonical IPv6
// literals in it, in order, duplicates dropped. Entries that are not an
// IPv6 literal (an IPv4 address, a name, garbage) go to `rejected` when
// the caller asks for them; blanks are skipped.
inline std::vector<std::string> ParseAddressList(std::string_view list, std::vector<std::string>* rejected = nullptr) {
std::vector<std::string> out;
std::size_t start = 0;
while (start <= list.size()) {
std::size_t comma = list.find(',', start);
std::string_view item = list.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start);
while (!item.empty() && (item.front() == ' ' || item.front() == '\t')) item.remove_prefix(1);
while (!item.empty() && (item.back() == ' ' || item.back() == '\t')) item.remove_suffix(1);
if (!item.empty()) {
if (auto c = CanonicalIp6(item)) {
if (std::find(out.begin(), out.end(), *c) == out.end()) out.push_back(*c);
} else if (rejected) {
rejected->emplace_back(item);
}
}
if (comma == std::string_view::npos) break;
start = comma + 1;
}
return out;
}
// A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID
// material (the Python rand()). The generator is a parameter so tests can
// pin output; the default constructor seeds from random_device.
class Rng {
public:
Rng() {
std::random_device rd;
gen_.seed((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
}
explicit Rng(std::uint64_t seed) { gen_.seed(seed); }
std::string Token(std::size_t n) {
static constexpr std::string_view Alphabet =
"abcdefghijklmnopqrstuvwxyz0123456789";
std::uniform_int_distribution<std::size_t> dist(0, Alphabet.size() - 1);
std::string s;
s.reserve(n);
for (std::size_t i = 0; i < n; i++)
s.push_back(Alphabet[dist(gen_)]);
return s;
}
// A random integer in [lo, hi] — used for SPIs, RTP SSRC/seq/ts.
std::uint32_t UInt(std::uint32_t lo, std::uint32_t hi) {
std::uniform_int_distribution<std::uint32_t> dist(lo, hi);
return dist(gen_);
}
private:
std::mt19937_64 gen_;
};
// RFC 4122 version-4 UUID from the caller's Rng — the shape the stock
// modem mints for its registered Contact user-part (oracle capture
// 2026-07-21, journal/ims.md s53).
inline std::string Uuid4(Rng& rng) {
std::uint32_t a = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t b = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t c = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t d = rng.UInt(0, 0xFFFFFFFFu);
b = (b & 0xFFFF0FFFu) | 0x00004000u; // version nibble = 4
c = (c & 0x3FFFFFFFu) | 0x80000000u; // variant bits = 10
return std::format("{:08x}-{:04x}-{:04x}-{:04x}-{:04x}{:08x}", a, b >> 16, b & 0xFFFFu, c >> 16, c & 0xFFFFu, d);
}
// Lowercase hex of a byte span (no separators).
inline std::string ToHex(std::span<const std::uint8_t> bytes) {
std::string s;
s.reserve(bytes.size() * 2);
for (std::uint8_t b : bytes)
s += std::format("{:02x}", b);
return s;
}
// Decode a hex string (any case, even length) to bytes; nullopt on a bad
// char or odd length. Colons are skipped so qmicli's "AB:CD:.." APDU form
// decodes directly.
inline std::optional<std::vector<std::uint8_t>> FromHex(std::string_view hex) {
auto nibble = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
std::vector<std::uint8_t> out;
int hi = -1;
for (char c : hex) {
if (c == ':' || c == ' ') continue;
int v = nibble(c);
if (v < 0) return std::nullopt;
if (hi < 0) {
hi = v;
} else {
out.push_back(static_cast<std::uint8_t>((hi << 4) | v));
hi = -1;
}
}
if (hi >= 0)
return std::nullopt; // odd number of nibbles
return out;
}
// Standard base64 decode (RFC 4648, '+' '/', '=' padding). Whitespace is
// ignored; nullopt on an invalid character. The IMS 401 nonce is the
// base64 of RAND(16)||AUTN(16)||... — this unwraps it.
inline std::optional<std::vector<std::uint8_t>> FromBase64(std::string_view in) {
auto val = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
};
std::vector<std::uint8_t> out;
int acc = 0;
int bits = 0;
for (char c : in) {
if (c == '=') break;
if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
int v = val(c);
if (v < 0) return std::nullopt;
acc = (acc << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((acc >> bits) & 0xFF));
}
}
return out;
}
// ---- MD5 (RFC 1321), streaming over arbitrary bytes. ------------------
class Md5 {
public:
Md5() { Reset(); }
void Update(std::span<const std::uint8_t> data) {
std::size_t i = 0;
std::size_t bufFill = static_cast<std::size_t>((count_ >> 3) & 0x3F);
count_ += static_cast<std::uint64_t>(data.size()) << 3;
if (bufFill) {
std::size_t need = 64 - bufFill;
if (data.size() < need) {
std::copy_n(data.begin(), data.size(), buf_.begin() + bufFill);
return;
}
std::copy_n(data.begin(), need, buf_.begin() + bufFill);
Transform(buf_.data());
i = need;
}
for (; i + 64 <= data.size(); i += 64)
Transform(data.data() + i);
std::copy(data.begin() + i, data.end(), buf_.begin());
}
void Update(std::string_view s) {
Update(std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(s.data()), s.size()));
}
std::array<std::uint8_t, 16> Digest() {
std::array<std::uint8_t, 8> lenBytes{};
for (int i = 0; i < 8; i++)
lenBytes[i] = static_cast<std::uint8_t>((count_ >> (8 * i)) & 0xFF);
static constexpr std::uint8_t pad[64] = {0x80};
std::size_t bufFill = static_cast<std::size_t>((count_ >> 3) & 0x3F);
std::size_t padLen = (bufFill < 56) ? (56 - bufFill) : (120 - bufFill);
Update(std::span<const std::uint8_t>(pad, padLen));
Update(std::span<const std::uint8_t>(lenBytes.data(), 8));
std::array<std::uint8_t, 16> out{};
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
out[i * 4 + j] = static_cast<std::uint8_t>((state_[i] >> (8 * j)) & 0xFF);
return out;
}
// Convenience: hex digest of a single buffer.
static std::string HexDigest(std::span<const std::uint8_t> data) {
Md5 m;
m.Update(data);
auto d = m.Digest();
return ToHex(d);
}
static std::string HexDigest(std::string_view s) {
return HexDigest(std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(s.data()), s.size()));
}
private:
std::array<std::uint32_t, 4> state_;
std::uint64_t count_; // bit count
std::array<std::uint8_t, 64> buf_;
void Reset() {
state_ = {0x67452301u, 0xefcdab89u, 0x98badcfeu, 0x10325476u};
count_ = 0;
buf_.fill(0);
}
static std::uint32_t Rotl(std::uint32_t x, int c) {
return (x << c) | (x >> (32 - c));
}
void Transform(const std::uint8_t* block) {
static constexpr std::uint32_t K[64] = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,
0xa8304613,0xfd469501,0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,
0x6b901122,0xfd987193,0xa679438e,0x49b40821,0xf61e2562,0xc040b340,
0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,
0x676f02d9,0x8d2a4c8a,0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,
0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,0x289b7ec6,0xeaa127fa,
0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,
0xffeff47d,0x85845dd1,0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,
0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391};
static constexpr int S[64] = {
7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,
5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,
6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21};
std::uint32_t M[16];
for (int i = 0; i < 16; i++)
M[i] = static_cast<std::uint32_t>(block[i * 4]) |
(static_cast<std::uint32_t>(block[i * 4 + 1]) << 8) |
(static_cast<std::uint32_t>(block[i * 4 + 2]) << 16) |
(static_cast<std::uint32_t>(block[i * 4 + 3]) << 24);
std::uint32_t a = state_[0];
std::uint32_t b = state_[1];
std::uint32_t c = state_[2];
std::uint32_t d = state_[3];
for (int i = 0; i < 64; i++) {
std::uint32_t f;
int g;
if (i < 16) { f = (b & c) | (~b & d); g = i; }
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) & 15; }
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) & 15; }
else { f = c ^ (b | ~d); g = (7 * i) & 15; }
f = f + a + K[i] + M[g];
a = d; d = c; c = b;
b = b + Rotl(f, S[i]);
}
state_[0] += a; state_[1] += b; state_[2] += c; state_[3] += d;
}
};
}