// 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/ [local]:;branch=z9hG4bK;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 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 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: {};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 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: {};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 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: ", 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 dialogTo; // remote To/From incl. tag, once known std::vector recordRoute; // route set, UAC order (see DialogRoute) std::optional 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 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: ;+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 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 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 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 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 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 sdp = std::nullopt, std::span extra = {}) { std::vector 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: ", 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 sdp = std::nullopt, std::span extra = {}) { std::vector 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: ", 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)); } }