diff --git a/AGENTS.md b/AGENTS.md index 4d881fc..4e8dcad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so that every decision in it has a unit test.** New protocol logic goes into a partition with a test. The shell (`main.cpp`) holds protocol decisions no -test reaches — some older than 0.3.10, some added by 0.3.10 or 0.3.11: the reg-event +test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11 or 0.3.12: the reg-event subscription schedule (§7.4: its retry ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: @@ -82,7 +82,10 @@ meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the 12 s wait restarted on a provisional); the reregistration's headers and its answer to a challenge (`Reregister`: the stored challenge answer, or the empty digest for a pre-0.3.11 state file, the new SPIs, `PortUcNext`; a 401 thrown -as a refusal carrying its status); the +as a refusal carrying its status); the state-file round trip +(`PersistState`/`LoadState`: the `security_server` key, written empty for a +value with a quote, a backslash or a newline — the choice between that list +and the entry rebuilt from the SA is `Imsd:Ipsec`'s, with a test); the inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local refusals and network silence, and the catch sites that sort a failed refresh (the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt @@ -261,6 +264,23 @@ Rules: bench — and O2 has not run this shape) and the escalation by one refused refresh that carries a `Retry-After` — the escalation to a fresh registration is pinned in `tests/Engine` only; a + resume echoing a multi-mechanism Security-Server list on the wire (the + choice of the stored list is pinned in `tests/Ipsec` with O2's recorded + list; KPN's has one mechanism, so on the bench the stored value and the + rebuilt one are the same bytes; O2's six-entry list has not resumed on + this code, and the state-file round trip is shell code no test + reaches); a Security-Server list `PersistState` writes empty (one with + a quote, a backslash or a newline — neither KPN's nor O2's list has + one; the resume of such a file ran with a hand-edited empty key, + 2026-10-11); a state write of a standing registration after a fresh + attempt that aborted before its SA setup (`RegState::securityServer`, + not the attempt's `ss_` — on KPN the old SA's retry after a throttled + attempt is refused 403, the throttled-attempt chain below, so no such + write follows here); the window that leaves a state file naming another + SA pair than the kernel's (a stop or a crash between a fresh attempt's + SA setup and its 200's state write — the check is pinned in `tests/Ipsec` + and ran on a hand-edited list, 2026-10-11; the window itself is not + staged); a refused initial or fresh REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or stayed silent — pinned in `tests/Engine` only); the write-off of the old @@ -427,10 +447,20 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): 45063) for the SA pairs a re-authentication would set up — the server port stays (TS 33.203 7.4.0) — and the Security-Verify (the 401's list). A registration resumed from the kernel's SAs sends the same - Authorization and Security-Client, but its Security-Verify — on the - resume's re-REGISTER and on every keepalive after it, until its next - fresh registration — is the one entry rebuilt from the SA - (`Imsd:Ipsec`), not the 401's list, as before 0.3.11. A 401 to a + Security-Client as that refresh, the Authorization the state file holds + (a file from before 0.3.11: the empty digest) and the Security-Verify + from the state file (since 0.3.12); resumed from a file written before + 0.3.12, from one whose list `PersistState` wrote empty, or from one + whose list names another SA pair than the kernel holds (an attempt the + process did not live to record: its SAs installed, its 200 not + written), it sends — + on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event + SUBSCRIBE and INVITEs, until its next fresh registration — the one + entry rebuilt from the SA + (`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the + default offer in the same bytes, not a list such as O2's six, which RFC 3329 §2.3.1 has the server answer with a + 494 when it does not mirror its own; a refused resume registers fresh. + A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports, which imsd does not implement: it is a refusal and the recovery is a fresh registration (§7.4). Every refresh of the pre-0.3.11 @@ -438,7 +468,13 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): in-use SPIs) was refused with 503 (§13). The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, -`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, and since +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 0.3.12 +`security_server` (the registration's Security-Verify: the 401's list, +written empty when it holds a quote, a backslash or a newline; absent, +empty or naming another SA pair than the kernel's — a file from before +0.3.12, such a list, or a file an unrecorded attempt left behind — the +resume rebuilds one mechanism from the SA, logs which, and stores that), +and since 0.3.11 `auth_nonce`, `auth_cnonce`, `auth_response` (a registration resumed from an older file has none: its reregistrations — the resume's and the keepalives', across restarts — carry the empty digest, as before 0.3.11, @@ -746,7 +782,12 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: no challenge answer (pre-0.3.11): the refresh carries an empty digest` at a resume of a registration made before 0.3.11 — every reregistration of it carries the empty digest until its next fresh registration, and - the line repeats at each later resume of it. + the line repeats at each later resume of it; `Security-Verify from the + state file (1 mechanism)` / `(N mechanisms)`, `state file holds no Security-Server (a + pre-0.3.12 file, or a list the file cannot hold): Security-Verify + rebuilt from the SA as one mechanism` or `state file's Security-Server + names another SA pair than the one in the kernel (spi-s N vs M): + Security-Verify rebuilt from the SA as one mechanism` at every resume. - `connect: no answer from the P-CSCF N times (…)` when the protected connect series ends unanswered. @@ -1010,7 +1051,8 @@ only the imsd side of its bus choice. something else. - SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and addresses, and the state file (since 0.3.11) the last challenge answer — - nonce, cnonce and response. Dumps are mode 0600 under the state dir, off by default + nonce, cnonce and response — and (since 0.3.12) the P-CSCF's + Security-Server list. Dumps are mode 0600 under the state dir, off by default (`DUMP_SIP=0`), and never leave the phone in a commit. - The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming the default route and gives the P-CSCF a host route; imsd adds one per diff --git a/implementations/main.cpp b/implementations/main.cpp index af2159f..575204f 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -644,6 +644,14 @@ struct RegState { // state file; its reregistrations carry an empty digest, the pre-0.3.11 // shape, until the next fresh registration. std::string authNonce, authCnonce, authResponse; + // The Security-Verify this registration's protected REGISTERs, its + // reg-event SUBSCRIBE and its INVITEs echo: the 401's whole + // Security-Server list (RFC 3329 §2.3.1). Set on the fresh 200 and at + // the resume, never from an attempt in flight: + // the member ss_ holds a fresh attempt's 401 before that attempt can + // still abort (the throttle, an unsupported mechanism), and the + // standing registration's state writes must not store it. + std::string securityServer; }; // Writable directory for the daemon's own files: the persisted registration @@ -658,14 +666,21 @@ std::string StateDir() { // tiny JSON reader/writer for the persisted registration context. std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } +// `security_server` is the registration's Security-Verify (RegState's +// securityServer: the 401's joined list); a resume takes it from here +// since 0.3.12. The file's values are quoted without escaping; a value +// with a quote, a backslash or a newline in it (none RFC 3329's token +// syntax produces) is written empty, and the resume falls back to the SA. void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) { + std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string(); std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", " - "\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\"}}", + "\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", " + "\"security_server\": \"{}\"}}", r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, - r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse); + r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss); std::string path = StatePath(); std::error_code ec; std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); @@ -689,7 +704,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse; + std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -710,6 +725,7 @@ std::optional LoadState() { ps.authNonce = QuotedAfter(s, "\"auth_nonce\": \""); // the three absent in pre-0.3.11 files ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \""); ps.authResponse = QuotedAfter(s, "\"auth_response\": \""); + ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files ps.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUs = IntAfter(s, "\"spi_us\": "); @@ -1280,7 +1296,29 @@ private: // No fallback identity: an empty ppi omits P-Preferred-Identity and // is re-learned from the next 200's P-Associated-URI. ppi_ = ps->ppi.value_or(""); - ss_ = sa->securityServer; + // The Security-Verify the protected REGISTERs, the reg-event + // SUBSCRIBE and the INVITEs echo: the 401's whole Security-Server + // list (RFC 3329 §2.3.1), from the state file since 0.3.12, or the + // one-mechanism entry rebuilt from the SA — Imsd:Ipsec chooses + // (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to + // the default offer in the same bytes, not a list such as O2's six, + // which the RFC has the server answer with a 494; a refused resume + // registers fresh. + auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_); + ss_ = rsv.value; + using Source = imsd::ipsec::ResumeSecurityVerify::Source; + switch (rsv.source) { + case Source::Stored: + Log(std::format("Security-Verify from the state file ({} mechanism{})", rsv.mechanisms, rsv.mechanisms == 1 ? "" : "s")); + break; + case Source::NoneStored: + Log("state file holds no Security-Server (a pre-0.3.12 file, or a list the file cannot hold): Security-Verify rebuilt from the SA as one mechanism"); + break; + case Source::OtherSa: + Log(std::format("state file's Security-Server names another SA pair than the one in the kernel (spi-s {} vs {}): Security-Verify rebuilt from the SA as one mechanism", + rsv.storedSpiPs ? std::to_string(*rsv.storedSpiPs) : "none", sa->spiPs)); + break; + } // 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. @@ -1308,6 +1346,7 @@ private: reg_.spiUc = static_cast(ps->spiUc.value_or(sa->spiUc)); reg_.spiUs = static_cast(ps->spiUs.value_or(sa->spiUs)); reg_.expiry = ps->expiry.value_or(0); + reg_.securityServer = ss_; reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or(""); if (reg_.authNonce.empty()) Log("state file holds no challenge answer (pre-0.3.11): the refresh carries an empty digest"); auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); @@ -1477,6 +1516,7 @@ private: reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response; + reg_.securityServer = ss_; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); UpdateRouteAndPpi(ok); @@ -1572,9 +1612,9 @@ private: // successful authentication, a Security-Client with new SPIs and a new // client port for the SA pairs a re-authentication would set up (the // server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's - // list for a registration this process made fresh; for a resumed one - // the entry rebuilt from the kernel's SA, on every reregistration until - // the next fresh registration). Returns (200 msg, used cseq). A 401 + // list, kept in the state file since 0.3.12 so a resumed registration + // echoes it too; a resume from an older file sends the one entry + // rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401 // asks for re-authentication over temporary SAs on the proposed ports, // which imsd does not do: it is thrown as a refusal whose recovery is a // fresh registration (RegRecovery's 401 rule). Every refresh of the diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index b5e6105..df29ace 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -13,11 +13,13 @@ alg (HMAC-SHA-1-96 or HMAC-MD5-96) keyed with IK; confidentiality is the negotiated ealg keyed with CK. Both come from the Security-Server mechanism the P-CSCF selected in its 401 (parsed here), and this module turns the negotiated SPIs/ports/keys into the exact argv lists the daemon shell hands to -`ip`; for a warm resume it reconstructs the SPIs, ports, and Security-Server -value back out of `ip xfrm state`/`policy` text — the Security-Verify and -the P-CSCF ports for the resume's flow, and the SPIs in use, which a -reregistration's new SPIs must differ from (and the state file falls back -to). +`ip`; for a warm resume it reconstructs the SPIs and ports back out of `ip +xfrm state`/`policy` text — the P-CSCF ports for the resume's flow, and the +SPIs in use, which a reregistration's new SPIs must differ from (and the +state file falls back to) — and a one-mechanism Security-Server value that +stands in for the Security-Verify when the state file holds no list for the +SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12 +the 401's own list is stored there). Pure std C++ (the shell runs the commands and captures the text). Command shapes and the parser are pinned by tests/Ipsec. @@ -308,7 +310,7 @@ export namespace imsd::ipsec { // What a warm SA in the kernel tells us — enough for a refresh re-REGISTER // to re-bind without re-authenticating. struct ExistingSa { - std::string securityServer; // reconstructed Security-Server header value + std::string securityServer; // one-mechanism Security-Server value: the resume's Security-Verify when the state file holds no list for this pair int portPs = 0; int portPc = 0; std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one) @@ -403,6 +405,35 @@ export namespace imsd::ipsec { return sa; } + // The Security-Verify a resume sends, and where it came from: the state + // file's list when the entry carrying its SPIs names the SA pair the + // kernel holds (a file an attempt the process did not live to record + // left behind names another pair), else the one-mechanism entry rebuilt + // from the SA — for an empty or unparseable value too. + struct ResumeSecurityVerify { + enum class Source { Stored, NoneStored, OtherSa }; + Source source = Source::NoneStored; + std::string value; + std::size_t mechanisms = 0; // of the stored list, when it is used + std::optional storedSpiPs; // the stored list's spi-s (OtherSa); nullopt = none parsed + }; + ResumeSecurityVerify ChooseResumeSecurityVerify(std::string_view stored, const ExistingSa& sa, std::string_view offeredEalg) { + ResumeSecurityVerify r; + r.value = sa.securityServer; + if (stored.empty()) return r; + r.source = ResumeSecurityVerify::Source::OtherSa; + if (auto parsed = ParseSecurityServer(stored, "hmac-sha-1-96", offeredEalg)) { + const SecurityMechanism& c = parsed->Carrier(); + r.storedSpiPs = c.spiPs; + if (c.spiPc == sa.spiPc && c.spiPs == sa.spiPs && c.portPc == sa.portPc && c.portPs == sa.portPs) { + r.source = ResumeSecurityVerify::Source::Stored; + r.value = std::string(stored); + r.mechanisms = parsed->mechanisms.size(); + } + } + return r; + } + // Every local address holding an SA to the P-CSCF, once each, in kernel // order: where a registration made on another address of the link (the // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index 2991a4b..dcb28de 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -29,8 +29,9 @@ 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. + // registration. Ports default to the fixed protected values; route and + // ppi are filled in from the REGISTER 200 OK, securityServer from the + // 401 (at a resume, from the state file). struct Context { imsd::aka::Identity id; std::string local; @@ -63,7 +64,7 @@ export namespace imsd::msg { // 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) + std::string securityServer; // Security-Verify value: the 401's Security-Server list (from the state file on a resume) // 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 diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index fcb1d89..1f63107 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -196,6 +196,38 @@ int main() { auto bare = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null;", "hmac-sha-1-96", "null"); Check(bare && bare->mechanisms.size() == 1 && bare->offerMatched && !bare->Carrier().spiPs && !bare->Carrier().portPs, "no spi-s: absent, not zero"); } + // ---- the resume's Security-Verify: the stored list only for the SA pair the kernel holds + { + using Source = ResumeSecurityVerify::Source; + ExistingSa sa; + sa.spiPc = 129427852u; sa.spiPs = 133937015u; sa.portPc = 6051; sa.portPs = 6060; + sa.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=129427852; spi-s=133937015; port-c=6051; port-s=6060"; + auto ok = ChooseResumeSecurityVerify(O2, sa, "null"); + Check(ok.source == Source::Stored && ok.value == O2 && ok.mechanisms == 6, "resume: O2's six-entry list whose carrier is the kernel's pair -> the list verbatim, six mechanisms"); + auto okAes = ChooseResumeSecurityVerify(O2, sa, "aes-cbc"); + Check(okAes.source == Source::Stored && okAes.value == O2, "resume: the same under the aes offer (the carrier does not depend on the offer)"); + constexpr std::pair off[] = { + {"spi-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427853;spi-s=133937015;port-c=6051;port-s=6060"}, + {"spi-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937016;port-c=6051;port-s=6060"}, + {"port-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6052;port-s=6060"}, + {"port-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6061"}, + }; + for (const auto& [field, text] : off) { + auto r = ChooseResumeSecurityVerify(text, sa, "null"); + Check(r.source == Source::OtherSa && r.value == sa.securityServer, std::format("resume: {} off by one -> the rebuilt entry", field)); + } + auto offSpiS = ChooseResumeSecurityVerify(off[1].second, sa, "null"); + Check(offSpiS.storedSpiPs && *offSpiS.storedSpiPs == 133937016u, "resume: the stored spi-s is reported for the log line"); + auto none = ChooseResumeSecurityVerify("", sa, "null"); + Check(none.source == Source::NoneStored && none.value == sa.securityServer, "resume: nothing stored -> the rebuilt entry"); + auto junk = ChooseResumeSecurityVerify("not a list", sa, "null"); + Check(junk.source == Source::OtherSa && junk.value == sa.securityServer && !junk.storedSpiPs, "resume: an unparseable value -> the rebuilt entry, no spi-s to report"); + ExistingSa kpn; + kpn.spiPc = 49889723u; kpn.spiPs = 49889722u; kpn.portPc = 33142; kpn.portPs = 6000; + kpn.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=49889723; spi-s=49889722; port-c=33142; port-s=6000"; + auto k = ChooseResumeSecurityVerify(kpn.securityServer, kpn, "aes-cbc"); + Check(k.source == Source::Stored && k.value == kpn.securityServer && k.mechanisms == 1, "resume: KPN's one-entry list for its pair -> the list, one mechanism"); + } // ---- selection rules and syntax corners { // spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)