Compare commits

..
5 changed files with 26 additions and 178 deletions

View file

@ -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

View file

@ -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<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer;
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() {
@ -725,7 +710,6 @@ std::optional<PersistedState> 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<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);
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

View file

@ -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<std::uint32_t> 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.

View file

@ -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

View file

@ -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<std::string_view, std::string_view> 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)