Compare commits

..
8 changed files with 81 additions and 210 deletions

View file

@ -73,16 +73,13 @@ 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 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 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 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 it: the reg-event
subscription schedule (§7.4: its retry subscription schedule (§7.4: its retry
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`: registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`:
which message is a REGISTER's final, the dispatch of requests arriving 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 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 12 s wait restarted on a provisional; `Reregister`'s one re-challenge); the
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 inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local
refusals and network silence, and the catch sites that sort a failed refresh refusals and network silence, and the catch sites that sort a failed refresh
(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt (the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt
@ -249,26 +246,18 @@ Rules:
pre-fix build that renewed that closure; the retry on this code, the pre-fix build that renewed that closure; the retry on this code, the
renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the
renew arm and the back-off's survival across a refresh have run nowhere — renew arm and the back-off's survival across a refresh have run nowhere —
§3, §7.4); a `Retry-After` on a REGISTER has run in the field (one §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery
report, a screenshot of 2026-10-08, O2, 0.3.10: a refresh's Retry-After step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the
set the next step's time — `in 7 min`, then `in 13 min` — and the `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine`
refusals counted; the wait's length went unmeasured, the journal lines only); a refused initial or fresh
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; 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 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 stayed silent — pinned in `tests/Engine` only); the write-off of the old
registration by a recovery's fresh attempt that fails past the SA setup registration by a recovery's fresh attempt that fails past the SA setup
(such attempts are on record only as a bring-up's fatal exit, which does (such attempts are on record only as a bring-up's fatal exit, which does
not pass that arm — pinned in `tests/Engine` only); the escalation by not pass that arm — pinned in `tests/Engine` only); a refused
two refused refreshes that carry no `Retry-After` (KPN refuses a refresh refresh counted
only with the 403 that says the registration is gone — pinned in towards the escalation, and the escalation by two of them (KPN refuses a
refresh only with the 403 that says the registration is gone — pinned in
`tests/Engine` only); of the throttled-attempt chain in recovery (a `tests/Engine` only); of the throttled-attempt chain in recovery (a
throttled fresh attempt, the old SA retried once meanwhile with the stored throttled fresh attempt, the old SA retried once meanwhile with the stored
binding's Contact and refused 403, the deferred attempt accepted at the binding's Contact and refused 403, the deferred attempt accepted at the
@ -418,31 +407,10 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
(§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event (§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event
SUBSCRIBE (§7.4). SUBSCRIBE (§7.4).
5. A keepalive re-REGISTER refresh at half the granted lifetime, within 5. A keepalive re-REGISTER refresh at half the granted lifetime, within
120–1800 s (`REFRESH_INTERVAL`), only while idle. The refresh of a 120–1800 s (`REFRESH_INTERVAL`), only while idle.
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`, 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`.
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 A failed fresh bring-up exits non-zero and systemd retries after
`RestartSec` — except a bring-up the network refused or throttled, which `RestartSec` — except a bring-up the network refused or throttled, which
@ -515,11 +483,8 @@ manual runs): `IMS_APN` (default `ims`), `IMS_IP_TYPE` (`ipv6`),
What the daemon does when the registration stops working after bring-up, as 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 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 with kinds of failure reach it: a **refusal** (the network answered a refresh or a
a final other than 200 — a 401, re-authentication, included — or a fresh fresh REGISTER with a final other than 200/401) and **silence** (the flow died
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). 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 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 RFC 5626 have the UE register anew). Before this reducer existed the daemon
@ -532,7 +497,7 @@ The policy, with its constants:
| Step | Rule | | Step | Rule |
|---|---| |---|---|
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | | 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) 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)`). | | Escalate to a fresh registration | After two refused refreshes (`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 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`). | | 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`). | | `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`). |
@ -740,13 +705,7 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY. - `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: …`. - `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 - 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: no answer from the P-CSCF N times (…)` when the protected
connect series ends unanswered. connect series ends unanswered.
@ -1009,8 +968,7 @@ only the imsd side of its bus choice.
policy that names an account breaks the moment the account is called policy that names an account breaks the moment the account is called
something else. something else.
- SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and - 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 — addresses. Dumps are mode 0600 under the state dir, off by default
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. (`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 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 the default route and gives the P-CSCF a host route; imsd adds one per
@ -1043,17 +1001,7 @@ the related code.
registration's address the attempt is refused and costs the registration. registration's address the attempt is refused and costs the registration.
- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 - **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33
unanswered reconnect cycles; a fresh registration was accepted in one unanswered reconnect cycles; a fresh registration was accepted in one
second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the second. The origin of `RegRecovery`.
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. 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 - **KPN closes a registration's previous reg-event subscription with
`noresource`** when a new one replaces it: that end is not renewed. `noresource`** when a new one replaces it: that end is not renewed.
- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string

View file

@ -48,13 +48,12 @@ import ImsdBus;
namespace { namespace {
// ---- static config (env-overridable, same knobs as imsd.py) --------------- // ---- static config (env-overridable, same knobs as imsd.py) ---------------
constexpr const char* Version = "0.3.11"; constexpr const char* Version = "0.3.10";
constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* BusName = "net.catcrafts.IMS1";
constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1";
constexpr const char* Iface = "net.catcrafts.IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1";
constexpr int PortUc = imsd::util::PortUc; constexpr int PortUc = imsd::util::PortUc;
constexpr int PortUs = imsd::util::PortUs; constexpr int PortUs = imsd::util::PortUs;
constexpr int PortUcNext = imsd::util::PortUcNext;
constexpr int InitPort = 5060; constexpr int InitPort = 5060;
std::string EnvOr(const char* k, std::string d) { std::string EnvOr(const char* k, std::string d) {
@ -297,12 +296,10 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U
// live). Handled in-process: a // live). Handled in-process: a
// systemd restart every RestartSec would never clear it. // systemd restart every RestartSec would never clear it.
struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; };
// A REGISTER the network refused: the protected one, or a reregistration, // A REGISTER the network refused: the protected one, or a refresh, answered
// answered with a final other than 200 (a reregistration's 401 asks for a // with a final other than 200 (a refresh's first 401 is its challenge); the
// re-authentication imsd does not do over the old SAs: a refusal carrying // unprotected initial one answered with a final from 300 up (its 401 is the
// 401); the unprotected initial one answered with a final from 300 up (its // challenge). The status and its Retry-After feed imsd::engine::RegRecovery.
// 401 is the challenge). The status and its Retry-After feed
// imsd::engine::RegRecovery.
struct RefusedError : std::runtime_error { struct RefusedError : std::runtime_error {
int status; int status;
long retryAfter; long retryAfter;
@ -638,12 +635,6 @@ struct RegState {
// Contact user-part of the stored binding (UUID since the s53 oracle // Contact user-part of the stored binding (UUID since the s53 oracle
// diff; empty = legacy IMSI binding from an older state file). // diff; empty = legacy IMSI binding from an older state file).
std::string contactUser; 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 // Writable directory for the daemon's own files: the persisted registration
@ -662,10 +653,9 @@ void PersistState(const RegState& r, const std::string& route, const std::string
std::string j = std::format( std::string j = std::format(
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " "\"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.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);
std::string path = StatePath(); std::string path = StatePath();
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
@ -689,7 +679,7 @@ void DumpRaw(std::string_view name, std::string_view msg) {
} }
struct PersistedState { struct PersistedState {
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse; std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport;
std::optional<long> cseq, spiUc, spiUs, expiry; std::optional<long> cseq, spiUc, spiUs, expiry;
}; };
std::optional<PersistedState> LoadState() { std::optional<PersistedState> LoadState() {
@ -707,9 +697,6 @@ std::optional<PersistedState> LoadState() {
ps.aor = QuotedAfter(s, "\"aor\": \""); ps.aor = QuotedAfter(s, "\"aor\": \"");
ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \"");
ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP 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.cseq = IntAfter(s, "\"cseq\": ");
ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": ");
ps.spiUs = IntAfter(s, "\"spi_us\": "); ps.spiUs = IntAfter(s, "\"spi_us\": ");
@ -1308,9 +1295,7 @@ private:
reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc)); reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc));
reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs)); reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs));
reg_.expiry = ps->expiry.value_or(0); reg_.expiry = ps->expiry.value_or(0);
reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or(""); auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
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; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
UpdateRouteAndPpi(okMsg); UpdateRouteAndPpi(okMsg);
@ -1476,7 +1461,6 @@ private:
reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2;
reg_.contactUser = contactUser; reg_.contactUser = contactUser;
reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.spiUc = spiUc; reg_.spiUs = spiUs;
reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response;
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
DumpRaw("imsd-register-200.raw", ok); DumpRaw("imsd-register-200.raw", ok);
UpdateRouteAndPpi(ok); UpdateRouteAndPpi(ok);
@ -1567,35 +1551,35 @@ private:
throw std::runtime_error("no 401 to unprotected REGISTER"); throw std::runtime_error("no 401 to unprotected REGISTER");
} }
// A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2: // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq).
// the same Call-ID and Contact, CSeq+1, the Authorization of the last std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) {
// successful authentication, a Security-Client with new SPIs and a new std::string auth = imsd::msg::AuthEmpty(ctx_);
// client port for the SA pairs a re-authentication would set up (the long cseq = startCseq;
// server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's bool challenged = false;
// list for a registration this process made fresh; for a resumed one for (;;) {
// the entry rebuilt from the kernel's SA, on every reregistration until std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth);
// the next fresh registration). Returns (200 msg, used cseq). A 401 if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
// asks for re-authentication over temporary SAs on the proposed ports, std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
// which imsd does not do: it is thrown as a refusal whose recovery is a auto st = imsd::sip::Status(m);
// fresh registration (RegRecovery's 401 rule). Every refresh of the if (st == 200) return {m, cseq};
// pre-0.3.11 shape on record from O2 UK — an initial registration's if (st == 401 && !challenged) {
// empty digest, the in-use SPIs — was refused 503 (2026-09-24 under challenged = true;
// 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that auto nonce = QuotedAfter(m, "nonce=\"");
// followed were accepted; KPN accepted both shapes without a challenge. auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt;
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long cseq) { if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh");
std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_) std::vector<std::uint8_t> rnd(raw->begin(), raw->begin() + 16);
: imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse); std::vector<std::uint8_t> autn(raw->begin() + 16, raw->begin() + 32);
std::uint32_t spiUc = 0, spiUs = 0; auto aka = Authenticate(rnd, autn);
do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs); if (!aka) throw std::runtime_error("AKA failed on refresh");
do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc); std::string cnonce = rng_.Token(16);
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext); std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response);
std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); cseq++;
auto st = imsd::sip::Status(m); continue;
if (st == 200) return {m, cseq}; }
DumpRaw("imsd-register-fail.raw", m); 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));
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) { void UpdateRouteAndPpi(const std::string& okMsg) {
@ -1678,7 +1662,7 @@ private:
if (Mono() - lastRefresh_ < interval) return; if (Mono() - lastRefresh_ < interval) return;
try { try {
RefreshPani(); RefreshPani();
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
@ -1686,12 +1670,9 @@ private:
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
// The network refused the refresh: the recovery policy counts it // The network refused the refresh: the recovery policy counts it
// — a refusal with a Retry-After of at least a second, or a 401 // (a second refusal means a fresh registration) and the flow is
// (the refresh throws a challenge as a refusal), makes the fresh // dropped so the reconnect step retries the refresh after the
// registration the next step, behind any wait the network set; a // wait (Retry-After when given).
// 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())); Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait(); CloseFlowAfterWait();
RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status);
@ -1787,7 +1768,7 @@ private:
} }
try { try {
RefreshPani(); RefreshPani();
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
UpdateRouteAndPpi(msg); UpdateRouteAndPpi(msg);

View file

@ -60,9 +60,7 @@ export namespace imsd::engine {
// bring-up, as a pure reducer: the shell supplies the clock and the // 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 // 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 // kinds of failure reach it. A REFUSAL: the network answered a refresh
// with a final other than 200 — a 401, re-authentication, included — or // (or a fresh REGISTER) with a final other than 200/401. SILENCE: the
// 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, // flow died and reconnecting through the SA, or the refresh over it,
// got no answer. Neither is retried for long on the same registration. // 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 // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after
@ -76,20 +74,13 @@ export namespace imsd::engine {
// fresh registration the network accepted within two seconds (ims // fresh registration the network accepted within two seconds (ims
// ledger F16). // ledger F16).
// //
// Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates
// Retry-After of at least a second (none, 0, unparsable) escalates to // to a fresh registration after two (one 503 may be a momentary
// a fresh registration after two (one 503 may be a momentary hiccup a // hiccup a retried refresh clears), silence after two reconnect cycles
// 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) —
// 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 // 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 // was not a local refusal, a bind() the kernel refused, or a refresh
// that fails short of a refusal: unanswered, a failed send (the resume // that fails short of a refusal: unanswered, an unusable challenge, a
// at start gives up after two unanswered // failed send (the resume at start gives up after two unanswered
// connects). // connects).
// A fresh registration is the expensive step: an AKA run, a // 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 // new SA pair, and on KPN a refusal (spi-s=0) while the registration on
@ -194,9 +185,8 @@ export namespace imsd::engine {
// A refused keepalive refresh, or a failed reconnect + refresh // A refused keepalive refresh, or a failed reconnect + refresh
// cycle; `status` is the refusal's. The backoff paces the next step; // 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 // a Retry-After holds every attempt for its own length (up to
// length (up to RetryAfterMax), whichever is longer, and makes the // RetryAfterMax), whichever is longer. 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 // registration is due says the old registration is gone (KPN drops
// it the moment an initial REGISTER arrives, throttled or not). // it the moment an initial REGISTER arrives, throttled or not).
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
@ -209,7 +199,6 @@ export namespace imsd::engine {
if (f == Failure::Refused) refusals++; if (f == Failure::Refused) refusals++;
else silentCycles++; else silentCycles++;
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
if (f == Failure::Refused && (retryAfter > 0 || status == 401)) fresh = true;
if (status == 403 && fresh) oldGone = true; if (status == 403 && fresh) oldGone = true;
backoffUntil = now + reconnectDelay; backoffUntil = now + reconnectDelay;
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);

View file

@ -14,10 +14,8 @@ 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 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 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 `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 value back out of `ip xfrm state`/`policy` text so a refresh re-REGISTER can
the P-CSCF ports for the resume's flow, and the SPIs in use, which a advertise the SPIs actually in use.
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 Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec. shapes and the parser are pinned by tests/Ipsec.
@ -311,7 +309,7 @@ export namespace imsd::ipsec {
std::string securityServer; // reconstructed Security-Server header value std::string securityServer; // reconstructed Security-Server header value
int portPs = 0; int portPs = 0;
int portPc = 0; int portPc = 0;
std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one) std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh)
std::uint32_t spiUs = 0; // our inbound server SPI std::uint32_t spiUs = 0; // our inbound server SPI
std::uint32_t spiPs = 0; std::uint32_t spiPs = 0;
std::uint32_t spiPc = 0; std::uint32_t spiPc = 0;

View file

@ -88,15 +88,9 @@ export namespace imsd::msg {
return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch); return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch);
} }
// Security-Client value: the SPIs and protected ports offered for the SA // Security-Client value carrying our in-use client SPIs + protected ports.
// 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) { inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) {
return SecurityClient(c, spiUc, spiUs, c.portUc, c.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, c.portUc, c.portUs);
} }
// Authorization line with an empty (unchallenged) digest — the first // Authorization line with an empty (unchallenged) digest — the first
@ -223,17 +217,9 @@ export namespace imsd::msg {
// The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture, // 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 // 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 // wrong and cost us all terminating delivery. `authLine` is a full
// Authorization line: AuthAka with the challenge's answer for the // Authorization line (AuthEmpty for the refresh's first try, AuthAka
// initial registration's second REGISTER and, verbatim, for every // once challenged).
// reregistration after it (TS 24.229 5.1.1.4.2 a: the last received 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) {
// 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<std::string> l = { std::vector<std::string> l = {
std::format("REGISTER {} SIP/2.0", c.id.regUri), std::format("REGISTER {} SIP/2.0", c.id.regUri),
ViaLine(c, c.transport, c.portUs, branch), ViaLine(c, c.transport, c.portUs, branch),
@ -247,7 +233,7 @@ export namespace imsd::msg {
std::string(authLine), std::string(authLine),
"Require: sec-agree", "Require: sec-agree",
"Proxy-Require: sec-agree", "Proxy-Require: sec-agree",
SecurityClient(c, spiUc, spiUs, portUc ? portUc : c.portUc, c.portUs), SecurityClient(c, spiUc, spiUs),
std::format("Security-Verify: {}", c.securityServer), std::format("Security-Verify: {}", c.securityServer),
}; };
l.push_back("Supported: sec-agree, path"); l.push_back("Supported: sec-agree, path");

View file

@ -24,12 +24,6 @@ export namespace imsd::util {
// an installed package's nftables accept rules. // an installed package's nftables accept rules.
constexpr int PortUc = 45061; constexpr int PortUc = 45061;
constexpr int PortUs = 45062; 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()). // An address is IPv6 iff it contains a colon (matches the Python is6()).
inline bool Is6(std::string_view addr) { return addr.contains(':'); } inline bool Is6(std::string_view addr) { return addr.contains(':'); }

View file

@ -882,9 +882,9 @@ int main() {
// Retry-After is the network's word: it holds every attempt for its // Retry-After is the network's word: it holds every attempt for its
// own length, next to the backoff, and a Dial cuts only the backoff // own length, next to the backoff, and a Dial cuts only the backoff
r.OnReconnectFailure(1000, F::Refused, 90); r.OnReconnectFailure(1000, F::Refused, 90);
Check(r.Fresh() && r.Reconnectable() && freshAt(r, 1000, 1090), "a refusal with a Retry-After escalates at once: the fresh attempt behind the network's wait (O2 2026-10-08), the old SA kept"); Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff");
r.OnDial(); r.OnDial();
Check(freshAt(r, 1000, 1090), "…and a Dial does not cut it"); Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it");
r.OnReconnectFailure(1090, F::Refused, 3); r.OnReconnectFailure(1090, F::Refused, 3);
Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it");
r.OnDial(); r.OnDial();
@ -900,17 +900,6 @@ int main() {
k.OnSuccess(); k.OnSuccess();
Check(k.Reconnectable(), "success forgets it"); 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 // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered
r.OnReconnectFailure(1000, F::Silent); r.OnReconnectFailure(1000, F::Silent);
Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s");
@ -968,7 +957,7 @@ int main() {
Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then");
RegRecovery rb; RegRecovery rb;
rb.OnReconnectFailure(1000, F::Refused, 86400); rb.OnReconnectFailure(1000, F::Refused, 86400);
Check(freshAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same, the fresh attempt behind it"); Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same");
RegRecovery h; RegRecovery h;
h.OnFreshFailure(1000, F::Refused, 60); h.OnFreshFailure(1000, F::Refused, 60);
Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule");

View file

@ -192,20 +192,6 @@ int main() {
Reg2, "protected REGISTER"); 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, // ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp,
// or the auto fallback): every protected request's Via says UDP, the // 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. // challenge's Via stays UDP either way, and TCP is the untouched default.