diff --git a/AGENTS.md b/AGENTS.md index ddce115..4d881fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld. The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so that every decision in it has a unit test.** New protocol logic goes into a partition with a test. The shell (`main.cpp`) holds protocol decisions no -test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11 or 0.3.12: 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`: @@ -82,10 +82,7 @@ meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the 12 s wait restarted on a provisional); the reregistration's headers and its answer to a challenge (`Reregister`: the stored challenge answer, or the empty digest for a pre-0.3.11 state file, the new SPIs, `PortUcNext`; a 401 thrown -as a refusal carrying its status); the state-file round trip -(`PersistState`/`LoadState`: the `security_server` key, written empty for a -value with a quote, a backslash or a newline — the choice between that list -and the entry rebuilt from the SA is `Imsd:Ipsec`'s, with a test); the +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 @@ -264,23 +261,6 @@ Rules: bench — and O2 has not run this shape) and the escalation by one refused refresh that carries a `Retry-After` — the escalation to a fresh registration is pinned in `tests/Engine` only; a - resume echoing a multi-mechanism Security-Server list on the wire (the - choice of the stored list is pinned in `tests/Ipsec` with O2's recorded - list; KPN's has one mechanism, so on the bench the stored value and the - rebuilt one are the same bytes; O2's six-entry list has not resumed on - this code, and the state-file round trip is shell code no test - reaches); a Security-Server list `PersistState` writes empty (one with - a quote, a backslash or a newline — neither KPN's nor O2's list has - one; the resume of such a file ran with a hand-edited empty key, - 2026-10-11); a state write of a standing registration after a fresh - attempt that aborted before its SA setup (`RegState::securityServer`, - not the attempt's `ss_` — on KPN the old SA's retry after a throttled - attempt is refused 403, the throttled-attempt chain below, so no such - write follows here); the window that leaves a state file naming another - SA pair than the kernel's (a stop or a crash between a fresh attempt's - SA setup and its 200's state write — the check is pinned in `tests/Ipsec` - and ran on a hand-edited list, 2026-10-11; the window itself is not - staged); a refused initial or fresh REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or stayed silent — pinned in `tests/Engine` only); the write-off of the old @@ -447,20 +427,10 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): 45063) for the SA pairs a re-authentication would set up — the server port stays (TS 33.203 7.4.0) — and the Security-Verify (the 401's list). A registration resumed from the kernel's SAs sends the same - Security-Client as that refresh, the Authorization the state file holds - (a file from before 0.3.11: the empty digest) and the Security-Verify - from the state file (since 0.3.12); resumed from a file written before - 0.3.12, from one whose list `PersistState` wrote empty, or from one - whose list names another SA pair than the kernel holds (an attempt the - process did not live to record: its SAs installed, its 200 not - written), it sends — - on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event - SUBSCRIBE and INVITEs, until its next fresh registration — the one - entry rebuilt from the SA - (`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the - default offer in the same bytes, not a list such as O2's six, which RFC 3329 §2.3.1 has the server answer with a - 494 when it does not mirror its own; a refused resume registers fresh. - A 401 to a + 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 @@ -468,13 +438,7 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`): in-use SPIs) was refused with 503 (§13). The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`, -`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 0.3.12 -`security_server` (the registration's Security-Verify: the 401's list, -written empty when it holds a quote, a backslash or a newline; absent, -empty or naming another SA pair than the kernel's — a file from before -0.3.12, such a list, or a file an unrecorded attempt left behind — the -resume rebuilds one mechanism from the SA, logs which, and stores that), -and since +`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, @@ -782,12 +746,7 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: no challenge answer (pre-0.3.11): the refresh carries an empty digest` at a resume of a registration made before 0.3.11 — every reregistration of it carries the empty digest until its next fresh registration, and - the line repeats at each later resume of it; `Security-Verify from the - state file (1 mechanism)` / `(N mechanisms)`, `state file holds no Security-Server (a - pre-0.3.12 file, or a list the file cannot hold): Security-Verify - rebuilt from the SA as one mechanism` or `state file's Security-Server - names another SA pair than the one in the kernel (spi-s N vs M): - Security-Verify rebuilt from the SA as one mechanism` at every resume. + 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. @@ -1019,12 +978,6 @@ only the imsd side of its bus choice. behaviour, in one sentence how it was tested. A version bump is a commit titled `imsd X.Y.Z` that changes only the `Version` constant and has no body. -- **Versioning.** `0.MINOR.PATCH`: the patch number counts releases, fixes - and features alike; the minor number moves when a release changes what - a user can rely on the daemon for (0.3 added emergency calling), not - with the size of a release; the major number stays 0 — what 1.0 would - mark is not decided, and it is not an ABI change (§8.1: the ABI is - frozen). - **No AI attribution trailers.** Commits carry no `Assisted-by:`, `Co-Authored-By:` or similar lines, whoever or whatever drafted them. - **Signed commits.** Every commit is GPG-signed with the repository's @@ -1057,8 +1010,7 @@ only the imsd side of its bus choice. something else. - SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and addresses, and the state file (since 0.3.11) the last challenge answer — - nonce, cnonce and response — and (since 0.3.12) the P-CSCF's - Security-Server list. 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. - The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming the default route and gives the P-CSCF a host route; imsd adds one per diff --git a/implementations/main.cpp b/implementations/main.cpp index da21229..af2159f 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -48,7 +48,7 @@ import ImsdBus; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "0.3.12"; +constexpr const char* Version = "0.3.11"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; @@ -644,14 +644,6 @@ struct RegState { // state file; its reregistrations carry an empty digest, the pre-0.3.11 // shape, until the next fresh registration. std::string authNonce, authCnonce, authResponse; - // The Security-Verify this registration's protected REGISTERs, its - // reg-event SUBSCRIBE and its INVITEs echo: the 401's whole - // Security-Server list (RFC 3329 §2.3.1). Set on the fresh 200 and at - // the resume, never from an attempt in flight: - // the member ss_ holds a fresh attempt's 401 before that attempt can - // still abort (the throttle, an unsupported mechanism), and the - // standing registration's state writes must not store it. - std::string securityServer; }; // Writable directory for the daemon's own files: the persisted registration @@ -666,21 +658,14 @@ std::string StateDir() { // tiny JSON reader/writer for the persisted registration context. std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } -// `security_server` is the registration's Security-Verify (RegState's -// securityServer: the 401's joined list); a resume takes it from here -// since 0.3.12. The file's values are quoted without escaping; a value -// with a quote, a backslash or a newline in it (none RFC 3329's token -// syntax produces) is written empty, and the resume falls back to the SA. void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) { - std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string(); std::string j = std::format( "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", " - "\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", " - "\"security_server\": \"{}\"}}", + "\"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.authNonce, r.authCnonce, r.authResponse, ss); + 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); @@ -704,7 +689,7 @@ void DumpRaw(std::string_view name, std::string_view msg) { } struct PersistedState { - std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer; + std::optional callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse; std::optional cseq, spiUc, spiUs, expiry; }; std::optional LoadState() { @@ -725,7 +710,6 @@ std::optional LoadState() { ps.authNonce = QuotedAfter(s, "\"auth_nonce\": \""); // the three absent in pre-0.3.11 files ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \""); ps.authResponse = QuotedAfter(s, "\"auth_response\": \""); - ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files ps.cseq = IntAfter(s, "\"cseq\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUs = IntAfter(s, "\"spi_us\": "); @@ -1296,29 +1280,7 @@ private: // No fallback identity: an empty ppi omits P-Preferred-Identity and // is re-learned from the next 200's P-Associated-URI. ppi_ = ps->ppi.value_or(""); - // The Security-Verify the protected REGISTERs, the reg-event - // SUBSCRIBE and the INVITEs echo: the 401's whole Security-Server - // list (RFC 3329 §2.3.1), from the state file since 0.3.12, or the - // one-mechanism entry rebuilt from the SA — Imsd:Ipsec chooses - // (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to - // the default offer in the same bytes, not a list such as O2's six, - // which the RFC has the server answer with a 494; a refused resume - // registers fresh. - auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_); - ss_ = rsv.value; - using Source = imsd::ipsec::ResumeSecurityVerify::Source; - switch (rsv.source) { - case Source::Stored: - Log(std::format("Security-Verify from the state file ({} mechanism{})", rsv.mechanisms, rsv.mechanisms == 1 ? "" : "s")); - break; - case Source::NoneStored: - Log("state file holds no Security-Server (a pre-0.3.12 file, or a list the file cannot hold): Security-Verify rebuilt from the SA as one mechanism"); - break; - case Source::OtherSa: - Log(std::format("state file's Security-Server names another SA pair than the one in the kernel (spi-s {} vs {}): Security-Verify rebuilt from the SA as one mechanism", - rsv.storedSpiPs ? std::to_string(*rsv.storedSpiPs) : "none", sa->spiPs)); - break; - } + ss_ = sa->securityServer; // The public identity our requests are signed with (From) — restored // here so a call placed before the refresh 200 re-learns it does not // fall back to the barred temporary IMPU. @@ -1346,7 +1308,6 @@ private: reg_.spiUc = static_cast(ps->spiUc.value_or(sa->spiUc)); reg_.spiUs = static_cast(ps->spiUs.value_or(sa->spiUs)); reg_.expiry = ps->expiry.value_or(0); - reg_.securityServer = ss_; reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or(""); if (reg_.authNonce.empty()) Log("state file holds no challenge answer (pre-0.3.11): the refresh carries an empty digest"); auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1); @@ -1516,7 +1477,6 @@ private: reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response; - reg_.securityServer = ss_; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); UpdateRouteAndPpi(ok); @@ -1612,9 +1572,9 @@ private: // successful authentication, a Security-Client with new SPIs and a new // client port for the SA pairs a re-authentication would set up (the // server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's - // list, kept in the state file since 0.3.12 so a resumed registration - // echoes it too; a resume from an older file sends the one entry - // rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401 + // 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 diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm index df29ace..b5e6105 100644 --- a/interfaces/Imsd-Ipsec.cppm +++ b/interfaces/Imsd-Ipsec.cppm @@ -13,13 +13,11 @@ alg (HMAC-SHA-1-96 or HMAC-MD5-96) keyed with IK; confidentiality is the negotiated ealg keyed with CK. Both come from the Security-Server mechanism the P-CSCF selected in its 401 (parsed here), and this module turns the negotiated SPIs/ports/keys into the exact argv lists the daemon shell hands to -`ip`; for a warm resume it reconstructs the SPIs and ports back out of `ip -xfrm state`/`policy` text — the P-CSCF ports for the resume's flow, and the -SPIs in use, which a reregistration's new SPIs must differ from (and the -state file falls back to) — and a one-mechanism Security-Server value that -stands in for the Security-Verify when the state file holds no list for the -SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12 -the 401's own list is stored there). +`ip`; for a warm resume it reconstructs the SPIs, ports, and Security-Server +value back out of `ip xfrm state`/`policy` text — the Security-Verify and +the P-CSCF ports for the resume's flow, and the SPIs in use, which a +reregistration's new SPIs must differ from (and the state file falls back +to). Pure std C++ (the shell runs the commands and captures the text). Command shapes and the parser are pinned by tests/Ipsec. @@ -310,7 +308,7 @@ export namespace imsd::ipsec { // What a warm SA in the kernel tells us — enough for a refresh re-REGISTER // to re-bind without re-authenticating. struct ExistingSa { - std::string securityServer; // one-mechanism Security-Server value: the resume's Security-Verify when the state file holds no list for this pair + std::string securityServer; // reconstructed Security-Server header value int portPs = 0; int portPc = 0; std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one) @@ -405,35 +403,6 @@ export namespace imsd::ipsec { return sa; } - // The Security-Verify a resume sends, and where it came from: the state - // file's list when the entry carrying its SPIs names the SA pair the - // kernel holds (a file an attempt the process did not live to record - // left behind names another pair), else the one-mechanism entry rebuilt - // from the SA — for an empty or unparseable value too. - struct ResumeSecurityVerify { - enum class Source { Stored, NoneStored, OtherSa }; - Source source = Source::NoneStored; - std::string value; - std::size_t mechanisms = 0; // of the stored list, when it is used - std::optional storedSpiPs; // the stored list's spi-s (OtherSa); nullopt = none parsed - }; - ResumeSecurityVerify ChooseResumeSecurityVerify(std::string_view stored, const ExistingSa& sa, std::string_view offeredEalg) { - ResumeSecurityVerify r; - r.value = sa.securityServer; - if (stored.empty()) return r; - r.source = ResumeSecurityVerify::Source::OtherSa; - if (auto parsed = ParseSecurityServer(stored, "hmac-sha-1-96", offeredEalg)) { - const SecurityMechanism& c = parsed->Carrier(); - r.storedSpiPs = c.spiPs; - if (c.spiPc == sa.spiPc && c.spiPs == sa.spiPs && c.portPc == sa.portPc && c.portPs == sa.portPs) { - r.source = ResumeSecurityVerify::Source::Stored; - r.value = std::string(stored); - r.mechanisms = parsed->mechanisms.size(); - } - } - return r; - } - // Every local address holding an SA to the P-CSCF, once each, in kernel // order: where a registration made on another address of the link (the // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm index dcb28de..2991a4b 100644 --- a/interfaces/Imsd-Messages.cppm +++ b/interfaces/Imsd-Messages.cppm @@ -29,9 +29,8 @@ import :Sip; export namespace imsd::msg { // Everything the builders need that is stable for the life of a - // registration. Ports default to the fixed protected values; route and - // ppi are filled in from the REGISTER 200 OK, securityServer from the - // 401 (at a resume, from the state file). + // registration. Ports default to the fixed protected values; route/ppi/ + // securityServer are filled in from the REGISTER 200 OK. struct Context { imsd::aka::Identity id; std::string local; @@ -64,7 +63,7 @@ export namespace imsd::msg { // self-targeted requests like reg-event // SUBSCRIBE; the temporary (IMSI-derived) // IMPU is barred for anything but REGISTER - std::string securityServer; // Security-Verify value: the 401's Security-Server list (from the state file on a resume) + std::string securityServer; // Security-Verify value (the network's ss) // P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli // location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header — // never fabricate one: the network reads it to learn which access diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp index 1f63107..fcb1d89 100644 --- a/tests/Ipsec/main.cpp +++ b/tests/Ipsec/main.cpp @@ -196,38 +196,6 @@ int main() { auto bare = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null;", "hmac-sha-1-96", "null"); Check(bare && bare->mechanisms.size() == 1 && bare->offerMatched && !bare->Carrier().spiPs && !bare->Carrier().portPs, "no spi-s: absent, not zero"); } - // ---- the resume's Security-Verify: the stored list only for the SA pair the kernel holds - { - using Source = ResumeSecurityVerify::Source; - ExistingSa sa; - sa.spiPc = 129427852u; sa.spiPs = 133937015u; sa.portPc = 6051; sa.portPs = 6060; - sa.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=129427852; spi-s=133937015; port-c=6051; port-s=6060"; - auto ok = ChooseResumeSecurityVerify(O2, sa, "null"); - Check(ok.source == Source::Stored && ok.value == O2 && ok.mechanisms == 6, "resume: O2's six-entry list whose carrier is the kernel's pair -> the list verbatim, six mechanisms"); - auto okAes = ChooseResumeSecurityVerify(O2, sa, "aes-cbc"); - Check(okAes.source == Source::Stored && okAes.value == O2, "resume: the same under the aes offer (the carrier does not depend on the offer)"); - constexpr std::pair off[] = { - {"spi-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427853;spi-s=133937015;port-c=6051;port-s=6060"}, - {"spi-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937016;port-c=6051;port-s=6060"}, - {"port-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6052;port-s=6060"}, - {"port-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6061"}, - }; - for (const auto& [field, text] : off) { - auto r = ChooseResumeSecurityVerify(text, sa, "null"); - Check(r.source == Source::OtherSa && r.value == sa.securityServer, std::format("resume: {} off by one -> the rebuilt entry", field)); - } - auto offSpiS = ChooseResumeSecurityVerify(off[1].second, sa, "null"); - Check(offSpiS.storedSpiPs && *offSpiS.storedSpiPs == 133937016u, "resume: the stored spi-s is reported for the log line"); - auto none = ChooseResumeSecurityVerify("", sa, "null"); - Check(none.source == Source::NoneStored && none.value == sa.securityServer, "resume: nothing stored -> the rebuilt entry"); - auto junk = ChooseResumeSecurityVerify("not a list", sa, "null"); - Check(junk.source == Source::OtherSa && junk.value == sa.securityServer && !junk.storedSpiPs, "resume: an unparseable value -> the rebuilt entry, no spi-s to report"); - ExistingSa kpn; - kpn.spiPc = 49889723u; kpn.spiPs = 49889722u; kpn.portPc = 33142; kpn.portPs = 6000; - kpn.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=49889723; spi-s=49889722; port-c=33142; port-s=6000"; - auto k = ChooseResumeSecurityVerify(kpn.securityServer, kpn, "aes-cbc"); - Check(k.source == Source::Stored && k.value == kpn.securityServer && k.mechanisms == 1, "resume: KPN's one-entry list for its pair -> the list, one mechanism"); - } // ---- selection rules and syntax corners { // spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)