imsd 0.2.7 — initial public snapshot
Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP media, call engine) behind a GDBus control daemon, a standalone AMR-WB media leg, and a Plasma Dialer backend. C++26 modules built with Crafter Build; the tree is clean under the project's house-style linter (crafter-build lint) across all three build products. Assisted-by: Claude:claude-fable-5 Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
This commit is contained in:
commit
6e77823933
31 changed files with 8761 additions and 0 deletions
297
interfaces/Imsd-Aka.cppm
Normal file
297
interfaces/Imsd-Aka.cppm
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
507
interfaces/Imsd-Engine.cppm
Normal file
507
interfaces/Imsd-Engine.cppm
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Engine — the call state machine as a pure reducer.
|
||||
|
||||
A CallMachine owns one call's SIP dialog, outgoing or incoming. It never
|
||||
touches a socket, a clock, or a subprocess: each event (the INVITE's
|
||||
responses, an in-dialog request from the network, an accept/hang-up command,
|
||||
the media leg exiting, a setup timeout) is a method that mutates the dialog
|
||||
state and returns a list of Actions for the daemon shell to carry out — send
|
||||
this on the client flow, answer the request on the channel it came in on,
|
||||
start/stop the media leg, emit this D-Bus state change, arm this deadline.
|
||||
That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
|
||||
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, and the
|
||||
terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on
|
||||
Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against
|
||||
recorded network dialogs with no I/O (tests/Engine).
|
||||
|
||||
State/reason strings are the frozen D-Bus vocabulary the dialer maps on
|
||||
(dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/
|
||||
local-hangup/remote-hangup/refused-or-busy/error). Pure std C++.
|
||||
*/
|
||||
|
||||
export module Imsd:Engine;
|
||||
import std;
|
||||
import :Util;
|
||||
import :Sip;
|
||||
import :Sdp;
|
||||
import :Messages;
|
||||
|
||||
export namespace imsd::engine {
|
||||
|
||||
enum class CallState { Dialing, Ringing, Incoming, Active, Terminated };
|
||||
enum class Direction { Outgoing, Incoming };
|
||||
|
||||
inline std::string_view StateStr(CallState s) {
|
||||
switch (s) {
|
||||
case CallState::Dialing: return "dialing";
|
||||
case CallState::Ringing: return "ringing";
|
||||
case CallState::Incoming: return "incoming";
|
||||
case CallState::Active: return "active";
|
||||
case CallState::Terminated: return "terminated";
|
||||
}
|
||||
return "terminated";
|
||||
}
|
||||
|
||||
inline std::string_view DirectionStr(Direction d) {
|
||||
return d == Direction::Incoming ? "incoming" : "outgoing";
|
||||
}
|
||||
|
||||
// Parsed SDP answer needed to bring up the media leg.
|
||||
struct MediaLeg {
|
||||
std::string remoteIp;
|
||||
int remotePort = 0;
|
||||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB";
|
||||
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
|
||||
};
|
||||
|
||||
struct Action {
|
||||
enum class Type {
|
||||
SendClient, // send `text` on the client TCP flow
|
||||
SendResponse, // answer the just-handled inbound request on its origin channel
|
||||
StartMedia, // bring up the media leg described by `media`
|
||||
StopMedia, // tear the media leg down
|
||||
State, // emit CallStateChanged(uni, `state`, `reason`)
|
||||
Deleted, // emit CallDeleted(uni); drop this machine
|
||||
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
|
||||
Log,
|
||||
};
|
||||
Type type;
|
||||
std::string text;
|
||||
std::string state;
|
||||
std::string reason;
|
||||
MediaLeg media;
|
||||
double seconds = 0;
|
||||
};
|
||||
|
||||
// ring/answer budget, and how long to wait for a 487 after CANCEL — the
|
||||
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
|
||||
inline constexpr double InviteTimeout = 90.0;
|
||||
inline constexpr double Cancel487Timeout = 10.0;
|
||||
// How long an unanswered incoming call may ring before we 480 it — a
|
||||
// safety net behind the network's own CANCEL, so generous.
|
||||
inline constexpr double IncomingRingTimeout = 120.0;
|
||||
|
||||
// Constructor tag: this machine is the UAS of the given inbound INVITE.
|
||||
struct IncomingInvite {
|
||||
std::string_view invite;
|
||||
};
|
||||
|
||||
class CallMachine {
|
||||
public:
|
||||
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond)
|
||||
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)),
|
||||
rtpPort_(rtpPort), precond_(precond) {
|
||||
d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain);
|
||||
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
|
||||
d_.itag = rng_.Token(10);
|
||||
d_.invBranch = rng_.Token(20);
|
||||
}
|
||||
|
||||
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort)
|
||||
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort),
|
||||
precond_(false), direction_(Direction::Incoming),
|
||||
state_(CallState::Incoming), reason_("incoming"),
|
||||
invite_(in.invite) {
|
||||
number_ = imsd::sip::CallerId(invite_);
|
||||
d_.callid = std::string(imsd::sip::Header(invite_, "Call-ID").value_or(""));
|
||||
d_.itag = rng_.Token(10); // the To tag we mint = our dialog tag
|
||||
d_.invBranch = rng_.Token(20); // unused as UAS; kept initialized
|
||||
if (auto from = imsd::sip::Header(invite_, "From")) d_.dialogTo = std::string(*from);
|
||||
if (auto ct = imsd::sip::Header(invite_, "Contact")) d_.remoteTarget = AngleUri(*ct);
|
||||
d_.ruri = d_.remoteTarget.value_or("");
|
||||
// The UAS route set is the Record-Route in RECEIVED order
|
||||
// (RFC 3261 12.1.1); DialogRoute() reverses for the UAC case, so
|
||||
// store it reversed here to cancel that out.
|
||||
auto rr = imsd::sip::Headers(invite_, "Record-Route");
|
||||
for (std::size_t i = rr.size(); i-- > 0;)
|
||||
d_.recordRoute.emplace_back(rr[i]);
|
||||
answerSdp_ = std::string(Body(invite_)); // the caller's offer
|
||||
nextCseq_ = 0; // our first in-dialog request gets CSeq 1
|
||||
}
|
||||
|
||||
const std::string& Uni() const { return uni_; }
|
||||
const std::string& Number() const { return number_; }
|
||||
const std::string& CallId() const { return d_.callid; }
|
||||
CallState State() const { return state_; }
|
||||
std::string_view StateStr() const { return engine::StateStr(state_); }
|
||||
Direction Dir() const { return direction_; }
|
||||
std::string_view DirectionStr() const { return engine::DirectionStr(direction_); }
|
||||
const std::string& Reason() const { return reason_; }
|
||||
bool Terminated() const { return state_ == CallState::Terminated; }
|
||||
|
||||
// Build + queue the INVITE and arm the ring timeout.
|
||||
std::vector<Action> Start() {
|
||||
imsd::sdp::Offer offer;
|
||||
offer.local = ctx_.local;
|
||||
offer.rtpPort = rtpPort_;
|
||||
offer.sessionId = rng_.UInt(1000000, 9999999);
|
||||
offer.precond = precond_;
|
||||
std::string sdp = imsd::sdp::BuildOffer(offer);
|
||||
std::vector<Action> a;
|
||||
a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_)));
|
||||
a.push_back(Deadline(InviteTimeout));
|
||||
return a;
|
||||
}
|
||||
|
||||
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
|
||||
// reliable only when the caller Requires 100rel) and arm the ring
|
||||
// timeout. An offer we cannot serve — no audio line, or no AMR-WB
|
||||
// (the media plane is AMR-WB-only) — is refused with 488 up front,
|
||||
// before the UI ever rings.
|
||||
std::vector<Action> OnInvite() {
|
||||
std::vector<Action> a;
|
||||
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
|
||||
MediaLeg leg = ParseAnswer();
|
||||
if (!leg.Valid() || leg.codec != "AMR-WB") {
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag)));
|
||||
Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent"));
|
||||
return a;
|
||||
}
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag)));
|
||||
std::string require(imsd::sip::Header(invite_, "Require").value_or(""));
|
||||
if (require.contains("precondition")) a.push_back(LogAct("caller requires preconditions; answering without them"));
|
||||
std::vector<std::string> extra;
|
||||
if (require.contains("100rel")) {
|
||||
extra.push_back("Require: 100rel");
|
||||
extra.push_back("RSeq: 1");
|
||||
}
|
||||
last180_ = imsd::msg::BuildInviteResponse(ctx_, invite_, 180, "Ringing", d_.itag, std::nullopt, extra);
|
||||
a.push_back(Respond(last180_));
|
||||
a.push_back(Deadline(IncomingRingTimeout));
|
||||
return a;
|
||||
}
|
||||
|
||||
// UAS: the user answered. 200 OK with the SDP answer (the offer's own
|
||||
// payload types, RFC 3264), media up, active. The caller's ACK needs
|
||||
// no action when it arrives (OnRequest ignores ACK).
|
||||
std::vector<Action> OnAccept() {
|
||||
std::vector<Action> a;
|
||||
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
|
||||
MediaLeg leg = ParseAnswer();
|
||||
imsd::sdp::AnswerSpec spec;
|
||||
spec.local = ctx_.local;
|
||||
spec.rtpPort = rtpPort_;
|
||||
spec.sessionId = rng_.UInt(1000000, 9999999);
|
||||
spec.payloadType = leg.payloadType;
|
||||
spec.octetAlign = leg.octetAlign;
|
||||
spec.codec = leg.codec;
|
||||
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000);
|
||||
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
||||
// session timers: echo the caller's Session-Expires; with no
|
||||
// refresher stated, make the caller (uac) the refresher — its
|
||||
// re-INVITE/UPDATE refreshes get 200d by OnRequest.
|
||||
std::vector<std::string> extra;
|
||||
if (auto se = imsd::sip::Header(invite_, "Session-Expires")) {
|
||||
std::string v(*se);
|
||||
if (!v.contains("refresher=")) v += ";refresher=uac";
|
||||
extra.push_back(std::format("Session-Expires: {}", v));
|
||||
extra.push_back("Require: timer");
|
||||
}
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 200, "OK", d_.itag, localSdp_, extra)));
|
||||
a.push_back(StartMediaAct(leg));
|
||||
state_ = CallState::Active;
|
||||
a.push_back(StateAct("accepted"));
|
||||
return a;
|
||||
}
|
||||
|
||||
// A response to our INVITE (the shell already matched Call-ID + CSeq).
|
||||
std::vector<Action> OnInviteResponse(std::string_view msg) {
|
||||
std::vector<Action> a;
|
||||
if (direction_ == Direction::Incoming)
|
||||
return a; // a UAS has no INVITE transaction of its own
|
||||
AbsorbDialog(msg);
|
||||
auto st = imsd::sip::Status(msg);
|
||||
if (!st) return a;
|
||||
int code = *st;
|
||||
if (code > 100 && code < 200) {
|
||||
if (state_ == CallState::Dialing) {
|
||||
state_ = CallState::Ringing;
|
||||
a.push_back(StateAct("outgoing"));
|
||||
}
|
||||
auto rseq = imsd::sip::Header(msg, "RSeq");
|
||||
std::string require = std::string(imsd::sip::Header(msg, "Require").value_or(""));
|
||||
if (rseq && require.contains("100rel") && !pracked_.contains(std::string(*rseq))) {
|
||||
pracked_.insert(std::string(*rseq));
|
||||
std::array<std::string, 1> extra = {
|
||||
std::format("RAck: {} 1 INVITE", *rseq)};
|
||||
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "PRACK", nextCseq_, rng_.Token(20), extra)));
|
||||
nextCseq_++;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
if (code < 200)
|
||||
return a; // 1xx other than 100<code<200 (only 100): nothing
|
||||
if (code == 200) {
|
||||
a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or("")))));
|
||||
if (cancelled_) {
|
||||
// answered in the CANCEL race — we no longer want it
|
||||
nextCseq_++;
|
||||
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
|
||||
Append(a, Finish("local-hangup", "answered during our CANCEL; BYE sent"));
|
||||
return a;
|
||||
}
|
||||
MediaLeg leg = ParseAnswer();
|
||||
a.push_back(StartMediaAct(leg));
|
||||
state_ = CallState::Active;
|
||||
a.push_back(StateAct("accepted"));
|
||||
return a;
|
||||
}
|
||||
// non-2xx final
|
||||
a.push_back(Send(imsd::msg::BuildAckNon2xx(ctx_, d_, std::string(imsd::sip::Header(msg, "To").value_or("")))));
|
||||
std::string fin = WithReason(std::format("INVITE final {}", code), msg);
|
||||
if (cancelled_ || code == 487) Append(a, Finish("local-hangup", fin));
|
||||
else if (code == 486 || code == 480 || code == 603) Append(a, Finish("refused-or-busy", fin));
|
||||
else
|
||||
Append(a, Finish("error", fin));
|
||||
return a;
|
||||
}
|
||||
|
||||
// An in-dialog request from the network (the shell matched Call-ID).
|
||||
std::vector<Action> OnRequest(std::string_view msg) {
|
||||
std::vector<Action> a;
|
||||
std::string_view method = Method(msg);
|
||||
if (method == "ACK")
|
||||
return a; // ACK is never responded to (RFC 3261 17.1.1.2)
|
||||
std::string_view body = Body(msg);
|
||||
if (body.contains("m=audio")) answerSdp_ = std::string(body);
|
||||
if (direction_ == Direction::Incoming && state_ == CallState::Incoming) {
|
||||
if (method == "CANCEL") {
|
||||
// the caller gave up: 200 the CANCEL transaction, 487 the
|
||||
// INVITE transaction (RFC 3261 9.2), done.
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 487, "Request Terminated", d_.itag)));
|
||||
Append(a, Finish("remote-hangup", WithReason("CANCEL before answer", msg)));
|
||||
} else if (method == "INVITE") {
|
||||
// retransmitted INVITE (our provisional got lost): repeat it
|
||||
a.push_back(Respond(last180_));
|
||||
} else {
|
||||
// PRACK of our reliable 180, or anything else: 200 echo
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
if (method == "UPDATE" || method == "INVITE") {
|
||||
std::optional<std::string> ans;
|
||||
if (direction_ == Direction::Incoming) {
|
||||
// session-refresh: re-state the answer we gave if the
|
||||
// refresh carries an offer; a bodyless refresh gets a
|
||||
// bodyless 200 (RFC 3311/4028)
|
||||
if (body.contains("m=audio")) ans = localSdp_;
|
||||
} else {
|
||||
imsd::sdp::Offer offer;
|
||||
offer.local = ctx_.local;
|
||||
offer.rtpPort = rtpPort_;
|
||||
offer.sessionId = rng_.UInt(1000000, 9999999);
|
||||
offer.precond = precond_;
|
||||
offer.currRemote = "sendrecv";
|
||||
ans = imsd::sdp::BuildOffer(offer);
|
||||
}
|
||||
// RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires,
|
||||
// or the refresher treats the session as unrefreshed. KPN's
|
||||
// B2BUA audits ~55 s into every call with such an UPDATE and
|
||||
// releases ("PT: Session timer expired") on a bare 200.
|
||||
std::vector<std::string> extra;
|
||||
if (auto se = imsd::sip::Header(msg, "Session-Expires")) {
|
||||
std::string v(*se);
|
||||
if (!v.contains("refresher=")) v += ";refresher=uac";
|
||||
extra.push_back(std::format("Session-Expires: {}", v));
|
||||
extra.push_back("Require: timer");
|
||||
}
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg, ans ? std::optional<std::string_view>(*ans) : std::nullopt, extra)));
|
||||
} else if (method == "BYE") {
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
|
||||
Append(a, Finish("remote-hangup", WithReason("remote BYE", msg)));
|
||||
} else if (method == "CANCEL") {
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
|
||||
Append(a, Finish("remote-hangup", WithReason("remote CANCEL", msg)));
|
||||
} else {
|
||||
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
std::vector<Action> OnHangup() {
|
||||
std::vector<Action> a;
|
||||
if (direction_ == Direction::Incoming && state_ == CallState::Incoming) {
|
||||
// reject the unanswered call
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 486, "Busy Here", d_.itag)));
|
||||
Append(a, Finish("local-hangup", "rejected (486 sent)"));
|
||||
return a;
|
||||
}
|
||||
if (state_ == CallState::Active) {
|
||||
nextCseq_++;
|
||||
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
|
||||
Append(a, Finish("local-hangup", "HangUp; BYE sent"));
|
||||
} else if ((state_ == CallState::Dialing || state_ == CallState::Ringing) && !cancelled_) {
|
||||
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
|
||||
cancelled_ = true;
|
||||
a.push_back(Deadline(Cancel487Timeout));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// The media subprocess exited. code 3 = the downlink RTP dried up
|
||||
// (far-end hangup); anything else = the leg died. Either way, if the
|
||||
// call was up we release it network-side and tear down.
|
||||
std::vector<Action> OnMediaExit(int code) {
|
||||
std::vector<Action> a;
|
||||
if (state_ != CallState::Active) return a;
|
||||
nextCseq_++;
|
||||
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
|
||||
Append(a, Finish("remote-hangup", code == 3 ? "far-end hangup (downlink RTP stopped); BYE sent" : std::format("media leg died (code {}); BYE sent", code)));
|
||||
return a;
|
||||
}
|
||||
|
||||
// A setup deadline fired (never armed once Active).
|
||||
std::vector<Action> OnDeadline() {
|
||||
std::vector<Action> a;
|
||||
if (state_ == CallState::Active) return a;
|
||||
if (direction_ == Direction::Incoming) {
|
||||
if (state_ == CallState::Incoming) {
|
||||
// rang unanswered past the safety net; the missed call
|
||||
// reads as remote-hangup, like a network CANCEL would
|
||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 480, "Temporarily Unavailable", d_.itag)));
|
||||
Append(a, Finish("remote-hangup", "rang unanswered past the deadline (480 sent)"));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
if (!cancelled_ && (state_ == CallState::Dialing || state_ == CallState::Ringing)) {
|
||||
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
|
||||
cancelled_ = true;
|
||||
a.push_back(Deadline(Cancel487Timeout));
|
||||
} else {
|
||||
Append(a, Finish("error", "setup deadline expired"));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// The shell couldn't send (socket error) — collapse the call.
|
||||
std::vector<Action> Fail(std::string_view reason) {
|
||||
std::vector<Action> a;
|
||||
Append(a, Finish(reason, "client-flow send failed"));
|
||||
return a;
|
||||
}
|
||||
|
||||
private:
|
||||
const imsd::msg::Context& ctx_;
|
||||
imsd::util::Rng& rng_;
|
||||
std::string uni_, number_;
|
||||
int rtpPort_;
|
||||
bool precond_;
|
||||
Direction direction_ = Direction::Outgoing;
|
||||
CallState state_ = CallState::Dialing;
|
||||
std::string reason_ = "outgoing";
|
||||
std::string invite_; // incoming only: the inbound INVITE verbatim
|
||||
std::string last180_; // incoming only: our provisional, for retransmits
|
||||
std::string localSdp_; // incoming only: the SDP answer we sent
|
||||
imsd::msg::Dialog d_;
|
||||
int nextCseq_ = 2;
|
||||
bool cancelled_ = false;
|
||||
std::set<std::string> pracked_;
|
||||
std::string answerSdp_;
|
||||
|
||||
static std::string_view Body(std::string_view msg) {
|
||||
std::size_t at = msg.find("\r\n\r\n");
|
||||
return at == std::string_view::npos ? std::string_view{}
|
||||
: msg.substr(at + 4);
|
||||
}
|
||||
static std::string_view Method(std::string_view msg) {
|
||||
std::size_t sp = msg.find(' ');
|
||||
return sp == std::string_view::npos ? msg : msg.substr(0, sp);
|
||||
}
|
||||
static std::optional<std::string> AngleUri(std::string_view v) {
|
||||
std::size_t lt = v.find('<');
|
||||
if (lt == std::string_view::npos) return std::string(v);
|
||||
std::size_t gt = v.find('>', lt);
|
||||
if (gt == std::string_view::npos) return std::string(v);
|
||||
return std::string(v.substr(lt + 1, gt - lt - 1));
|
||||
}
|
||||
|
||||
// Fold a response's dialog-establishing headers into the dialog.
|
||||
void AbsorbDialog(std::string_view msg) {
|
||||
if (auto to = imsd::sip::Header(msg, "To"))
|
||||
if (to->contains("tag=")) d_.dialogTo = std::string(*to);
|
||||
auto rr = imsd::sip::Headers(msg, "Record-Route");
|
||||
if (!rr.empty()) {
|
||||
d_.recordRoute.clear();
|
||||
for (auto v : rr)
|
||||
d_.recordRoute.emplace_back(v);
|
||||
}
|
||||
if (auto ct = imsd::sip::Header(msg, "Contact")) d_.remoteTarget = AngleUri(*ct);
|
||||
std::string_view body = Body(msg);
|
||||
if (body.contains("m=audio")) answerSdp_ = std::string(body);
|
||||
}
|
||||
|
||||
MediaLeg ParseAnswer() const {
|
||||
imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_);
|
||||
MediaLeg leg;
|
||||
leg.remoteIp = ans.ip;
|
||||
leg.remotePort = ans.port < 0 ? 0 : ans.port;
|
||||
leg.payloadType = ans.payloadType;
|
||||
leg.octetAlign = ans.octetAlign;
|
||||
leg.codec = ans.codec;
|
||||
return leg;
|
||||
}
|
||||
|
||||
// Terminate: state change, media teardown, deletion — in that order,
|
||||
// so the UI leaves the call page before the (slow) media teardown.
|
||||
// One journald line per teardown — who ended the call and why
|
||||
// (the s59 60-second-drop hunt ran blind for lack of exactly
|
||||
// this; the network's Reason header named the cause all along).
|
||||
// `detail` carries the SIP-level cause when the caller knows it.
|
||||
std::vector<Action> Finish(std::string_view reason, std::string_view detail = "") {
|
||||
state_ = CallState::Terminated;
|
||||
reason_ = std::string(reason);
|
||||
std::vector<Action> a;
|
||||
a.push_back(LogAct(detail.empty() ? std::format("call {} ended: {}", uni_, reason) : std::format("call {} ended: {} — {}", uni_, reason, detail)));
|
||||
a.push_back(StateAct(reason));
|
||||
a.push_back(Action{Action::Type::StopMedia});
|
||||
a.push_back(Action{Action::Type::Deleted});
|
||||
return a;
|
||||
}
|
||||
|
||||
// "remote BYE, Reason: SIP;cause=503;text=..." — the peer's own
|
||||
// stated cause, when it sent one.
|
||||
static std::string WithReason(std::string_view what, std::string_view msg) {
|
||||
auto r = imsd::sip::Header(msg, "Reason");
|
||||
return r ? std::format("{}, Reason: {}", what, *r) : std::string(what);
|
||||
}
|
||||
|
||||
Action Send(std::string text) {
|
||||
return Action{Action::Type::SendClient, std::move(text)};
|
||||
}
|
||||
Action Respond(std::string text) {
|
||||
return Action{Action::Type::SendResponse, std::move(text)};
|
||||
}
|
||||
Action StateAct(std::string_view reason) {
|
||||
reason_ = std::string(reason);
|
||||
Action a{Action::Type::State};
|
||||
a.state = std::string(engine::StateStr(state_));
|
||||
a.reason = reason_;
|
||||
return a;
|
||||
}
|
||||
Action StartMediaAct(const MediaLeg& leg) {
|
||||
Action a{Action::Type::StartMedia};
|
||||
a.media = leg;
|
||||
return a;
|
||||
}
|
||||
Action Deadline(double s) {
|
||||
Action a{Action::Type::SetDeadline};
|
||||
a.seconds = s;
|
||||
return a;
|
||||
}
|
||||
Action LogAct(std::string text) {
|
||||
return Action{Action::Type::Log, std::move(text)};
|
||||
}
|
||||
static void Append(std::vector<Action>& into, std::vector<Action> more) {
|
||||
for (auto& m : more)
|
||||
into.push_back(std::move(m));
|
||||
}
|
||||
};
|
||||
}
|
||||
208
interfaces/Imsd-Ipsec.cppm
Normal file
208
interfaces/Imsd-Ipsec.cppm
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Ipsec — IMS security-association plumbing expressed as `ip xfrm` command
|
||||
lines, plus a reader for an SA already installed in the kernel.
|
||||
|
||||
A registered IMS UE keeps two ESP SA pairs with the P-CSCF (TS 33.203):
|
||||
reqid 1 carries the UE-initiated client flow (protected client port ⇄ P-CSCF
|
||||
server port), reqid 2 the terminating server flow. Integrity is HMAC-SHA1-96
|
||||
keyed with IK; confidentiality is the negotiated ealg keyed with CK. This
|
||||
module turns the negotiated SPIs/ports/keys into the exact argv lists the
|
||||
daemon shell hands to `ip`, and — for a warm resume — reconstructs the SPIs,
|
||||
ports, and Security-Server value back out of `ip xfrm state`/`policy` text so
|
||||
a refresh re-REGISTER can advertise the SPIs actually in use.
|
||||
|
||||
Pure std C++ (the shell runs the commands and captures the text). Command
|
||||
shapes and the parser are pinned by tests/Ipsec.
|
||||
*/
|
||||
|
||||
export module Imsd:Ipsec;
|
||||
import std;
|
||||
import :Util;
|
||||
|
||||
export namespace imsd::ipsec {
|
||||
|
||||
// UE protected ports are fixed by the stack; P-CSCF ports come from the
|
||||
// Security-Server the network offered (fresh) or the kernel (resume).
|
||||
struct SaParams {
|
||||
std::string local; // UE global IPv6/4 on the ims PDN
|
||||
std::string pcscf; // P-CSCF address
|
||||
std::vector<std::uint8_t> ik; // integrity key (auth)
|
||||
std::vector<std::uint8_t> ck; // cipher key (enc)
|
||||
std::uint32_t spiUc = 0; // UE client inbound SA (P->UE, reqid 1)
|
||||
std::uint32_t spiUs = 0; // UE server inbound SA (P->UE, reqid 2)
|
||||
std::uint32_t spiPc = 0; // P-CSCF client outbound SA (UE->P, reqid 2)
|
||||
std::uint32_t spiPs = 0; // P-CSCF server outbound SA (UE->P, reqid 1)
|
||||
int portUc = imsd::util::PortUc; // UE protected client port
|
||||
int portUs = imsd::util::PortUs; // UE protected server port
|
||||
int portPs = 0; // P-CSCF server port (UE connects its TCP here)
|
||||
int portPc = 0; // P-CSCF client port
|
||||
std::string ealg = "aes-cbc"; // aes-cbc | des-ede3-cbc/3des | null
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
// ESP cipher argv triple for `ip xfrm state add ... <enc>`, matching
|
||||
// imscall.setup_sa: aes-cbc keyed with CK; 3DES uses CK||CK[:8] to
|
||||
// reach 24 bytes; anything else is the null cipher (empty key).
|
||||
inline std::vector<std::string> EncArgs(const SaParams& p) {
|
||||
if (p.ealg == "aes-cbc") return {"enc", "cbc(aes)", std::format("0x{}", imsd::util::ToHex(p.ck))};
|
||||
if (p.ealg == "des-ede3-cbc" || p.ealg == "3des") {
|
||||
std::vector<std::uint8_t> k = p.ck;
|
||||
k.insert(k.end(), p.ck.begin(), p.ck.begin() + 8);
|
||||
return {"enc", "cbc(des3_ede)", std::format("0x{}", imsd::util::ToHex(k))};
|
||||
}
|
||||
return {"enc", "cipher_null", ""};
|
||||
}
|
||||
|
||||
inline std::vector<std::string> StateAdd(std::string_view src, std::string_view dst, std::uint32_t spi, int reqid, std::string_view ikx, const std::vector<std::string>& enc) {
|
||||
std::vector<std::string> a = {
|
||||
"ip", "xfrm", "state", "add",
|
||||
"src", std::string(src), "dst", std::string(dst),
|
||||
"proto", "esp", "spi", std::to_string(spi),
|
||||
"mode", "transport", "reqid", std::to_string(reqid),
|
||||
"auth-trunc", "hmac(sha1)", std::string(ikx), "96"};
|
||||
a.insert(a.end(), enc.begin(), enc.end());
|
||||
return a;
|
||||
}
|
||||
|
||||
inline std::vector<std::string> PolicyAdd(std::string_view src, std::string_view dst, int plen, int sport, int dport, std::string_view dir, std::string_view tsrc, std::string_view tdst, int reqid) {
|
||||
return {
|
||||
"ip", "xfrm", "policy", "add",
|
||||
"src", std::format("{}/{}", src, plen),
|
||||
"dst", std::format("{}/{}", dst, plen),
|
||||
"sport", std::to_string(sport), "dport", std::to_string(dport),
|
||||
"dir", std::string(dir),
|
||||
"tmpl", "src", std::string(tsrc), "dst", std::string(tdst),
|
||||
"proto", "esp", "reqid", std::to_string(reqid),
|
||||
"mode", "transport"};
|
||||
}
|
||||
}
|
||||
|
||||
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush
|
||||
// state+policy, add the four ESP states, add the four transport policies.
|
||||
// Order matches imscall.setup_sa exactly.
|
||||
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
|
||||
const std::string& local = p.local;
|
||||
const std::string& pcscf = p.pcscf;
|
||||
std::string ikx = std::format("0x{}", imsd::util::ToHex(p.ik));
|
||||
std::vector<std::string> enc = detail::EncArgs(p);
|
||||
int plen = imsd::util::Is6(local) ? 128 : 32;
|
||||
std::vector<std::vector<std::string>> cmds;
|
||||
cmds.push_back({"ip", "xfrm", "state", "flush"});
|
||||
cmds.push_back({"ip", "xfrm", "policy", "flush"});
|
||||
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, ikx, enc));
|
||||
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, ikx, enc));
|
||||
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, ikx, enc));
|
||||
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUs, 2, ikx, enc));
|
||||
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUc, p.portPs, "out", local, pcscf, 1));
|
||||
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPs, p.portUc, "in", pcscf, local, 1));
|
||||
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUs, p.portPc, "out", local, pcscf, 2));
|
||||
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPc, p.portUs, "in", pcscf, local, 2));
|
||||
return cmds;
|
||||
}
|
||||
|
||||
std::vector<std::vector<std::string>> BuildTeardownCommands() {
|
||||
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
|
||||
}
|
||||
|
||||
// What a warm SA in the kernel tells us — enough for a refresh re-REGISTER
|
||||
// to re-bind without re-authenticating.
|
||||
struct ExistingSa {
|
||||
std::string securityServer; // reconstructed Security-Server header value
|
||||
int portPs = 0;
|
||||
int portPc = 0;
|
||||
std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh)
|
||||
std::uint32_t spiUs = 0; // our inbound server SPI
|
||||
std::uint32_t spiPs = 0;
|
||||
std::uint32_t spiPc = 0;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
// The whitespace-delimited token immediately after `key` (which should
|
||||
// include its trailing space, e.g. "spi "); empty if `key` is absent.
|
||||
inline std::string_view TokenAfter(std::string_view text, std::string_view key) {
|
||||
std::size_t at = text.find(key);
|
||||
if (at == std::string_view::npos) return {};
|
||||
std::size_t start = at + key.size();
|
||||
std::size_t end = text.find_first_of(" \t\r\n", start);
|
||||
return text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
|
||||
}
|
||||
|
||||
// Split into blocks, each starting at a line-anchored "src " and
|
||||
// running to just before the next one.
|
||||
inline std::vector<std::string_view> SrcBlocks(std::string_view text) {
|
||||
std::vector<std::string_view> blocks;
|
||||
std::size_t i = 0;
|
||||
std::optional<std::size_t> cur;
|
||||
std::size_t pos = 0;
|
||||
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);
|
||||
if (line.starts_with("src ")) {
|
||||
if (cur) blocks.push_back(text.substr(*cur, pos - *cur));
|
||||
cur = pos;
|
||||
}
|
||||
if (nl == std::string_view::npos) break;
|
||||
pos = nl + 1;
|
||||
}
|
||||
if (cur) blocks.push_back(text.substr(*cur));
|
||||
(void)i;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
inline std::optional<std::uint32_t> ParseHex32(std::string_view tok) {
|
||||
if (tok.starts_with("0x") || tok.starts_with("0X")) tok.remove_prefix(2);
|
||||
if (tok.empty()) return std::nullopt;
|
||||
std::uint32_t v = 0;
|
||||
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v, 16);
|
||||
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
|
||||
return v;
|
||||
}
|
||||
|
||||
inline std::optional<int> ParseDec(std::string_view tok) {
|
||||
int v = 0;
|
||||
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v);
|
||||
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text.
|
||||
// Mapping (matches imscall.parse_existing_sa):
|
||||
// state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc
|
||||
// (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs
|
||||
// policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport
|
||||
// Returns nullopt unless spiPs, spiPc, portPs, portPc are all found.
|
||||
std::optional<ExistingSa> ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) {
|
||||
ExistingSa sa;
|
||||
bool havePs = false;
|
||||
bool havePc = false;
|
||||
bool havePortPs = false;
|
||||
bool havePortPc = false;
|
||||
for (std::string_view b : detail::SrcBlocks(stateText)) {
|
||||
std::string_view src = detail::TokenAfter(b, "src ");
|
||||
std::string_view dst = detail::TokenAfter(b, "dst ");
|
||||
auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi "));
|
||||
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
||||
if (!spi || !reqid) continue;
|
||||
if (src == local && dst == pcscf && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
|
||||
if (src == local && dst == pcscf && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
|
||||
if (src == pcscf && dst == local && *reqid == 1) sa.spiUc = *spi;
|
||||
if (src == pcscf && dst == local && *reqid == 2) sa.spiUs = *spi;
|
||||
}
|
||||
for (std::string_view b : detail::SrcBlocks(policyText)) {
|
||||
if (b.find("dir out") == std::string_view::npos) continue;
|
||||
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
|
||||
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
||||
if (!dport || !reqid) continue;
|
||||
if (*reqid == 1) { sa.portPs = *dport; havePortPs = true; }
|
||||
else if (*reqid == 2) { sa.portPc = *dport; havePortPc = true; }
|
||||
}
|
||||
if (!(havePs && havePc && havePortPs && havePortPc)) return std::nullopt;
|
||||
sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs);
|
||||
return sa;
|
||||
}
|
||||
}
|
||||
457
interfaces/Imsd-Messages.cppm
Normal file
457
interfaces/Imsd-Messages.cppm
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Messages — the SIP request/response text the engine puts on the wire.
|
||||
|
||||
Every VoLTE message the daemon sends is assembled here from an explicit
|
||||
context and the per-request variables (tags, branches, CSeq) so the builders
|
||||
are pure functions with no hidden state: the engine owns the dialog bookkeeping
|
||||
and the transport owns the socket, this module only formats bytes. That makes
|
||||
each message byte-pinnable — tests/Messages compares the output against strings
|
||||
cross-generated from the Python reference stack, header for header.
|
||||
|
||||
Covers: the unprotected + protected REGISTER and the refresh re-REGISTER
|
||||
(sec-agree, Security-Client/Verify, AKAv1-MD5 Authorization); the INVITE with
|
||||
an AMR-WB SDP offer and IMS MMTEL feature tags; the in-dialog PRACK/BYE and
|
||||
2xx/non-2xx ACK; CANCEL (reusing the INVITE branch); the 200 OK that echoes
|
||||
an inbound request's dialog headers; and the UAS responses to an inbound
|
||||
INVITE (100/180/200 and rejection finals). Pure std C++.
|
||||
*/
|
||||
|
||||
export module Imsd:Messages;
|
||||
import std;
|
||||
import :Util;
|
||||
import :Aka;
|
||||
import :Sip;
|
||||
|
||||
export namespace imsd::msg {
|
||||
|
||||
// Everything the builders need that is stable for the life of a
|
||||
// registration. Ports default to the fixed protected values; route/ppi/
|
||||
// securityServer are filled in from the REGISTER 200 OK.
|
||||
struct Context {
|
||||
imsd::aka::Identity id;
|
||||
std::string local;
|
||||
int portUc = imsd::util::PortUc;
|
||||
int portUs = imsd::util::PortUs;
|
||||
int initPort = 5060;
|
||||
std::string ealgOffer = "aes-cbc";
|
||||
// RFC 7254 IMEI URN for +sip.instance (e.g. urn:gsma:imei:35999999-
|
||||
// 000000-1); empty omits the parameter.
|
||||
std::string instanceId;
|
||||
// User-part of the registered-binding Contact URI. The stock modem
|
||||
// registers a fresh random UUID here, NOT the IMSI (oracle capture
|
||||
// 2026-07-21, journal/ims.md s53) — one of the two residual diffs vs
|
||||
// our REGISTER while MT calls still CSFB. Empty falls back to the
|
||||
// IMSI: the pre-oracle shape, which old state files resumed from and
|
||||
// the byte-pinned tests encode.
|
||||
std::string contactUser;
|
||||
// User-Agent header value for REGISTER; the stock modem sends its
|
||||
// build string (the other residual oracle diff). Empty omits.
|
||||
std::string userAgent;
|
||||
// From the successful REGISTER:
|
||||
std::string route; // Route header value (Service-Route or fallback)
|
||||
std::string ppi; // P-Preferred-Identity value
|
||||
std::string aor; // default public identity (P-Associated-URI[0],
|
||||
// sip: entry preferred) — the identity for
|
||||
// self-targeted requests like reg-event
|
||||
// SUBSCRIBE; the temporary (IMSI-derived)
|
||||
// IMPU is barred for anything but REGISTER
|
||||
std::string securityServer; // Security-Verify value (the network's ss)
|
||||
// P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli
|
||||
// location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header —
|
||||
// never fabricate one: the network reads it to learn which access
|
||||
// the UE is reachable on (stock sends it in every REGISTER/INVITE).
|
||||
std::string panInfo;
|
||||
};
|
||||
|
||||
// A Via line: "Via: SIP/2.0/<transport> [local]:<port>;branch=z9hG4bK<b>;rport".
|
||||
//
|
||||
// Port rule (TS 24.229 5.1.1.4 / 33.203): every PROTECTED request carries
|
||||
// the protected SERVER port (portUs) in Via and Contact — that is the
|
||||
// port the network delivers terminating requests to (MT INVITE, NOTIFY,
|
||||
// in-dialog requests toward us). The protected CLIENT port (portUc) is
|
||||
// only the source port of the TCP connection we open; it never appears
|
||||
// in headers. Getting this wrong is invisible on the originating path
|
||||
// (TCP responses ride our own connection regardless) but silently kills
|
||||
// ALL terminating delivery: the P-CSCF has no deliverable flow toward a
|
||||
// Contact naming portUc, so MT calls fall back to CS and reg-event
|
||||
// NOTIFYs vanish (found live 2026-07-20; the stock modem puts its
|
||||
// port_us in both Via and Contact).
|
||||
inline std::string ViaLine(const Context& c, std::string_view transport, int port, std::string_view branch) {
|
||||
return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch);
|
||||
}
|
||||
|
||||
// Security-Client value carrying our in-use client SPIs + protected ports.
|
||||
inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) {
|
||||
return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, c.portUc, c.portUs);
|
||||
}
|
||||
|
||||
// Authorization line with an empty (unchallenged) digest — the first
|
||||
// REGISTER, before the 401 nonce is known.
|
||||
inline std::string AuthEmpty(const Context& c) {
|
||||
return std::format("Authorization: Digest username=\"{}\", realm=\"{}\", uri=\"{}\", " "nonce=\"\", response=\"\"", c.id.impi, c.id.domain, c.id.regUri);
|
||||
}
|
||||
|
||||
// Authorization line with a computed AKAv1-MD5 response.
|
||||
inline std::string AuthAka(const Context& c, std::string_view nonce, std::string_view cnonce, std::string_view response) {
|
||||
return std::format(
|
||||
"Authorization: Digest username=\"{}\", realm=\"{}\", uri=\"{}\", "
|
||||
"nonce=\"{}\", qop=auth, nc=00000001, cnonce=\"{}\", response=\"{}\", "
|
||||
"algorithm=AKAv1-MD5",
|
||||
c.id.impi, c.id.domain, c.id.regUri, nonce, cnonce, response);
|
||||
}
|
||||
|
||||
// REGISTER Contact media-feature tags. The MMTEL ICSI declares the
|
||||
// binding VOICE-capable: terminating access-domain selection only routes
|
||||
// incoming calls to contacts registered with it — without the tag the
|
||||
// network diverts straight to voicemail (KPN, observed live 2026-07-20;
|
||||
// the stock modem registers with exactly this ICSI). The URN colons are
|
||||
// percent-encoded (TS 24.229 7.2A.8.2 feature-tag coding; RFC 3841
|
||||
// matching compares the value as a string, so the encoding must match
|
||||
// what the network selects on — the stock modem sends exactly
|
||||
// urn%3Aurn-7%3A..., diag capture 2026-07-18). +sip.instance carries the
|
||||
// IMEI URN when known. Deliberately NOT +g.3gpp.smsip: it routes
|
||||
// terminating SMS over IMS to a stack with no SMSoIP handling yet,
|
||||
// silently losing texts — and exact tag parity including smsip +
|
||||
// nw-init-ussi was tested live (2026-07-20, stored binding echo-verified)
|
||||
// and did NOT change terminating access-domain selection, so the tags
|
||||
// buy nothing.
|
||||
// The binding user-part every Contact we emit carries (see
|
||||
// Context::contactUser).
|
||||
inline std::string_view ContactUser(const Context& c) {
|
||||
return c.contactUser.empty() ? std::string_view(c.id.imsi)
|
||||
: std::string_view(c.contactUser);
|
||||
}
|
||||
|
||||
inline std::string ContactFeatures(const Context& c) {
|
||||
std::string s;
|
||||
if (!c.instanceId.empty()) s += std::format(";+sip.instance=\"<{}>\"", c.instanceId);
|
||||
s += ";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"";
|
||||
return s;
|
||||
}
|
||||
|
||||
// Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national
|
||||
// numbers become user=phone SIP URIs at the home domain; a bare short code
|
||||
// gets phone-context.
|
||||
inline std::string RuriFor(std::string_view target, std::string_view domain) {
|
||||
std::string t;
|
||||
for (char ch : target)
|
||||
if (ch != ' ') t.push_back(ch);
|
||||
if (t.starts_with("+")) return std::format("sip:{}@{};user=phone", t, domain);
|
||||
if (t.starts_with("00")) return std::format("sip:+{}@{};user=phone", t.substr(2), domain);
|
||||
if (t.starts_with("0") && t.size() >= 9) return std::format("sip:+31{}@{};user=phone", t.substr(1), domain);
|
||||
return std::format("sip:{};phone-context={}@{};user=phone", t, domain, domain);
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
inline std::string Join(std::span<const std::string> lines) {
|
||||
std::string s;
|
||||
for (std::size_t i = 0; i < lines.size(); i++) {
|
||||
s += lines[i];
|
||||
s += "\r\n";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- REGISTER -------------------------------------------------------
|
||||
|
||||
// The first, unprotected REGISTER (UDP from the init port, CSeq 1, empty
|
||||
// digest, Supported: path). Triggers the 401 that carries the AKA nonce.
|
||||
std::string BuildRegisterInitial(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, std::uint32_t spiUc, std::uint32_t spiUs) {
|
||||
std::vector<std::string> l = {
|
||||
std::format("REGISTER {} SIP/2.0", c.id.regUri),
|
||||
ViaLine(c, "UDP", c.initPort, branch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("From: <{}>;tag={}", c.id.impu, ftag),
|
||||
std::format("To: <{}>", c.id.impu),
|
||||
std::format("Call-ID: {}", callid),
|
||||
"CSeq: 1 REGISTER",
|
||||
std::format("Contact: <sip:{}@{}>{};expires=600000", ContactUser(c), imsd::util::HostPort(c.local, c.initPort), ContactFeatures(c)),
|
||||
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER",
|
||||
AuthEmpty(c),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
SecurityClient(c, spiUc, spiUs),
|
||||
"Supported: sec-agree, path",
|
||||
"Expires: 600000",
|
||||
};
|
||||
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
|
||||
if (!c.userAgent.empty()) l.push_back(std::format("User-Agent: {}", c.userAgent));
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// A protected REGISTER over the IPsec/TCP flow. Always advertises
|
||||
// Supported: sec-agree, path — RFC 3327: 'path' here is the UA's
|
||||
// declaration that it is reachable via the Path the P-CSCF inserted,
|
||||
// i.e. the route every terminating request (NOTIFY, MT INVITE) takes.
|
||||
// The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture,
|
||||
// reg2 build at 19:30:44); an earlier reading that reg2 omits it was
|
||||
// wrong and cost us all terminating delivery. `authLine` is a full
|
||||
// Authorization line (AuthEmpty for the refresh's first try, AuthAka
|
||||
// once challenged).
|
||||
std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine) {
|
||||
std::vector<std::string> l = {
|
||||
std::format("REGISTER {} SIP/2.0", c.id.regUri),
|
||||
ViaLine(c, "TCP", c.portUs, branch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("From: <{}>;tag={}", c.id.impu, ftag),
|
||||
std::format("To: <{}>", c.id.impu),
|
||||
std::format("Call-ID: {}", callid),
|
||||
std::format("CSeq: {} REGISTER", cseq),
|
||||
std::format("Contact: <sip:{}@{}>{};expires=600000", ContactUser(c), imsd::util::HostPort(c.local, c.portUs), ContactFeatures(c)),
|
||||
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER",
|
||||
std::string(authLine),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
SecurityClient(c, spiUc, spiUs),
|
||||
std::format("Security-Verify: {}", c.securityServer),
|
||||
};
|
||||
l.push_back("Supported: sec-agree, path");
|
||||
l.push_back("Expires: 600000");
|
||||
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
|
||||
if (!c.userAgent.empty()) l.push_back(std::format("User-Agent: {}", c.userAgent));
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// SUBSCRIBE to the registration event package (RFC 3680) for our own
|
||||
// AOR — TS 24.229 5.1.1.3 requires it after registration. The immediate
|
||||
// NOTIFY carries application/reginfo+xml with every binding the S-CSCF
|
||||
// holds for the implicit registration set, feature tags as stored — the
|
||||
// network's own view of what terminating routing selects on. Same
|
||||
// sec-agree/route shape as the other protected in-flow requests; a new
|
||||
// dialog (fresh Call-ID, CSeq 1) per subscription.
|
||||
std::string BuildSubscribeReg(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch) {
|
||||
std::string_view aor = c.aor.empty() ? std::string_view(c.id.impu)
|
||||
: std::string_view(c.aor);
|
||||
std::vector<std::string> l = {
|
||||
std::format("SUBSCRIBE {} SIP/2.0", aor),
|
||||
ViaLine(c, "TCP", c.portUs, branch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", c.route),
|
||||
std::format("From: <{}>;tag={}", aor, ftag),
|
||||
std::format("To: <{}>", aor),
|
||||
std::format("Call-ID: {}", callid),
|
||||
"CSeq: 1 SUBSCRIBE",
|
||||
std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)),
|
||||
"Event: reg",
|
||||
"Accept: application/reginfo+xml",
|
||||
"Expires: 600000",
|
||||
};
|
||||
// P-Preferred-Identity is a hint (RFC 3325) — omitted when no public
|
||||
// identity has been learned yet; the network asserts one regardless.
|
||||
if (!c.ppi.empty()) l.push_back(std::format("P-Preferred-Identity: <{}>", c.ppi));
|
||||
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
|
||||
l.push_back(std::format("Security-Verify: {}", c.securityServer));
|
||||
l.push_back("Proxy-Require: sec-agree");
|
||||
l.push_back("Require: sec-agree");
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// ---- call dialog ----------------------------------------------------
|
||||
|
||||
// The dialog identity + routing the engine accumulates for one call.
|
||||
// For an outgoing call itag is our From tag and dialogTo is learned from
|
||||
// the responses; for an incoming call itag is the To tag we mint,
|
||||
// dialogTo is the caller's From, and both feed BuildInDialog identically
|
||||
// (our requests always put OUR tag in From and the remote's in To).
|
||||
struct Dialog {
|
||||
std::string ruri;
|
||||
std::string callid;
|
||||
std::string itag; // our (local) dialog tag
|
||||
std::string invBranch; // INVITE Via branch (CANCEL reuses it)
|
||||
std::optional<std::string> dialogTo; // remote To/From incl. tag, once known
|
||||
std::vector<std::string> recordRoute; // route set, UAC order (see DialogRoute)
|
||||
std::optional<std::string> remoteTarget; // remote Contact URI
|
||||
};
|
||||
|
||||
// Dialog route set: the reversed Record-Route, or the registration Route
|
||||
// when none was recorded (imsd._dialog_route).
|
||||
inline std::string DialogRoute(const Context& c, const Dialog& d) {
|
||||
if (d.recordRoute.empty()) return c.route;
|
||||
std::string s;
|
||||
for (std::size_t i = d.recordRoute.size(); i-- > 0;) {
|
||||
s += d.recordRoute[i];
|
||||
if (i != 0) s += ", ";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// INVITE with an AMR-WB SDP offer (passed in, built by Imsd:Sdp). MMTEL
|
||||
// ICSI feature tags + sec-agree + session timers, exactly as imsd._send_invite.
|
||||
std::string BuildInvite(const Context& c, const Dialog& d, std::string_view sdp, bool precond) {
|
||||
std::string supported = precond ? "100rel, timer, precondition"
|
||||
: "100rel, timer";
|
||||
std::vector<std::string> l = {
|
||||
std::format("INVITE {} SIP/2.0", d.ruri),
|
||||
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", c.route),
|
||||
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
|
||||
std::format("To: <{}>", d.ruri),
|
||||
std::format("Call-ID: {}", d.callid),
|
||||
"CSeq: 1 INVITE",
|
||||
std::format("Contact: <sip:{}@{}>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)),
|
||||
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER",
|
||||
std::format("Supported: {}", supported),
|
||||
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"",
|
||||
};
|
||||
// see BuildSubscribeReg: P-Preferred-Identity only when known
|
||||
if (!c.ppi.empty()) l.push_back(std::format("P-Preferred-Identity: <{}>", c.ppi));
|
||||
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
|
||||
l.push_back(std::format("Security-Verify: {}", c.securityServer));
|
||||
l.push_back("Proxy-Require: sec-agree");
|
||||
l.push_back("Require: sec-agree");
|
||||
l.push_back("Session-Expires: 1800;refresher=uac");
|
||||
l.push_back("Min-SE: 90");
|
||||
l.push_back("Content-Type: application/sdp");
|
||||
l.push_back(std::format("Content-Length: {}", sdp.size()));
|
||||
l.push_back("");
|
||||
return detail::Join(l) + std::string(sdp);
|
||||
}
|
||||
|
||||
// An in-dialog request (PRACK, BYE, UPDATE...) over the dialog route set.
|
||||
// `extra` lines go after CSeq; an optional body sets Content-Type/Length.
|
||||
std::string BuildInDialog(const Context& c, const Dialog& d, std::string_view method, int cseq, std::string_view viaBranch, std::span<const std::string> extra = {}, std::string_view body = {}, std::string_view ctype = {}) {
|
||||
std::string target = d.remoteTarget.value_or(d.ruri);
|
||||
std::string to = d.dialogTo.value_or(std::format("<{}>", d.ruri));
|
||||
std::vector<std::string> l = {
|
||||
std::format("{} {} SIP/2.0", method, target),
|
||||
ViaLine(c, "TCP", c.portUs, viaBranch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", DialogRoute(c, d)),
|
||||
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
|
||||
std::format("To: {}", to),
|
||||
std::format("Call-ID: {}", d.callid),
|
||||
std::format("CSeq: {} {}", cseq, method),
|
||||
};
|
||||
for (const std::string& e : extra)
|
||||
l.push_back(e);
|
||||
if (!body.empty()) {
|
||||
l.push_back(std::format("Content-Type: {}", ctype));
|
||||
l.push_back(std::format("Content-Length: {}", body.size()));
|
||||
l.push_back("");
|
||||
return detail::Join(l) + std::string(body);
|
||||
}
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// ACK for a 2xx final: a new transaction over the dialog route to the
|
||||
// remote target, echoing the dialog To (with the remote tag).
|
||||
std::string BuildAck2xx(const Context& c, const Dialog& d, std::string_view viaBranch, std::string_view toHeader) {
|
||||
std::string target = d.remoteTarget.value_or(d.ruri);
|
||||
std::string to = d.dialogTo.value_or(std::string(toHeader));
|
||||
std::vector<std::string> l = {
|
||||
std::format("ACK {} SIP/2.0", target),
|
||||
ViaLine(c, "TCP", c.portUs, viaBranch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", DialogRoute(c, d)),
|
||||
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
|
||||
std::format("To: {}", to),
|
||||
std::format("Call-ID: {}", d.callid),
|
||||
"CSeq: 1 ACK",
|
||||
"Content-Length: 0",
|
||||
};
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// ACK for a non-2xx final: hop-by-hop over the INVITE's own branch and the
|
||||
// registration Route, echoing the response's To.
|
||||
std::string BuildAckNon2xx(const Context& c, const Dialog& d, std::string_view toHeader) {
|
||||
std::vector<std::string> l = {
|
||||
std::format("ACK {} SIP/2.0", d.ruri),
|
||||
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", c.route),
|
||||
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
|
||||
std::format("To: {}", toHeader),
|
||||
std::format("Call-ID: {}", d.callid),
|
||||
"CSeq: 1 ACK",
|
||||
"Content-Length: 0",
|
||||
};
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// CANCEL: RFC 3261 requires it copy the INVITE's branch + CSeq number.
|
||||
std::string BuildCancel(const Context& c, const Dialog& d) {
|
||||
std::vector<std::string> l = {
|
||||
std::format("CANCEL {} SIP/2.0", d.ruri),
|
||||
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
|
||||
"Max-Forwards: 70",
|
||||
std::format("Route: {}", c.route),
|
||||
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
|
||||
std::format("To: <{}>", d.ruri),
|
||||
std::format("Call-ID: {}", d.callid),
|
||||
"CSeq: 1 CANCEL",
|
||||
"Content-Length: 0",
|
||||
};
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// A response to an inbound INVITE — the UAS side of call setup. Unlike
|
||||
// BuildResponse200 (first Via only, fine for hop-by-hop CANCEL/BYE
|
||||
// answers), an INVITE response travels the whole proxy chain back to the
|
||||
// caller: it must echo EVERY Via in order, return the Record-Route set on
|
||||
// dialog-establishing (1xx/2xx) responses, and mint the dialog's local
|
||||
// half by tagging To (all but 100 Trying, which is hop-by-hop and
|
||||
// tagless). 1xx/2xx carry our Contact (the dialog's remote target for the
|
||||
// caller) and Allow; `extra` appends headers (RSeq, Session-Expires...);
|
||||
// `sdp` turns a 200 into the answer.
|
||||
std::string BuildInviteResponse(const Context& c, std::string_view invite, int code, std::string_view text, std::string_view toTag, std::optional<std::string_view> sdp = std::nullopt, std::span<const std::string> extra = {}) {
|
||||
std::vector<std::string> l = {std::format("SIP/2.0 {} {}", code, text)};
|
||||
for (auto v : imsd::sip::Headers(invite, "Via"))
|
||||
l.push_back(std::format("Via: {}", v));
|
||||
if (code < 300)
|
||||
for (auto v : imsd::sip::Headers(invite, "Record-Route"))
|
||||
l.push_back(std::format("Record-Route: {}", v));
|
||||
if (auto f = imsd::sip::Header(invite, "From")) l.push_back(std::format("From: {}", *f));
|
||||
if (auto t = imsd::sip::Header(invite, "To")) {
|
||||
if (code == 100 || t->contains("tag=")) l.push_back(std::format("To: {}", *t));
|
||||
else
|
||||
l.push_back(std::format("To: {};tag={}", *t, toTag));
|
||||
}
|
||||
if (auto ci = imsd::sip::Header(invite, "Call-ID")) l.push_back(std::format("Call-ID: {}", *ci));
|
||||
if (auto cs = imsd::sip::Header(invite, "CSeq")) l.push_back(std::format("CSeq: {}", *cs));
|
||||
if (code != 100 && code < 300) {
|
||||
l.push_back(std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)));
|
||||
l.push_back("Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER");
|
||||
}
|
||||
for (const std::string& e : extra)
|
||||
l.push_back(e);
|
||||
if (sdp) {
|
||||
l.push_back("Content-Type: application/sdp");
|
||||
l.push_back(std::format("Content-Length: {}", sdp->size()));
|
||||
l.push_back("");
|
||||
return detail::Join(l) + std::string(*sdp);
|
||||
}
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
|
||||
// A 200 OK for an inbound request: echo Via/From/To/Call-ID/CSeq in order,
|
||||
// then either an SDP answer (with our Contact) or an empty body.
|
||||
// `extra` appends headers (Session-Expires echo for refreshes...).
|
||||
std::string BuildResponse200(const Context& c, std::string_view request, std::optional<std::string_view> sdp = std::nullopt, std::span<const std::string> extra = {}) {
|
||||
std::vector<std::string> l = {"SIP/2.0 200 OK"};
|
||||
for (std::string_view h : {"Via", "From", "To", "Call-ID", "CSeq"})
|
||||
if (auto v = imsd::sip::Header(request, h)) l.push_back(std::format("{}: {}", h, *v));
|
||||
for (const auto& e : extra)
|
||||
l.push_back(e);
|
||||
if (sdp) {
|
||||
l.push_back(std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)));
|
||||
l.push_back("Content-Type: application/sdp");
|
||||
l.push_back(std::format("Content-Length: {}", sdp->size()));
|
||||
l.push_back("");
|
||||
return detail::Join(l) + std::string(*sdp);
|
||||
}
|
||||
l.push_back("Content-Length: 0");
|
||||
return std::format("{}\r\n", detail::Join(l));
|
||||
}
|
||||
}
|
||||
227
interfaces/Imsd-Sdp.cppm
Normal file
227
interfaces/Imsd-Sdp.cppm
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Sdp — AMR-WB SDP offer/answer building + parsing.
|
||||
|
||||
The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
|
||||
optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
|
||||
expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The
|
||||
parser extracts only what the media plane needs: remote address/port, the
|
||||
negotiated payload type, and octet-align mode — it reads answers to our
|
||||
offers and inbound offers alike (same fields either way). BuildAnswer is the
|
||||
terminating side: it answers an inbound offer with the offer's own payload
|
||||
type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires
|
||||
both directions to use one mode). Pure std C++.
|
||||
*/
|
||||
|
||||
export module Imsd:Sdp;
|
||||
import std;
|
||||
|
||||
namespace imsd::sdp {
|
||||
|
||||
bool Is6(std::string_view addr) { return addr.contains(':'); }
|
||||
|
||||
// First token (up to whitespace/CR/LF) starting at `pos`.
|
||||
std::string_view TokenAt(std::string_view body, std::size_t pos) {
|
||||
std::size_t end = body.find_first_of(" \t\r\n", pos);
|
||||
return body.substr(pos, end == std::string_view::npos ? std::string_view::npos : end - pos);
|
||||
}
|
||||
|
||||
std::optional<int> ParseInt(std::string_view s) {
|
||||
int v = 0;
|
||||
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
|
||||
if (ec != std::errc{} || p != s.data() + s.size()) return std::nullopt;
|
||||
return v;
|
||||
}
|
||||
|
||||
// First occurrence of `needle` anywhere in the body; nullopt if absent.
|
||||
std::optional<std::size_t> Find(std::string_view body, std::string_view needle) {
|
||||
std::size_t at = body.find(needle);
|
||||
if (at == std::string_view::npos) return std::nullopt;
|
||||
return at;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace imsd::sdp {
|
||||
|
||||
struct Offer {
|
||||
std::string local; // connection address (v4 or v6)
|
||||
int rtpPort = 0;
|
||||
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
|
||||
bool precond = false; // IR.92 QoS preconditions block
|
||||
std::string currRemote = "none"; // a=curr:qos remote <this>
|
||||
};
|
||||
|
||||
// AMR-WB (octet-aligned) offer. Byte layout is pinned by tests/Sdp.
|
||||
std::string BuildOffer(const Offer& o) {
|
||||
std::string ipver = Is6(o.local) ? "IP6" : "IP4";
|
||||
std::string s;
|
||||
s += "v=0\r\n";
|
||||
s += std::format("o=- {} {} IN {} {}\r\n", o.sessionId, o.sessionId, ipver, o.local);
|
||||
s += "s=-\r\n";
|
||||
s += std::format("c=IN {} {}\r\n", ipver, o.local);
|
||||
s += "t=0 0\r\n";
|
||||
s += std::format("m=audio {} RTP/AVP 97 98\r\n", o.rtpPort);
|
||||
s += "b=AS:41\r\n";
|
||||
s += "b=RS:512\r\n";
|
||||
s += "b=RR:1536\r\n";
|
||||
s += "a=rtpmap:97 AMR-WB/16000/1\r\n";
|
||||
s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n";
|
||||
s += "a=rtpmap:98 telephone-event/16000\r\n";
|
||||
s += "a=fmtp:98 0-15\r\n";
|
||||
s += "a=ptime:20\r\n";
|
||||
s += "a=maxptime:240\r\n";
|
||||
if (o.precond) {
|
||||
s += "a=curr:qos local sendrecv\r\n";
|
||||
s += std::format("a=curr:qos remote {}\r\n", o.currRemote);
|
||||
s += "a=des:qos mandatory local sendrecv\r\n";
|
||||
s += "a=des:qos mandatory remote sendrecv\r\n";
|
||||
}
|
||||
s += "a=sendrecv\r\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
struct Answer {
|
||||
std::string ip; // empty = no c= line found
|
||||
int port = -1; // -1 = no m=audio line found
|
||||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB";
|
||||
};
|
||||
|
||||
// Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with
|
||||
// octet-align=1 > AMR. Octet-aligned wins within a codec because it is
|
||||
// the media leg's native, call-proven format (bandwidth-efficient is
|
||||
// supported but was the s56 static-audio culprit) — when the peer offers
|
||||
// both variants, taking the octet-aligned pt keeps real calls on the
|
||||
// battle-tested path. With no rtpmap match at all, the first pt is used
|
||||
// and codec comes back EMPTY — the body named no AMR variant (e.g. a
|
||||
// PCMA offer), and pretending otherwise would defeat codec checks
|
||||
// upstream.
|
||||
Answer ParseAnswer(std::string_view body) {
|
||||
Answer a;
|
||||
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
|
||||
else if (auto at4 = Find(body, "c=IN IP4 ")) a.ip = TokenAt(body, *at4 + 9);
|
||||
|
||||
auto m = Find(body, "m=audio ");
|
||||
if (!m) return a;
|
||||
std::string_view portTok = TokenAt(body, *m + 8);
|
||||
auto port = ParseInt(portTok);
|
||||
// both the port and the " RTP/AVP " literal are required
|
||||
std::size_t afterPort = *m + 8 + portTok.size();
|
||||
constexpr std::string_view Avp = " RTP/AVP ";
|
||||
if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
|
||||
a.port = *port;
|
||||
|
||||
// payload-type list: digits + spaces up to CR/LF
|
||||
std::size_t ptsBegin = afterPort + Avp.size();
|
||||
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
|
||||
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
|
||||
std::vector<int> pts;
|
||||
for (auto part : std::views::split(ptsRun, ' '))
|
||||
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
|
||||
if (pts.empty()) return a;
|
||||
|
||||
auto hasOctetAlign = [&](int p) {
|
||||
auto fm = Find(body, std::format("a=fmtp:{} ", p));
|
||||
if (!fm) return false;
|
||||
std::size_t lineEnd = body.find_first_of("\r\n", *fm);
|
||||
std::string_view line = body.substr(*fm, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *fm);
|
||||
return line.contains("octet-align=1");
|
||||
};
|
||||
int wbOa = -1;
|
||||
int wb = -1;
|
||||
int nbOa = -1;
|
||||
int nb = -1;
|
||||
for (int p : pts) {
|
||||
std::string key = std::format("a=rtpmap:{} ", p);
|
||||
auto at = Find(body, key);
|
||||
if (!at) continue;
|
||||
// codec name: up to '/', CR or LF
|
||||
std::size_t nameBegin = *at + key.size();
|
||||
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
|
||||
std::string_view name = body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin);
|
||||
if (name.contains("AMR-WB")) {
|
||||
if (wb == -1) wb = p;
|
||||
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p;
|
||||
} else if (name.contains("AMR")) {
|
||||
if (nb == -1) nb = p;
|
||||
if (nbOa == -1 && hasOctetAlign(p)) nbOa = p;
|
||||
}
|
||||
}
|
||||
if (wbOa != -1 || wb != -1) {
|
||||
a.payloadType = wbOa != -1 ? wbOa : wb;
|
||||
a.codec = "AMR-WB";
|
||||
} else if (nbOa != -1 || nb != -1) {
|
||||
a.payloadType = nbOa != -1 ? nbOa : nb;
|
||||
a.codec = "AMR";
|
||||
} else {
|
||||
a.payloadType = pts.front();
|
||||
a.codec.clear();
|
||||
}
|
||||
a.octetAlign = hasOctetAlign(a.payloadType);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
|
||||
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
|
||||
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
|
||||
std::string needle = std::format("telephone-event/{}", clockRate);
|
||||
std::size_t pos = 0;
|
||||
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
|
||||
std::size_t ptBegin = pos + 9;
|
||||
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
|
||||
std::size_t sp = body.find(' ', ptBegin);
|
||||
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
|
||||
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
|
||||
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
|
||||
if (pt && name.starts_with(needle)) return pt;
|
||||
}
|
||||
pos = ptBegin;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
struct AnswerSpec {
|
||||
std::string local; // connection address (v4 or v6)
|
||||
int rtpPort = 0;
|
||||
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
|
||||
int payloadType = 97; // the offer's pt for the chosen codec
|
||||
bool octetAlign = true; // mirror of the offer's mode
|
||||
std::string codec = "AMR-WB"; // "AMR-WB" or "AMR"
|
||||
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
|
||||
};
|
||||
|
||||
// SDP answer to an inbound audio offer: single codec at the offer's own
|
||||
// payload type, octet-align echoed only when the offer used it (absence
|
||||
// = bandwidth-efficient, RFC 4867 §8.1), telephone-event kept when
|
||||
// offered. Byte layout pinned by tests/Sdp.
|
||||
std::string BuildAnswer(const AnswerSpec& s) {
|
||||
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
||||
int rate = s.codec == "AMR-WB" ? 16000 : 8000;
|
||||
std::string out;
|
||||
out += "v=0\r\n";
|
||||
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local);
|
||||
out += "s=-\r\n";
|
||||
out += std::format("c=IN {} {}\r\n", ipver, s.local);
|
||||
out += "t=0 0\r\n";
|
||||
if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
|
||||
else
|
||||
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType);
|
||||
out += std::format("b=AS:{}\r\n", s.codec == "AMR-WB" ? 41 : 30);
|
||||
out += "b=RS:512\r\n";
|
||||
out += "b=RR:1536\r\n";
|
||||
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate);
|
||||
if (s.octetAlign) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
|
||||
if (s.dtmfPt) {
|
||||
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
|
||||
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);
|
||||
}
|
||||
out += "a=ptime:20\r\n";
|
||||
out += "a=maxptime:240\r\n";
|
||||
out += "a=sendrecv\r\n";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
236
interfaces/Imsd-Sip.cppm
Normal file
236
interfaces/Imsd-Sip.cppm
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Sip — SIP message text layer + TCP framing.
|
||||
|
||||
Deliberately a text layer, not a full RFC 3261 parser: the engine works on
|
||||
whole messages as strings and pulls out exactly what it needs (headers,
|
||||
status codes, the granted registration lifetime), which keeps every message
|
||||
loggable and testable verbatim. Edge-case behavior (empty header values,
|
||||
line anchoring, keepalive discards) is pinned by tests/Sip — a change that
|
||||
flips one of those tests is a behavior change, not a cleanup.
|
||||
|
||||
Pure std C++ (no GLib).
|
||||
*/
|
||||
|
||||
export module Imsd:Sip;
|
||||
import std;
|
||||
|
||||
namespace imsd::sip {
|
||||
|
||||
constexpr char AsciiLower(char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
|
||||
}
|
||||
|
||||
constexpr bool IEqualsPrefix(std::string_view text, std::string_view prefix) {
|
||||
if (text.size() < prefix.size()) return false;
|
||||
for (std::size_t i = 0; i < prefix.size(); i++)
|
||||
if (AsciiLower(text[i]) != AsciiLower(prefix[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// spaces, tabs, CR, LF off both ends
|
||||
constexpr std::string_view Trim(std::string_view s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r' || s.front() == '\n'))
|
||||
s.remove_prefix(1);
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n'))
|
||||
s.remove_suffix(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Iterate \n-separated lines (any trailing CR stays on the line and is
|
||||
// stripped by Trim when a value is extracted).
|
||||
template <typename F>
|
||||
void ForEachLine(std::string_view msg, F&& f) {
|
||||
std::size_t pos = 0;
|
||||
while (pos <= msg.size()) {
|
||||
std::size_t nl = msg.find('\n', pos);
|
||||
std::string_view line = msg.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
|
||||
if (!f(line)) return;
|
||||
if (nl == std::string_view::npos) return;
|
||||
pos = nl + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// One "Name: value" match: name anchored at line start (case-insensitive,
|
||||
// per RFC 3261 header names), immediately followed by the colon — no
|
||||
// whitespace before it — and at least one character (even just the CR)
|
||||
// after it. A bare "Name:\r" line matches with value ""; a "Name:" that
|
||||
// ends the buffer does not match. Matching never crosses the line
|
||||
// boundary: an empty-valued header yields "", NOT the next line's
|
||||
// content (a regex-based implementation whose whitespace class eats the
|
||||
// CRLF gets that wrong — pinned by the "bare header" test).
|
||||
std::optional<std::string_view> MatchHeaderLine(std::string_view line, std::string_view name) {
|
||||
if (!IEqualsPrefix(line, name)) return std::nullopt;
|
||||
if (line.size() <= name.size() || line[name.size()] != ':') return std::nullopt;
|
||||
std::string_view rest = line.substr(name.size() + 1);
|
||||
if (rest.empty()) return std::nullopt;
|
||||
return Trim(rest);
|
||||
}
|
||||
|
||||
// First run of ASCII digits starting at `s`; nullopt if none.
|
||||
std::optional<long> LeadingDigits(std::string_view s) {
|
||||
std::size_t n = 0;
|
||||
while (n < s.size() && s[n] >= '0' && s[n] <= '9')
|
||||
n++;
|
||||
if (n == 0) return std::nullopt;
|
||||
long v = 0;
|
||||
auto [_, ec] = std::from_chars(s.data(), s.data() + n, v);
|
||||
if (ec != std::errc{}) return std::nullopt;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace imsd::sip {
|
||||
|
||||
// Value of the first `name:` header, trimmed; nullopt if absent.
|
||||
std::optional<std::string_view> Header(std::string_view msg, std::string_view name) {
|
||||
std::optional<std::string_view> out;
|
||||
ForEachLine(msg, [&](std::string_view line) {
|
||||
if (auto v = MatchHeaderLine(line, name)) {
|
||||
out = *v;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every `name:` header value, in order.
|
||||
std::vector<std::string_view> Headers(std::string_view msg, std::string_view name) {
|
||||
std::vector<std::string_view> out;
|
||||
ForEachLine(msg, [&](std::string_view line) {
|
||||
if (auto v = MatchHeaderLine(line, name)) out.push_back(*v);
|
||||
return true;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// The 3-digit code of a "SIP/2.0 NNN ..." response start-line; nullopt
|
||||
// for requests / anything else. Only matches at the start of the message.
|
||||
std::optional<int> Status(std::string_view msg) {
|
||||
constexpr std::string_view Prefix = "SIP/2.0 ";
|
||||
if (!msg.starts_with(Prefix) || msg.size() < Prefix.size() + 3) return std::nullopt;
|
||||
std::string_view d = msg.substr(Prefix.size(), 3);
|
||||
for (char c : d)
|
||||
if (c < '0' || c > '9') return std::nullopt;
|
||||
int v = 0;
|
||||
std::from_chars(d.data(), d.data() + 3, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
// Where a UDP response to this request must go (RFC 3261 18.2.2): the
|
||||
// top Via's sent-by port — unless the Via carries rport, in which case
|
||||
// the caller should reply to the request's source port instead
|
||||
// (nullopt). Also nullopt when the Via has no explicit port (5060 is
|
||||
// the default, but our P-CSCF flows always carry one). Handles
|
||||
// "[v6]:port" and "host:port" sent-by forms.
|
||||
std::optional<int> ViaResponsePort(std::string_view msg) {
|
||||
auto via = Header(msg, "Via");
|
||||
if (!via) return std::nullopt;
|
||||
std::string_view v = *via;
|
||||
if (std::size_t sc = v.find(';'); sc != std::string_view::npos) {
|
||||
if (v.substr(sc).find("rport") != std::string_view::npos) return std::nullopt;
|
||||
v = v.substr(0, sc);
|
||||
}
|
||||
std::size_t colon;
|
||||
if (std::size_t br = v.rfind(']'); br != std::string_view::npos)
|
||||
colon = v.find(':', br); // v6: port only after the ]
|
||||
else
|
||||
colon = v.rfind(':'); // v4/hostname (transport token
|
||||
// has no colon of its own)
|
||||
if (colon == std::string_view::npos) return std::nullopt;
|
||||
if (auto p = LeadingDigits(Trim(v.substr(colon + 1))))
|
||||
if (*p > 0 && *p < 65536) return static_cast<int>(*p);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Registration lifetime granted by a REGISTER 200 OK: the Contact
|
||||
// header's expires= parameter (the registrar's answer for our binding),
|
||||
// else the Expires header; nullopt if absent. "expires=" is searched
|
||||
// anywhere in each Contact value, not parameter-parsed.
|
||||
std::optional<long> GrantedExpires(std::string_view msg) {
|
||||
for (std::string_view contact : Headers(msg, "Contact")) {
|
||||
std::size_t at = contact.find("expires=");
|
||||
if (at != std::string_view::npos)
|
||||
if (auto v = LeadingDigits(contact.substr(at + 8))) return v;
|
||||
}
|
||||
if (auto e = Header(msg, "Expires")) {
|
||||
std::string_view v = *e;
|
||||
if (!v.empty() && v.find_first_not_of("0123456789") == std::string_view::npos) return LeadingDigits(v);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Caller identity of an inbound request: the number/user of the first
|
||||
// P-Asserted-Identity (the network-asserted identity, RFC 3325), falling
|
||||
// back to From. Understands "<tel:+31612345678>", "<sip:+31612345678@
|
||||
// host;user=phone>" (user part) and bare values; returns "" if neither
|
||||
// header yields one. Anonymous callers come out as "anonymous".
|
||||
std::string CallerId(std::string_view msg) {
|
||||
auto extract = [](std::string_view v) -> std::optional<std::string> {
|
||||
std::string_view uri = v;
|
||||
std::size_t lt = v.find('<');
|
||||
if (lt != std::string_view::npos) {
|
||||
std::size_t gt = v.find('>', lt);
|
||||
if (gt != std::string_view::npos) uri = v.substr(lt + 1, gt - lt - 1);
|
||||
} else {
|
||||
std::size_t semi = v.find(';');
|
||||
if (semi != std::string_view::npos) uri = v.substr(0, semi);
|
||||
uri = Trim(uri);
|
||||
}
|
||||
if (uri.starts_with("tel:")) {
|
||||
std::string_view n = uri.substr(4);
|
||||
std::size_t semi = n.find(';');
|
||||
if (semi != std::string_view::npos) n = n.substr(0, semi);
|
||||
return std::string(n);
|
||||
}
|
||||
if (uri.starts_with("sip:") || uri.starts_with("sips:")) {
|
||||
std::string_view n = uri.substr(uri.find(':') + 1);
|
||||
std::size_t end = n.find_first_of("@;");
|
||||
if (end == std::string_view::npos) return std::nullopt;
|
||||
return std::string(n.substr(0, end));
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
for (std::string_view h : Headers(msg, "P-Asserted-Identity"))
|
||||
if (auto n = extract(h)) return *n;
|
||||
if (auto f = Header(msg, "From"))
|
||||
if (auto n = extract(*f)) return *n;
|
||||
return "";
|
||||
}
|
||||
|
||||
// SIP-over-TCP stream framing, transport-free: feed received bytes into
|
||||
// Append(), pull complete messages (headers + Content-Length body) out
|
||||
// of TryExtract(). Leading CRLFs (RFC 5626 keepalive pongs) are
|
||||
// discarded.
|
||||
class FrameBuffer {
|
||||
public:
|
||||
void Append(std::string_view data) { buf_.append(data); }
|
||||
|
||||
// One complete SIP message off the front of the buffer, or nullopt
|
||||
// if more bytes are needed. Content-Length is located with the same
|
||||
// line-anchored matching rules as Header().
|
||||
std::optional<std::string> TryExtract() {
|
||||
std::size_t keep = buf_.find_first_not_of("\r\n");
|
||||
buf_.erase(0, keep == std::string::npos ? buf_.size() : keep);
|
||||
std::size_t idx = buf_.find("\r\n\r\n");
|
||||
if (idx == std::string::npos) return std::nullopt;
|
||||
std::string_view head = std::string_view(buf_).substr(0, idx);
|
||||
long clen = 0;
|
||||
if (auto v = Header(head, "Content-Length")) clen = LeadingDigits(*v).value_or(0);
|
||||
std::size_t total = idx + 4 + static_cast<std::size_t>(clen);
|
||||
if (buf_.size() < total) return std::nullopt;
|
||||
std::string msg = buf_.substr(0, total);
|
||||
buf_.erase(0, total);
|
||||
return msg;
|
||||
}
|
||||
|
||||
std::size_t Size() const { return buf_.size(); }
|
||||
|
||||
private:
|
||||
std::string buf_;
|
||||
};
|
||||
}
|
||||
261
interfaces/Imsd-Util.cppm
Normal file
261
interfaces/Imsd-Util.cppm
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// 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, 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
15
interfaces/Imsd.cppm
Normal file
15
interfaces/Imsd.cppm
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
/*
|
||||
imsd — userspace IMS/VoLTE daemon for mainline Linux phones.
|
||||
*/
|
||||
|
||||
export module Imsd;
|
||||
export import :Util;
|
||||
export import :Aka;
|
||||
export import :Ipsec;
|
||||
export import :Sip;
|
||||
export import :Sdp;
|
||||
export import :Messages;
|
||||
export import :Engine;
|
||||
Loading…
Add table
Add a link
Reference in a new issue