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:
parent
a00b14a5a9
commit
799fd1a802
8 changed files with 185 additions and 72 deletions
68
AGENTS.md
68
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
|
||||
|
|
|
|||
|
|
@ -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<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<PersistedState> LoadState() {
|
||||
|
|
@ -697,6 +707,9 @@ std::optional<PersistedState> 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<std::uint32_t>(ps->spiUc.value_or(sa->spiUc));
|
||||
reg_.spiUs = static_cast<std::uint32_t>(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<std::string, long> 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<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);
|
||||
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<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");
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<std::string> 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");
|
||||
|
|
|
|||
|
|
@ -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(':'); }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue