engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters

The keepalive refresh and the resume's re-REGISTER carried an initial
registration's first-REGISTER Authorization — an empty nonce and
response — in the second REGISTER's frame, with a Security-Client
naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a
reregistration carry the Authorization
of the last successful authentication — the last received nonce and
the last calculated response — and a Security-Client with the new
parameter values for the two SA pairs a re-authentication would set
up; TS 33.203 7.4.0 keeps the server ports and changes the client
ports. O2 UK refused the refresh of the old shape with 503 on every
occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone
included) and accepted the fresh registrations that followed; KPN
accepted the old shape without a challenge.

The fresh registration now records the 401's nonce, our cnonce and
the response in the registration state (auth_nonce, auth_cnonce,
auth_response in the state file); a reregistration sends them
verbatim, mints new SPIs and proposes PortUcNext (45063) as the
client port with the server port unchanged. A registration resumed from a
state file written before this change has no answer: its
reregistrations carry the empty digest, as before, until its next
fresh registration, and the resume says so. A 401 to a reregistration asks
for re-authentication over temporary SAs on the proposed ports (TS
24.229 5.1.1.5.1), which imsd does not implement: the refresh throws
it as a refusal and RegRecovery makes the next step the fresh
registration, replacing the in-place answer over the old SAs.

Tested with the Messages and Engine suites and on the dev phone (KPN)
with a build of this commit's tree: a resume from the previous state
file, a fresh registration, a resume that sent the stored answer,
three refreshes at a 120-s interval, an outgoing call to a bench line
and an incoming call's ring.
This commit is contained in:
Jorijn van der Graaf 2026-10-10 21:00:28 +02:00
commit 799fd1a802
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
8 changed files with 185 additions and 72 deletions

View file

@ -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 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 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 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; `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 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
@ -253,8 +256,11 @@ Rules:
on the screenshot carry no timestamps; KPN's refusals, all 403, carry on the screenshot carry no timestamps; KPN's refusals, all 403, carry
none) and a `Dial` bringing a recovery step none) and a `Dial` bringing a recovery step
forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards
it) is pinned in `tests/Engine` only; the escalation by one refused it) is pinned in `tests/Engine` only; a 401 to a reregistration (the
refresh that carries a `Retry-After` is pinned in `tests/Engine` only; a 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 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
@ -412,10 +418,31 @@ 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. 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`, 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 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
@ -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 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 or a kinds of failure reach it: a **refusal** (the network answered a refresh with
fresh REGISTER with a final other than 200/401) and **silence** (the flow died 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). 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
@ -502,7 +532,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), 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 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`). |
@ -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. - `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.
@ -973,7 +1009,8 @@ 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. 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. (`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
@ -1009,7 +1046,14 @@ the related code.
second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the 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 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` `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 - **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

@ -54,6 +54,7 @@ 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) {
@ -296,10 +297,12 @@ 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 refresh, answered // A REGISTER the network refused: the protected one, or a reregistration,
// with a final other than 200 (a refresh's first 401 is its challenge); the // answered with a final other than 200 (a reregistration's 401 asks for a
// unprotected initial one answered with a final from 300 up (its 401 is the // re-authentication imsd does not do over the old SAs: a refusal carrying
// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. // 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 { struct RefusedError : std::runtime_error {
int status; int status;
long retryAfter; long retryAfter;
@ -635,6 +638,12 @@ 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
@ -653,9 +662,10 @@ 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.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse);
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);
@ -679,7 +689,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; std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse;
std::optional<long> cseq, spiUc, spiUs, expiry; std::optional<long> cseq, spiUc, spiUs, expiry;
}; };
std::optional<PersistedState> LoadState() { std::optional<PersistedState> LoadState() {
@ -697,6 +707,9 @@ 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\": ");
@ -1295,7 +1308,9 @@ 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);
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; 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);
@ -1461,6 +1476,7 @@ 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);
@ -1551,36 +1567,36 @@ private:
throw std::runtime_error("no 401 to unprotected REGISTER"); throw std::runtime_error("no 401 to unprotected REGISTER");
} }
// re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). // A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2:
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { // the same Call-ID and Contact, CSeq+1, the Authorization of the last
std::string auth = imsd::msg::AuthEmpty(ctx_); // successful authentication, a Security-Client with new SPIs and a new
long cseq = startCseq; // client port for the SA pairs a re-authentication would set up (the
bool challenged = false; // server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's
for (;;) { // list for a registration this process made fresh; for a resumed one
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); // 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<std::string, long> 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"); if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
auto st = imsd::sip::Status(m); auto st = imsd::sip::Status(m);
if (st == 200) return {m, cseq}; 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<std::uint8_t> rnd(raw->begin(), raw->begin() + 16);
std::vector<std::uint8_t> 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); 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) {
auto svc = imsd::sip::Headers(okMsg, "Service-Route"); auto svc = imsd::sip::Headers(okMsg, "Service-Route");
@ -1662,7 +1678,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, reg_.spiUc, reg_.spiUs); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
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);
@ -1670,11 +1686,12 @@ 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 makes the // — a refusal with a Retry-After of at least a second, or a 401
// fresh registration the next step, behind the network's wait; // (the refresh throws a challenge as a refusal), makes the fresh
// one without gets one more refresh first, a second refusal then // registration the next step, behind any wait the network set; a
// escalates — and the flow is dropped so the next step runs // refusal with neither gets one more refresh first, a second
// after the wait. // 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);
@ -1770,7 +1787,7 @@ private:
} }
try { try {
RefreshPani(); 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; 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,7 +60,9 @@ 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
// (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, // 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
@ -79,12 +81,15 @@ export namespace imsd::engine {
// a fresh registration after two (one 503 may be a momentary hiccup a // a fresh registration after two (one 503 may be a momentary hiccup a
// retried refresh clears); one with such a Retry-After escalates at // retried refresh clears); one with such a Retry-After escalates at
// once — it is the network's considered answer (O2 2026-10-08: the // 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). // refresh after the first wait was refused again, 414 s then 772 s) —
// Silence escalates after two reconnect cycles // 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, an unusable challenge, a // that fails short of a refusal: unanswered, a failed send (the resume
// failed send (the resume at start gives up after two unanswered // 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
@ -191,7 +196,7 @@ export namespace imsd::engine {
// 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 of at least a second holds every attempt for its own
// length (up to RetryAfterMax), whichever is longer, and makes the // 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 // 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) {
@ -204,7 +209,7 @@ 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) 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,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 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 so a refresh re-REGISTER can value back out of `ip xfrm state`/`policy` text — the Security-Verify and
advertise the SPIs actually in use. 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 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.
@ -309,7 +311,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 (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 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,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); 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) { 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 // 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, // 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 (AuthEmpty for the refresh's first try, AuthAka // Authorization line: AuthAka with the challenge's answer for the
// once challenged). // initial registration's second REGISTER and, verbatim, for every
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) { // 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<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),
@ -233,7 +247,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), SecurityClient(c, spiUc, spiUs, portUc ? portUc : c.portUc, c.portUs),
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,6 +24,12 @@ 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

@ -900,6 +900,17 @@ 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");

View file

@ -192,6 +192,20 @@ 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.