messages: sign our requests with the public identity, not the IMSI IMPU

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.
This commit is contained in:
Jorijn van der Graaf 2026-09-08 20:37:47 +02:00
commit 7978b94502
6 changed files with 97 additions and 16 deletions

View file

@ -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: <tel:+31611111111>;tag="), "MO INVITE From is our public (tel:) identity, not the IMSI IMPU");
Check(!Sent(start, "INVITE ")->text.contains("From: <sip:001010123456789@"), "MO INVITE From never the temporary IMPU once an identity is known");
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
Check(m.State() == CallState::Dialing, "starts dialing");
@ -610,7 +612,8 @@ int main() {
Check(bye && bye->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: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
Check(bye && bye->text.contains("From: <sip:001010123456789@ims.mnc001.mcc001." "3gppnetwork.org>;tag="), "BYE From is us (our tag)");
Check(bye && bye->text.contains("From: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;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: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\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: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;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: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;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;
}

View file

@ -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: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=ITAG123456\r\n"
"From: <tel:+31611111111>;tag=ITAG123456\r\n"
"To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\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: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;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: <tel:+31611111111>\r\n"), "P-Preferred-Identity unchanged");
Check(BuildCancel(c2, d).contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "CANCEL From follows");
Check(BuildAckNon2xx(c2, d, "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "non-2xx ACK From follows");
d.dialogTo = "<sip:x@y>;tag=r"; d.remoteTarget = "sip:x@[2001:db8::9]:5060";
Check(BuildAck2xx(c2, d, "ab", "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "2xx ACK From follows");
Check(BuildInDialog(c2, d, "BYE", 2, "bb").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;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: <tel:+31611111111>;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: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;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: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;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)
{