imsd/interfaces/Imsd-Messages.cppm

457 lines
24 KiB
Text
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd: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));
}
}