// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types // Imsd:Messages unit tests — every builder compared byte-for-byte against a // reference message cross-generated from the Python VoLTE stack with pinned // tags/branches/SPIs. A divergence here is a wire-format change, i.e. a // registration or call that the commercial IMS core may reject; these are the // hardest pins in the project. Two deliberate post-parity divergences: // the REGISTER Contact carries +sip.instance and the MMTEL ICSI feature tag // (the Python stack registered without them, which made the network divert // incoming calls to voicemail — see ContactFeatures in Imsd:Messages), and // every ICSI feature-tag value is percent-encoded (urn%3Aurn-7%3A...) — the // TS 24.229 coding and what the stock modem sends on the wire; the Python // stack's plain-colon form is a different string under RFC 3841 matching // and left terminating access-domain selection routing incoming calls to // CS fallback (observed live 2026-07-20). import std; import Imsd; using namespace imsd::msg; namespace { int Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; } } void Eq(std::string_view got, std::string_view want, std::string_view msg) { if (got != want) { std::println(std::cerr, "FAIL: {}\n--- got ---\n{}\n--- want ---\n{}", msg, got, want); ++Failures; } } Context MakeCtx() { Context c; c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01"); c.local = "2001:db8::db43"; c.route = ""; c.ppi = "tel:+31611111111"; c.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000"; c.instanceId = "urn:gsma:imei:35999999-000000-1"; c.panInfo = "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0"; return c; } constexpr std::string_view Reg1 = "REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n" "Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKREGBRANCH123456;rport\r\n" "Max-Forwards: 70\r\n" "From: ;tag=FTAG5678\r\n" "To: \r\n" "Call-ID: REGCALLID@2001:db8::db43\r\n" "CSeq: 1 REGISTER\r\n" "Contact: ;+sip.instance=\"\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n" "Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n" "Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"\", response=\"\"\r\n" "Require: sec-agree\r\n" "Proxy-Require: sec-agree\r\n" "Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n" "Supported: sec-agree, path\r\n" "Expires: 600000\r\n" "P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n" "Content-Length: 0\r\n" "\r\n"; constexpr std::string_view Reg2 = "REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n" "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n" "Max-Forwards: 70\r\n" "From: ;tag=FTAG5678\r\n" "To: \r\n" "Call-ID: REGCALLID@2001:db8::db43\r\n" "CSeq: 2 REGISTER\r\n" "Contact: ;+sip.instance=\"\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n" "Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n" "Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"NONCEXYZ\", qop=auth, nc=00000001, cnonce=\"CNONCE0123456789\", response=\"RESP0000\", algorithm=AKAv1-MD5\r\n" "Require: sec-agree\r\n" "Proxy-Require: sec-agree\r\n" "Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n" "Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n" "Supported: sec-agree, path\r\n" "Expires: 600000\r\n" "P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n" "Content-Length: 0\r\n" "\r\n"; constexpr std::string_view Sdp = "v=0\r\n" "o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n" "s=-\r\n" "c=IN IP6 2001:db8::db43\r\n" "t=0 0\r\n" "m=audio 50004 RTP/AVP 97 98\r\n" "b=AS:41\r\n" "b=RS:512\r\n" "b=RR:1536\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n" "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n" "a=rtpmap:98 telephone-event/16000\r\n" "a=fmtp:98 0-15\r\n" "a=ptime:20\r\n" "a=maxptime:240\r\n" "a=sendrecv\r\n"; constexpr std::string_view Invite = "INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n" "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n" "Max-Forwards: 70\r\n" "Route: \r\n" "From: ;tag=ITAG123456\r\n" "To: \r\n" "Call-ID: CALLID789@2001:db8::db43\r\n" "CSeq: 1 INVITE\r\n" "Contact: ;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n" "Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER\r\n" "Supported: 100rel, timer\r\n" "Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n" "P-Preferred-Identity: \r\n" "P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n" "Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n" "Proxy-Require: sec-agree\r\n" "Require: sec-agree\r\n" "Session-Expires: 1800;refresher=uac\r\n" "Min-SE: 90\r\n" "Content-Type: application/sdp\r\n" "Content-Length: 323\r\n" "\r\n" "v=0\r\n" "o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n" "s=-\r\n" "c=IN IP6 2001:db8::db43\r\n" "t=0 0\r\n" "m=audio 50004 RTP/AVP 97 98\r\n" "b=AS:41\r\n" "b=RS:512\r\n" "b=RR:1536\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n" "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n" "a=rtpmap:98 telephone-event/16000\r\n" "a=fmtp:98 0-15\r\n" "a=ptime:20\r\n" "a=maxptime:240\r\n" "a=sendrecv\r\n"; constexpr std::string_view Subscribe = "SUBSCRIBE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n" "Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKSUBBRANCH0000000000;rport\r\n" "Max-Forwards: 70\r\n" "Route: \r\n" "From: ;tag=STAG1234\r\n" "To: \r\n" "Call-ID: SUBCALLID@2001:db8::db43\r\n" "CSeq: 1 SUBSCRIBE\r\n" "Contact: \r\n" "Event: reg\r\n" "Accept: application/reginfo+xml\r\n" "Expires: 600000\r\n" "P-Preferred-Identity: \r\n" "P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n" "Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n" "Proxy-Require: sec-agree\r\n" "Require: sec-agree\r\n" "Content-Length: 0\r\n" "\r\n"; } int main() { Context c = MakeCtx(); // ---- REGISTER (initial + protected), byte-exact vs Python Eq(BuildRegisterInitial(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 7449812u, 176466735u), Reg1, "unprotected REGISTER"); { std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000"); Eq(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 2, 7449812u, 176466735u, auth), Reg2, "protected REGISTER"); } // ---- RuriFor variants Eq(RuriFor("1233", c.id.domain), "sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri short code"); Eq(RuriFor("+31612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri E.164"); Eq(RuriFor("0612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri Dutch national"); Eq(RuriFor("0031612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri 00 intl"); // ---- SUBSCRIBE (reg event, RFC 3680) — no Python counterpart; pinned // against the shape of the other protected in-flow requests Eq(BuildSubscribeReg(c, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"), Subscribe, "reg-event SUBSCRIBE (no aor: temporary IMPU fallback)"); { Context c2 = MakeCtx(); c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org"; std::string sub = BuildSubscribeReg(c2, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"); Check(sub.starts_with("SUBSCRIBE sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"), "reg-event SUBSCRIBE aor R-URI"); Check(sub.contains("\r\nFrom: ;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From"); Check(sub.contains("\r\nTo: \r\n"), "reg-event SUBSCRIBE aor To"); } { // no identity learned yet: P-Preferred-Identity is omitted, not "<>" Context c3 = MakeCtx(); c3.ppi.clear(); Check(!BuildSubscribeReg(c3, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000") .contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in SUBSCRIBE"); Dialog d; d.ruri = RuriFor("1233", c3.id.domain); d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib"; Check(!BuildInvite(c3, d, "v=0\r\n", false).contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in INVITE"); } // ---- INVITE, byte-exact vs Python { Dialog d; d.ruri = RuriFor("1233", c.id.domain); d.callid = "CALLID789@2001:db8::db43"; d.itag = "ITAG123456"; d.invBranch = "INVBRANCH0000000000"; Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer"); } // ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks) { Dialog d; d.ruri = RuriFor("1233", c.id.domain); d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib"; d.dialogTo = ";tag=remote99"; d.remoteTarget = "sip:termgw@2001:db8::9"; d.recordRoute = {"", ""}; // dialog route = reversed record-route Check(DialogRoute(c, d) == ", ", "dialog route reversed"); std::array rack = {"RAck: 42 1 INVITE"}; std::string prack = BuildInDialog(c, d, "PRACK", 2, "prbranch", rack); Check(prack.starts_with("PRACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "PRACK request-URI = remote target"); Check(prack.contains("Route: , \r\n"), "PRACK dialog route"); Check(prack.contains("To: ;tag=remote99\r\n"), "PRACK To has remote tag"); Check(prack.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq"); Check(prack.contains("RAck: 42 1 INVITE\r\n"), "PRACK RAck"); Check(prack.ends_with("Content-Length: 0\r\n\r\n"), "PRACK empty body"); std::string bye = BuildInDialog(c, d, "BYE", 3, "byebranch"); Check(bye.starts_with("BYE sip:termgw@2001:db8::9 SIP/2.0\r\n"), "BYE line"); Check(bye.contains("CSeq: 3 BYE\r\n"), "BYE CSeq"); std::string ack = BuildAck2xx(c, d, "ackbranch", ""); Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target"); Check(ack.contains("To: ;tag=remote99\r\n"), "ACK 2xx dialog To"); Check(ack.contains("CSeq: 1 ACK\r\n"), "ACK CSeq 1"); std::string cancel = BuildCancel(c, d); Check(cancel.starts_with(std::format("CANCEL {} SIP/2.0\r\n", d.ruri)), "CANCEL request-URI = ruri"); Check(cancel.contains("branch=z9hG4bKib;rport\r\n"), "CANCEL reuses INVITE branch"); Check(cancel.contains("CSeq: 1 CANCEL\r\n"), "CANCEL CSeq copies INVITE"); } { // non-2xx ACK uses the INVITE branch + registration route + resp To Dialog d; d.ruri = "sip:x@y"; d.callid = "CID"; d.itag = "it"; d.invBranch = "ib9"; std::string ack = BuildAckNon2xx(c, d, ";tag=z"); Check(ack.contains("branch=z9hG4bKib9;rport\r\n"), "non-2xx ACK INVITE branch"); Check(ack.contains(std::format("Route: {}\r\n", c.route)), "non-2xx ACK registration route"); Check(ack.contains("To: ;tag=z\r\n"), "non-2xx ACK echoes response To"); } // ---- 200 OK response echoes the request's dialog headers, in order { std::string req = "BYE sip:me@here SIP/2.0\r\n" "Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n" "From: ;tag=T\r\n" "To: ;tag=M\r\n" "Call-ID: xyz@far\r\n" "CSeq: 7 BYE\r\n" "Content-Length: 0\r\n\r\n"; std::string r = BuildResponse200(c, req); Check(r.starts_with("SIP/2.0 200 OK\r\n"), "200 status line"); Check(r.contains("Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"), "200 echoes Via"); Check(r.contains("Call-ID: xyz@far\r\n"), "200 echoes Call-ID"); Check(r.contains("CSeq: 7 BYE\r\n"), "200 echoes CSeq"); Check(r.ends_with("Content-Length: 0\r\n\r\n"), "200 empty body"); // with an SDP answer: our Contact + application/sdp appear std::string r2 = BuildResponse200(c, req, Sdp); Check(r2.contains("Content-Type: application/sdp\r\n"), "200 w/ SDP content-type"); Check(r2.contains("Contact: \r\n"), "200 w/ SDP includes our Contact"); Check(r2.ends_with(Sdp), "200 w/ SDP body"); } // ---- s53 oracle diff: UUID Contact user-part + User-Agent ------------ { Context c = MakeCtx(); c.contactUser = "029dd36c-857c-4270-ad7f-0ae70d31d50f"; c.userAgent = "Fairphone_Fairphone 6_FP6.QREL.16.82.0"; std::string r1 = BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2); Check(r1.contains("Contact: "), "reg1 Contact carries contactUser, not the IMSI"); Check(!r1.contains("sip:001010123456789@[2001:db8::db43]"), "reg1 has no IMSI-user Contact when contactUser set"); Check(r1.contains("From: "), "reg2 Contact carries contactUser at the protected port"); Check(r2.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg2 User-Agent emitted"); c.userAgent.clear(); Check(!BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2) .contains("User-Agent:"), "empty userAgent omits the header"); } if (Failures == 0) std::println("Messages: all tests passed"); return Failures; }