diff --git a/AGENTS.md b/AGENTS.md index d1db474..4d881fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,13 +73,16 @@ 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 it: the reg-event +test reaches — some older than 0.3.10, some added by 0.3.10 or 0.3.11: 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`: which message is a REGISTER's final, the dispatch of requests arriving meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the -12 s wait restarted on a provisional; `Reregister`'s one re-challenge); 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 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 @@ -253,8 +256,11 @@ Rules: on the screenshot carry no timestamps; KPN's refusals, all 403, carry none) and a `Dial` bringing a recovery step forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards - it) is pinned in `tests/Engine` only; the escalation by one refused - refresh that carries a `Retry-After` is pinned in `tests/Engine` only; a + it) is pinned in `tests/Engine` only; a 401 to a reregistration (the + refreshes KPN accepted were never challenged — consecutive CSeqs on every + 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 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 @@ -412,10 +418,31 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event SUBSCRIBE (§7.4). 5. A keepalive re-REGISTER refresh at half the granted lifetime, within - 120–1800 s (`REFRESH_INTERVAL`), only while idle. + 120–1800 s (`REFRESH_INTERVAL`), only while idle. The refresh of a + registration this process made fresh is a reregistration as TS 24.229 + 5.1.1.4.2 has it: the same Call-ID and Contact, CSeq+1, the + Authorization of the last successful authentication verbatim (the 401's + nonce, our cnonce and response, kept in the state file), a + Security-Client with new SPIs and a new client port (`PortUcNext`, + 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 + 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 + shape on record from O2 UK (an initial registration's empty digest, the + 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`. +`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, 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, +until its next fresh registration, and each such resume logs it). A failed fresh bring-up exits non-zero and systemd retries after `RestartSec` — except a bring-up the network refused or throttled, which @@ -488,8 +515,11 @@ manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`), What the daemon does when the registration stops working after bring-up, as a pure reducer the shell feeds with the clock and the network's answers. Two -kinds of failure reach it: a **refusal** (the network answered a refresh or a -fresh REGISTER with a final other than 200/401) and **silence** (the flow died +kinds of failure reach it: a **refusal** (the network answered a refresh with +a final other than 200 — a 401, re-authentication, included — or a fresh +registration's REGISTER with a final other than the expected one: from 300 +up on the unprotected one, whose 401 is the challenge, anything but 200 on +the protected one) and **silence** (the flow died and reconnecting through the SA, or the refresh over it, got no answer). Neither is retried for long on the same registration (TS 24.229 5.1.1.4 and RFC 5626 have the UE register anew). Before this reducer existed the daemon @@ -502,7 +532,7 @@ The policy, with its constants: | Step | Rule | |---|---| | Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | -| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | +| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s) or a 401 (re-authentication, which imsd answers with a fresh registration rather than over temporary SAs), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | | A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | | A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | | `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | @@ -710,7 +740,13 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: - `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. - `audio session: …` at start and at each call's media start; `CODECS override active: …`. - A refused REGISTER or SUBSCRIBE is logged with its status line and - any `Retry-After` / `Warning` / `Reason` / `Server` headers. + any `Retry-After` / `Warning` / `Reason` / `Server` headers; + `re-REGISTER challenged: 401 …; re-authentication asked, the next + registration is a fresh one` for a challenged refresh; `state file holds + 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. - `connect: no answer from the P-CSCF N times (…)` when the protected connect series ends unanswered. @@ -973,7 +1009,8 @@ only the imsd side of its bus choice. policy that names an account breaks the moment the account is called something else. - SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and - addresses. Dumps are mode 0600 under the state dir, off by default + 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 (`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 @@ -1009,7 +1046,14 @@ the related code. second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the first release to log a Retry-After), a refresh was refused 503 with `Retry-After` 414 s and the refresh after that wait 503 with `Retry-After` - 772 s: the origin of the `Retry-After` escalation. + 772 s: the origin of the `Retry-After` escalation. Every refresh of the + pre-0.3.11 shape — an initial registration's empty digest and the in-use + SPIs in Security-Client — on record from O2 was refused with 503: 21 on + 2026-09-24 under 0.3.9 and the same loop again that afternoon on a + stationary phone, two on 2026-10-08 under 0.3.10, and the user never saw + one succeed, while + the fresh registrations that followed were accepted: the origin of the + 5.1.1.4.2 refresh shape. KPN accepts both shapes without a challenge. - **KPN closes a registration's previous reg-event subscription with `noresource`** when a new one replaces it: that end is not renewed. - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string diff --git a/implementations/main.cpp b/implementations/main.cpp index 67cb950..047d6dc 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -54,6 +54,7 @@ constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; constexpr int PortUc = imsd::util::PortUc; constexpr int PortUs = imsd::util::PortUs; +constexpr int PortUcNext = imsd::util::PortUcNext; constexpr int InitPort = 5060; std::string EnvOr(const char* k, std::string d) { @@ -296,10 +297,12 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U // live). Handled in-process: a // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -// A REGISTER the network refused: the protected one, or a refresh, answered -// with a final other than 200 (a refresh's first 401 is its challenge); the -// unprotected initial one answered with a final from 300 up (its 401 is the -// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. +// A REGISTER the network refused: the protected one, or a reregistration, +// answered with a final other than 200 (a reregistration's 401 asks for a +// re-authentication imsd does not do over the old SAs: a refusal carrying +// 401); the unprotected initial one answered with a final from 300 up (its +// 401 is the challenge). The status and its Retry-After feed +// imsd::engine::RegRecovery. struct RefusedError : std::runtime_error { int status; long retryAfter; @@ -635,6 +638,12 @@ struct RegState { // Contact user-part of the stored binding (UUID since the s53 oracle // diff; empty = legacy IMSI binding from an older state file). std::string contactUser; + // The answer to the last challenge — the 401's nonce, our cnonce and + // the response — which every reregistration carries verbatim (TS + // 24.229 5.1.1.4.2 a). Empty = a registration resumed from a pre-0.3.11 + // state file; its reregistrations carry an empty digest, the pre-0.3.11 + // shape, until the next fresh registration. + std::string authNonce, authCnonce, authResponse; }; // Writable directory for the daemon's own files: the persisted registration @@ -653,9 +662,10 @@ void PersistState(const RegState& r, const std::string& route, const std::string std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " - "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\"}}", + "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", " + "\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\"}}", r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, - r.contactUser, transport); + r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse); std::string path = StatePath(); std::error_code ec; std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); @@ -679,7 +689,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, aor, contactUser, transport; + std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -697,6 +707,9 @@ std::optional LoadState() { ps.aor = QuotedAfter(s, "\"aor\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP + 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.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUs = IntAfter(s, "\"spi_us\": "); @@ -1295,7 +1308,9 @@ 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); - auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + 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); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; UpdateRouteAndPpi(okMsg); @@ -1461,6 +1476,7 @@ private: reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; + reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); UpdateRouteAndPpi(ok); @@ -1551,35 +1567,35 @@ private: throw std::runtime_error("no 401 to unprotected REGISTER"); } - // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). - std::pair Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { - std::string auth = imsd::msg::AuthEmpty(ctx_); - long cseq = startCseq; - bool challenged = false; - for (;;) { - std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); - if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); - std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); - auto st = imsd::sip::Status(m); - if (st == 200) return {m, cseq}; - if (st == 401 && !challenged) { - challenged = true; - auto nonce = QuotedAfter(m, "nonce=\""); - auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt; - if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh"); - std::vector rnd(raw->begin(), raw->begin() + 16); - std::vector autn(raw->begin() + 16, raw->begin() + 32); - auto aka = Authenticate(rnd, autn); - if (!aka) throw std::runtime_error("AKA failed on refresh"); - std::string cnonce = rng_.Token(16); - std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain); - auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response); - cseq++; - continue; - } - DumpRaw("imsd-register-fail.raw", m); - throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); - } + // A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2: + // the same Call-ID and Contact, CSeq+1, the Authorization of the last + // 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 + // 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 + // pre-0.3.11 shape on record from O2 UK — an initial registration's + // empty digest, the in-use SPIs — was refused 503 (2026-09-24 under + // 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that + // followed were accepted; KPN accepted both shapes without a challenge. + std::pair Reregister(const std::string& callid, const std::string& ftag, long cseq) { + std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_) + : imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse); + std::uint32_t spiUc = 0, spiUs = 0; + do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs); + do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc); + std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext); + if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); + std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); + auto st = imsd::sip::Status(m); + if (st == 200) return {m, cseq}; + DumpRaw("imsd-register-fail.raw", m); + if (st == 401) throw RefusedError(std::format("re-REGISTER challenged: {}; re-authentication asked, the next registration is a fresh one", imsd::sip::RefusalSummary(m)), 401, imsd::sip::RetryAfter(m).value_or(0)); + throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); } void UpdateRouteAndPpi(const std::string& okMsg) { @@ -1662,7 +1678,7 @@ private: if (Mono() - lastRefresh_ < interval) return; try { RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); @@ -1670,11 +1686,12 @@ private: OnRegistered(); } catch (const RefusedError& e) { // The network refused the refresh: the recovery policy counts it - // — a refusal with a Retry-After of at least a second makes the - // fresh registration the next step, behind the network's wait; - // one without gets one more refresh first, a second refusal then - // escalates — and the flow is dropped so the next step runs - // after the wait. + // — a refusal with a Retry-After of at least a second, or a 401 + // (the refresh throws a challenge as a refusal), makes the fresh + // registration the next step, behind any wait the network set; a + // refusal with neither gets one more refresh first, a second + // refusal then escalates — and the flow is dropped so the next + // step runs after the wait. Log(std::format("keepalive re-REGISTER failed: {}", e.what())); CloseFlowAfterWait(); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); @@ -1770,7 +1787,7 @@ private: } try { RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); reg_.cseq = used; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; UpdateRouteAndPpi(msg); diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 59a177b..2515e5c 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -60,7 +60,9 @@ export namespace imsd::engine { // bring-up, as a pure reducer: the shell supplies the clock and the // network, this decides the next step and how long to wait for it. Two // kinds of failure reach it. A REFUSAL: the network answered a refresh - // (or a fresh REGISTER) with a final other than 200/401. SILENCE: the + // with a final other than 200 — a 401, re-authentication, included — or + // a fresh registration's protected REGISTER with a final other than 200 + // (the unprotected one's 401 is its challenge). SILENCE: the // flow died and reconnecting through the SA, or the refresh over it, // got no answer. Neither is retried for long on the same registration. // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after @@ -79,12 +81,15 @@ export namespace imsd::engine { // a fresh registration after two (one 503 may be a momentary hiccup a // retried refresh clears); one with such a Retry-After escalates at // once — it is the network's considered answer (O2 2026-10-08: the - // refresh after the first wait was refused again, 414 s then 772 s). - // Silence escalates after two reconnect cycles + // refresh after the first wait was refused again, 414 s then 772 s) — + // and so does a 401, the network asking to re-authenticate, which imsd + // answers with a fresh registration (TS 24.229 5.1.1.5.1's temporary + // SAs on the proposed client port are not implemented). Silence + // escalates after two reconnect cycles // that end without an answer — a TCP connect series whose last attempt // was not a local refusal, a bind() the kernel refused, or a refresh - // that fails short of a refusal: unanswered, an unusable challenge, a - // failed send (the resume at start gives up after two unanswered + // that fails short of a refusal: unanswered, a failed send (the resume + // at start gives up after two unanswered // connects). // A fresh registration is the expensive step: an AKA run, a // new SA pair, and on KPN a refusal (spi-s=0) while the registration on @@ -191,7 +196,7 @@ export namespace imsd::engine { // cycle; `status` is the refusal's. The backoff paces the next step; // a Retry-After of at least a second holds every attempt for its own // length (up to RetryAfterMax), whichever is longer, and makes the - // next step the fresh registration. A 403 once a fresh + // next step the fresh registration, as does a 401. A 403 once a fresh // registration is due says the old registration is gone (KPN drops // it the moment an initial REGISTER arrives, throttled or not). void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { @@ -204,7 +209,7 @@ export namespace imsd::engine { if (f == Failure::Refused) refusals++; else silentCycles++; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; - if (f == Failure::Refused && retryAfter > 0) fresh = true; + if (f == Failure::Refused && (retryAfter > 0 || status == 401)) fresh = true; if (status == 403 && fresh) oldGone = true; backoffUntil = now + reconnectDelay; reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index 611bb82..b5e6105 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -14,8 +14,10 @@ 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 so a refresh re-REGISTER can -advertise the SPIs actually in use. +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). Pure std C++ (the shell runs the commands and captures the text). Command shapes and the parser are pinned by tests/Ipsec. @@ -309,7 +311,7 @@ export namespace imsd::ipsec { std::string securityServer; // reconstructed Security-Server header value int portPs = 0; int portPc = 0; - std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh) + std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one) std::uint32_t spiUs = 0; // our inbound server SPI std::uint32_t spiPs = 0; std::uint32_t spiPc = 0; diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index 3ef75e2..2991a4b 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -88,9 +88,15 @@ export namespace imsd::msg { 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. + // Security-Client value: the SPIs and protected ports offered for the SA + // pairs this REGISTER may set up — the in-use ones on an initial + // registration, new SPIs and a new client port on a reregistration + // (TS 24.229 5.1.1.4.2 d; TS 33.203 7.4.0: the server port stays). + inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs, int portUc, int portUs) { + 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, portUc, portUs); + } 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); + return SecurityClient(c, spiUc, spiUs, c.portUc, c.portUs); } // Authorization line with an empty (unchallenged) digest — the first @@ -217,9 +223,17 @@ export namespace imsd::msg { // 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) { + // Authorization line: AuthAka with the challenge's answer for the + // initial registration's second REGISTER and, verbatim, for every + // reregistration after it (TS 24.229 5.1.1.4.2 a: the last received + // nonce, the last calculated response); AuthEmpty for a registration + // resumed from a pre-0.3.11 state file, on every reregistration until + // its next fresh registration. A + // reregistration also passes NEW SPIs and the client port it proposes + // for a re-authentication's SA pairs (`portUc`, TS 33.203 7.4.0: the + // server port stays); 0 offers the in-use client port, the initial + // registration's case. + 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, int portUc = 0) { std::vector l = { std::format("REGISTER {} SIP/2.0", c.id.regUri), ViaLine(c, c.transport, c.portUs, branch), @@ -233,7 +247,7 @@ export namespace imsd::msg { std::string(authLine), "Require: sec-agree", "Proxy-Require: sec-agree", - SecurityClient(c, spiUc, spiUs), + SecurityClient(c, spiUc, spiUs, portUc ? portUc : c.portUc, c.portUs), std::format("Security-Verify: {}", c.securityServer), }; l.push_back("Supported: sec-agree, path"); diff --git a/interfaces/Imsd-Util.cppm b/interfaces/Imsd-Util.cppm index e6f1e13..7cc4936 100644 --- a/interfaces/Imsd-Util.cppm +++ b/interfaces/Imsd-Util.cppm @@ -24,6 +24,12 @@ export namespace imsd::util { // an installed package's nftables accept rules. constexpr int PortUc = 45061; constexpr int PortUs = 45062; + // The client port a reregistration proposes for the SA pairs a + // re-authentication would set up: TS 33.203 7.4.0 keeps the server + // ports (port_us, port_ps) and changes the client ports (port_uc, + // port_pc). imsd answers such a challenge with a fresh registration, so + // nothing is bound here and the firewall rules need not open it. + constexpr int PortUcNext = 45063; // An address is IPv6 iff it contains a colon (matches the Python is6()). inline bool Is6(std::string_view addr) { return addr.contains(':'); } diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 57de8c5..6d0f942 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -900,6 +900,17 @@ int main() { k.OnSuccess(); Check(k.Reconnectable(), "success forgets it"); } + // a 401 to a refresh asks for re-authentication over temporary SAs, + // which imsd does not do: the fresh registration at once, the old + // SA kept (the network still holds the registration) + { + RegRecovery j; + j.OnReconnectFailure(1000, F::Refused, 0, 401); + Check(j.Fresh() && j.Reconnectable() && j.refusals == 1 && freshAt(j, 1000, 1005), "a 401 on the first refusal: the fresh attempt after the backoff"); + RegRecovery jr; + jr.OnReconnectFailure(1000, F::Refused, 0, 503); + Check(!jr.Fresh() && reconnectAt(jr, 1000, 1005), "a bare 503 (no Retry-After) is still the hiccup case: one more refresh first"); + } // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered r.OnReconnectFailure(1000, F::Silent); Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index da46785..041ab0a 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -192,6 +192,20 @@ int main() { Reg2, "protected REGISTER"); } + // ---- a reregistration (the keepalive refresh, a resume): the last + // challenge answer verbatim, new SPIs and a new client port in + // Security-Client, the server port unchanged (TS 24.229 5.1.1.4.2, + // TS 33.203 7.4.0) + { + std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000"); + std::string r3 = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth, 45063); + Check(r3.contains("\r\nSecurity-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=55555; spi-s=66666; port-c=45063; port-s=45062\r\n"), "refresh Security-Client: new SPIs, a new client port, the same server port"); + Check(r3.contains("\r\nCSeq: 3 REGISTER\r\n") && r3.contains("\r\nVia: SIP/2.0/TCP [2001:db8::db43]:45062;") && r3.contains("@[2001:db8::db43]:45062>"), "refresh: CSeq+1, the server port in Via and Contact"); + Check(r3.contains(std::format("\r\n{}\r\n", auth)), "refresh carries the last challenge answer verbatim"); + std::string r3same = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth); + Check(r3same.contains("port-c=45061; port-s=45062\r\n"), "without a client port the in-use one is offered (the initial registration's second REGISTER)"); + } + // ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp, // or the auto fallback): every protected request's Via says UDP, the // challenge's Via stays UDP either way, and TCP is the untouched default.