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:
Jorijn van der Graaf 2026-07-22 22:53:28 +02:00
commit 6e77823933
31 changed files with 8761 additions and 0 deletions

297
interfaces/Imsd-Aka.cppm Normal file
View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View 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;