From 7978b94502a3c073e99af1040affa634644e351f Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 8 Sep 2026 20:37:47 +0200 Subject: [PATCH] messages: sign our requests with the public identity, not the IMSI IMPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every request we originated — INVITE, CANCEL, both ACKs, in-dialog BYE and friends — put the IMSI-derived temporary IMPU in From. 3GPP allows that identity in REGISTER only; the same lesson was learned for the reg-event SUBSCRIBE (480) and never carried to calls. Most P-CSCFs overwrite From and hid it; a Telia node did not, and a reporter's IMSI appeared on the callee's screen (field report 2026-09-01). A strict P-CSCF may reject the INVITE outright. CallerId(): the registered sip: public identity (P-Associated-URI), else the tel: one, and the temporary IMPU only before either is learned. One helper feeds all five builders, so a dialog's From never drifts. As UAS the dialog's local URI is the INVITE's To (RFC 3261 12.2.1.1), stored on the Dialog, so an incoming call's BYE is signed the way the network addressed us. The identity is persisted in the state file and restored on warm resume, so a call placed before the refresh 200 re-learns it cannot fall back. DUMP_SIP now also writes the last outgoing INVITE (imsd-invite-out.raw): the one request a field log could never show. The byte-pinned INVITE fixture moves to the tel: identity its test context knows; new scenarios cover the sip: identity, the tel: fallback, the pre-learning case and the UAS BYE. Bench-verified on KPN 2026-09-08: outgoing INVITE From is the registered sip: identity, call accepted and carried; caller ID at the far end unchanged. --- README.md | 3 ++- implementations/main.cpp | 24 ++++++++++++++++-------- interfaces/Imsd-Engine.cppm | 1 + interfaces/Imsd-Messages.cppm | 30 +++++++++++++++++++++++++----- tests/Engine/main.cpp | 21 ++++++++++++++++++++- tests/Messages/main.cpp | 34 +++++++++++++++++++++++++++++++++- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2b631e6..b4451ca 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,8 @@ PCSCF=2001:db8::105 | `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet | | `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `PRECOND` | `0` | `1` offers SDP QoS preconditions | -| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses | +| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run | +| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | diff --git a/implementations/main.cpp b/implementations/main.cpp index 0ee566c..c4705cf 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -411,12 +411,12 @@ std::string StateDir() { // tiny JSON reader/writer for the persisted registration context. std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } -void PersistState(const RegState& r, const std::string& route, const std::string& ppi) { +void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor) { std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " - "\"contact_user\": \"{}\"}}", - r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, + "\"aor\": \"{}\", \"contact_user\": \"{}\"}}", + r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, r.contactUser); std::string path = StatePath(); std::error_code ec; @@ -441,7 +441,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, contactUser; + std::optional callid, ftag, route, ppi, aor, contactUser; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -456,6 +456,7 @@ std::optional LoadState() { ps.ftag = QuotedAfter(s, "\"ftag\": \""); ps.route = QuotedAfter(s, "\"route\": \""); ps.ppi = QuotedAfter(s, "\"ppi\": \""); + ps.aor = QuotedAfter(s, "\"aor\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); @@ -809,6 +810,10 @@ private: // is re-learned from the next 200's P-Associated-URI. ppi_ = ps->ppi.value_or(""); ss_ = sa->securityServer; + // The public identity our requests are signed with (From) — restored + // here so a call placed before the refresh 200 re-learns it does not + // fall back to the barred temporary IMPU. + ctx_.aor = ps->aor.value_or(""); ctx_.route = route_; ctx_.ppi = ppi_; ctx_.securityServer = ss_; ctx_.portUc = PortUc; ctx_.portUs = PortUs; Log(std::format("RESUME via existing SA (cseq {})", ps->cseq.value_or(1))); @@ -834,7 +839,7 @@ private: if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; UpdateRouteAndPpi(okMsg); LogBindings(okMsg); - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("REGISTERED (resumed + true-refreshed)"); return true; } @@ -933,7 +938,7 @@ private: LogBindings(ok); if (route_.empty()) route_ = std::format("", imsd::util::HostPort(pcscf_, portPs)); ctx_.route = route_; ctx_.ppi = ppi_; - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("REGISTERED (fresh)"); } @@ -1051,7 +1056,7 @@ private: auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); SubscribeRegEvent(); if (!registered_) { registered_ = true; EmitStatus(true); } @@ -1087,7 +1092,7 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; UpdateRouteAndPpi(msg); LogBindings(msg); - PersistState(reg_, route_, ppi_); + PersistState(reg_, route_, ppi_, ctx_.aor); Log("reconnected + re-registered"); SubscribeRegEvent(); if (!registered_) { registered_ = true; EmitStatus(true); } @@ -1242,6 +1247,9 @@ private: for (const auto& a : actions) { switch (a.type) { case T::SendClient: + // the outgoing INVITE is the one request a field log could + // never show (DUMP_SIP dumped inbound messages only) + if (a.text.starts_with("INVITE ")) DumpRaw("imsd-invite-out.raw", a.text); if (!sip_.Send(a.text)) { sendOk = false; Log("client send failed"); } break; case T::SendResponse: diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index b91eef1..2a372d0 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -120,6 +120,7 @@ export namespace imsd::engine { 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 to = imsd::sip::Header(invite_, "To")) d_.localUri = AngleUri(*to).value_or(""); 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 diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index 1d8bce1..d70acdf 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -290,8 +290,28 @@ export namespace imsd::msg { 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 + std::string localUri; // our URI in this dialog: the UAS takes the + // INVITE's To URI; empty = CallerId(c) (UAC) }; + // The identity we sign our own requests with: the registered public + // identity (P-Associated-URI sip: entry), else the tel: one, and the + // IMSI-derived temporary IMPU only before either is learned. 3GPP allows + // the temporary IMPU in REGISTER alone; a P-CSCF that does not overwrite + // From shows it to the callee (Telia, 2026-09-01: the reporter's IMSI on + // the far phone) and a strict one may reject the request. + inline std::string_view CallerId(const Context& c) { + if (!c.aor.empty()) return c.aor; + if (!c.ppi.empty()) return c.ppi; + return c.id.impu; + } + + // From URI of a request inside a dialog (RFC 3261 12.2.1.1: the dialog's + // local URI — as UAS that is the INVITE's To, as UAC our caller identity). + inline std::string_view FromUri(const Context& c, const Dialog& d) { + return d.localUri.empty() ? CallerId(c) : std::string_view(d.localUri); + } + // 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) { @@ -314,7 +334,7 @@ export namespace imsd::msg { 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("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: <{}>", d.ruri), std::format("Call-ID: {}", d.callid), "CSeq: 1 INVITE", @@ -347,7 +367,7 @@ export namespace imsd::msg { 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("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", to), std::format("Call-ID: {}", d.callid), std::format("CSeq: {} {}", cseq, method), @@ -374,7 +394,7 @@ export namespace imsd::msg { 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("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", to), std::format("Call-ID: {}", d.callid), "CSeq: 1 ACK", @@ -391,7 +411,7 @@ export namespace imsd::msg { 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("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: {}", toHeader), std::format("Call-ID: {}", d.callid), "CSeq: 1 ACK", @@ -407,7 +427,7 @@ export namespace imsd::msg { 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("From: <{}>;tag={}", FromUri(c, d), d.itag), std::format("To: <{}>", d.ruri), std::format("Call-ID: {}", d.callid), "CSeq: 1 CANCEL", diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 2ad2f25..504e15f 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -164,6 +164,8 @@ int main() { CallMachine m(c, rng, "ims-call-1", "1233", 50004, false); auto start = m.Start(); Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE"); + Check(Sent(start, "INVITE ")->text.contains("\r\nFrom: ;tag="), "MO INVITE From is our public (tel:) identity, not the IMSI IMPU"); + Check(!Sent(start, "INVITE ")->text.contains("From: text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact"); Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1"); Check(bye && bye->text.contains("To: " ";tag=caller1\r\n"), "BYE To is the caller (their tag)"); - Check(bye && bye->text.contains("From: ;tag="), "BYE From is us (our tag)"); + Check(bye && bye->text.contains("From: ;tag="), "UAS BYE From is the INVITE's To URI (our tag), not the IMSI IMPU"); + Check(bye && !bye->text.contains("001010123456789@ims"), "UAS BYE carries no IMSI-derived identity"); // UAS route set: Record-Route in RECEIVED order Check(bye && bye->text.contains("Route: , " "\r\n"), "BYE routes via Record-Route in received order"); const Action* t = Find(ah, Action::Type::State); @@ -828,6 +831,22 @@ int main() { Check(inv && inv->text.contains("a=rtpmap:100 telephone-event/8000\r\n") && !inv->text.contains("telephone-event/16000"), "CODECS=PCMA,PCMU: narrowband DTMF only"); } + // ---- MO with the registered sip: identity learned (the normal case on a + // live network): From, CANCEL and BYE all carry it, never the IMSI + { + Context ca = MakeCtx(); + ca.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org"; + imsd::util::Rng rng(61); + CallMachine m(ca, rng, "ims-call-30", "1233", 50004, false); + auto start = m.Start(); + const Action* inv = Sent(start, "INVITE "); + Check(inv && inv->text.contains("\r\nFrom: ;tag="), "MO INVITE From is the registered sip: identity"); + auto cancel = m.OnHangup(); + const Action* can = Sent(cancel, "CANCEL "); + Check(can && can->text.contains("\r\nFrom: ;tag="), "CANCEL From is the same identity"); + Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); + } + if (Failures == 0) std::println("Engine: all tests passed"); return Failures; } diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index 3ee9ba2..0c9eea6 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -117,7 +117,7 @@ namespace { "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" + "From: ;tag=ITAG123456\r\n" "To: \r\n" "Call-ID: CALLID789@2001:db8::db43\r\n" "CSeq: 1 INVITE\r\n" @@ -230,6 +230,38 @@ int main() { Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer"); } + // ---- From carries our PUBLIC identity, never the IMSI-derived temporary + // IMPU once anything better is known (ledger F1: a Telia P-CSCF showed + // the reporter's IMSI to the callee). aor > tel: ppi > temp IMPU; the + // same URI on every request of the dialog; a UAS signs with the + // INVITE's To. + { + Dialog d; + d.ruri = RuriFor("1233", c.id.domain); + d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib"; + Context c2 = MakeCtx(); + c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org"; + std::string inv = BuildInvite(c2, d, "v=0\r\n", false); + Check(inv.contains("\r\nFrom: ;tag=it\r\n"), "INVITE From is the registered sip: identity when known"); + Check(!inv.contains("001010123456789@ims"), "INVITE never carries the IMSI IMPU once the aor is known"); + Check(inv.contains("\r\nP-Preferred-Identity: \r\n"), "P-Preferred-Identity unchanged"); + Check(BuildCancel(c2, d).contains("\r\nFrom: ;tag=it\r\n"), "CANCEL From follows"); + Check(BuildAckNon2xx(c2, d, ";tag=r").contains("\r\nFrom: ;tag=it\r\n"), "non-2xx ACK From follows"); + d.dialogTo = ";tag=r"; d.remoteTarget = "sip:x@[2001:db8::9]:5060"; + Check(BuildAck2xx(c2, d, "ab", ";tag=r").contains("\r\nFrom: ;tag=it\r\n"), "2xx ACK From follows"); + Check(BuildInDialog(c2, d, "BYE", 2, "bb").contains("\r\nFrom: ;tag=it\r\n"), "BYE From follows"); + // only tel: known (pre-aor): the tel: identity, as the pinned INVITE shows + Check(BuildInvite(c, d, "v=0\r\n", false).contains("\r\nFrom: ;tag=it\r\n"), "INVITE From falls back to the tel: identity"); + // nothing learned yet: the temporary IMPU is all there is + Context c4 = MakeCtx(); + c4.ppi.clear(); + Check(BuildInvite(c4, d, "v=0\r\n", false).contains("\r\nFrom: ;tag=it\r\n"), "INVITE From is the temporary IMPU only before any identity is learned"); + // UAS: the dialog's local URI is the INVITE's To, whatever we registered as + Dialog du = d; + du.localUri = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone"; + Check(BuildInDialog(c4, du, "BYE", 1, "cc").contains("\r\nFrom: ;tag=it\r\n"), "UAS BYE From is the INVITE's To URI"); + } + // ---- emergency INVITE: byte-identical to the plain INVITE except the // Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6) {