2026-07-22 22:53:28 +02:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
|
|
|
|
|
|
// lint-disable-file fixed-width-types
|
|
|
|
|
/*
|
|
|
|
|
Imsd:Ipsec — IMS security-association plumbing expressed as `ip xfrm` command
|
|
|
|
|
lines, plus a reader for an SA already installed in the kernel.
|
|
|
|
|
|
|
|
|
|
A registered IMS UE keeps two ESP SA pairs with the P-CSCF (TS 33.203):
|
|
|
|
|
reqid 1 carries the UE-initiated client flow (protected client port ⇄ P-CSCF
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
server port), reqid 2 the terminating server flow. Integrity is the negotiated
|
|
|
|
|
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
|
imsd: a resumed registration echoes the 401's Security-Server list from the state file
A warm resume rebuilt its Security-Verify from the kernel's SA: one
mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329
§2.3.1 has the client mirror the server's whole list. The fresh
registration kept the 401's list in memory, so a registration this
process made fresh echoed it; after a restart, the resumed
registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and
its INVITEs carried the rebuilt entry until the next fresh registration.
KPN's one-entry answer to the default offer is the rebuilt entry byte
for byte; against a six-entry list (O2's) the mirror is broken, which
the RFC has the server answer with a 494, and a refused resume
registers fresh.
The fresh registration now stores the joined Security-Server value in the
state file (security_server, next to the challenge answer) and the resume
takes it from there, logging the mechanism count, when the entry
carrying its SPIs names the SA pair in the kernel: a file an attempt the
process did not live to record left behind (its SAs installed, its 200
not written) names another. A file from before this change holds none.
In both cases the resume falls back to the rebuilt entry, says which,
and the registration's next state write stores that entry, which is what
it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's
recorded list and KPN's. A value with a quote, a backslash or a newline in it is
written empty, which the resume treats as absent; RFC 3329's token
syntax produces none of these, a quoted-string extension parameter
could carry the first two.
Tested on the dev phone (KPN) with the build of this tree: a resume from
a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so
do one from a file whose list names another SA pair and one whose list
is empty; two keepalives at a 120-s interval; a fresh registration
stores KPN's list byte-for-byte as the journal shows it received; a
restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
|
|
|
`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).
|
2026-07-22 22:53:28 +02:00
|
|
|
|
|
|
|
|
Pure std C++ (the shell runs the commands and captures the text). Command
|
|
|
|
|
shapes and the parser are pinned by tests/Ipsec.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export module Imsd:Ipsec;
|
|
|
|
|
import std;
|
|
|
|
|
import :Util;
|
|
|
|
|
|
|
|
|
|
export namespace imsd::ipsec {
|
|
|
|
|
|
|
|
|
|
// UE protected ports are fixed by the stack; P-CSCF ports come from the
|
|
|
|
|
// Security-Server the network offered (fresh) or the kernel (resume).
|
|
|
|
|
struct SaParams {
|
|
|
|
|
std::string local; // UE global IPv6/4 on the ims PDN
|
|
|
|
|
std::string pcscf; // P-CSCF address
|
|
|
|
|
std::vector<std::uint8_t> ik; // integrity key (auth)
|
|
|
|
|
std::vector<std::uint8_t> ck; // cipher key (enc)
|
|
|
|
|
std::uint32_t spiUc = 0; // UE client inbound SA (P->UE, reqid 1)
|
|
|
|
|
std::uint32_t spiUs = 0; // UE server inbound SA (P->UE, reqid 2)
|
|
|
|
|
std::uint32_t spiPc = 0; // P-CSCF client outbound SA (UE->P, reqid 2)
|
|
|
|
|
std::uint32_t spiPs = 0; // P-CSCF server outbound SA (UE->P, reqid 1)
|
|
|
|
|
int portUc = imsd::util::PortUc; // UE protected client port
|
|
|
|
|
int portUs = imsd::util::PortUs; // UE protected server port
|
|
|
|
|
int portPs = 0; // P-CSCF server port (UE connects its TCP here)
|
|
|
|
|
int portPc = 0; // P-CSCF client port
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
std::string alg = "hmac-sha-1-96"; // hmac-sha-1-96 | hmac-md5-96
|
2026-07-22 22:53:28 +02:00
|
|
|
std::string ealg = "aes-cbc"; // aes-cbc | des-ede3-cbc/3des | null
|
|
|
|
|
};
|
|
|
|
|
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
// Kernel name of a TS 33.203 ESP integrity algorithm; nullopt for one we
|
|
|
|
|
// cannot install. Both take IK itself as the key: HMAC zero-pads a key
|
|
|
|
|
// shorter than its block, so the IK||0x00000000 the spec writes for
|
|
|
|
|
// SHA-1 hashes identically to the 128-bit IK handed to the kernel.
|
|
|
|
|
std::optional<std::string> KernelAuth(std::string_view alg) {
|
|
|
|
|
if (alg == "hmac-sha-1-96") return "hmac(sha1)";
|
|
|
|
|
if (alg == "hmac-md5-96") return "hmac(md5)";
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Canonical ealg name: absent is the null cipher (RFC 3329 §2.2), "3des"
|
|
|
|
|
// (imsd's EALG alias) is des-ede3-cbc. Used on both sides of the
|
|
|
|
|
// offer/answer comparison.
|
|
|
|
|
std::string NormalizeEalg(std::string_view ealg) {
|
|
|
|
|
if (ealg.empty()) return "null";
|
|
|
|
|
if (ealg == "3des") return "des-ede3-cbc";
|
|
|
|
|
return std::string(ealg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The ESP ciphers EncArgs can key from CK (canonical names). A P-CSCF
|
|
|
|
|
// selecting anything else must be refused by the caller, never
|
|
|
|
|
// installed as a silent null.
|
|
|
|
|
bool KnownEalg(std::string_view ealg) {
|
|
|
|
|
return ealg == "null" || ealg == "aes-cbc" || ealg == "des-ede3-cbc";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 22:53:28 +02:00
|
|
|
namespace detail {
|
|
|
|
|
// ESP cipher argv triple for `ip xfrm state add ... <enc>`, matching
|
|
|
|
|
// imscall.setup_sa: aes-cbc keyed with CK; 3DES uses CK||CK[:8] to
|
|
|
|
|
// reach 24 bytes; anything else is the null cipher (empty key).
|
|
|
|
|
inline std::vector<std::string> EncArgs(const SaParams& p) {
|
|
|
|
|
if (p.ealg == "aes-cbc") return {"enc", "cbc(aes)", std::format("0x{}", imsd::util::ToHex(p.ck))};
|
|
|
|
|
if (p.ealg == "des-ede3-cbc" || p.ealg == "3des") {
|
|
|
|
|
std::vector<std::uint8_t> k = p.ck;
|
|
|
|
|
k.insert(k.end(), p.ck.begin(), p.ck.begin() + 8);
|
|
|
|
|
return {"enc", "cbc(des3_ede)", std::format("0x{}", imsd::util::ToHex(k))};
|
|
|
|
|
}
|
|
|
|
|
return {"enc", "cipher_null", ""};
|
|
|
|
|
}
|
|
|
|
|
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
inline std::vector<std::string> StateAdd(std::string_view src, std::string_view dst, std::uint32_t spi, int reqid, std::string_view auth, std::string_view ikx, const std::vector<std::string>& enc) {
|
2026-07-22 22:53:28 +02:00
|
|
|
std::vector<std::string> a = {
|
|
|
|
|
"ip", "xfrm", "state", "add",
|
|
|
|
|
"src", std::string(src), "dst", std::string(dst),
|
|
|
|
|
"proto", "esp", "spi", std::to_string(spi),
|
|
|
|
|
"mode", "transport", "reqid", std::to_string(reqid),
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
"auth-trunc", std::string(auth), std::string(ikx), "96"};
|
2026-07-22 22:53:28 +02:00
|
|
|
a.insert(a.end(), enc.begin(), enc.end());
|
|
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline std::vector<std::string> PolicyAdd(std::string_view src, std::string_view dst, int plen, int sport, int dport, std::string_view dir, std::string_view tsrc, std::string_view tdst, int reqid) {
|
|
|
|
|
return {
|
|
|
|
|
"ip", "xfrm", "policy", "add",
|
|
|
|
|
"src", std::format("{}/{}", src, plen),
|
|
|
|
|
"dst", std::format("{}/{}", dst, plen),
|
|
|
|
|
"sport", std::to_string(sport), "dport", std::to_string(dport),
|
|
|
|
|
"dir", std::string(dir),
|
|
|
|
|
"tmpl", "src", std::string(tsrc), "dst", std::string(tdst),
|
|
|
|
|
"proto", "esp", "reqid", std::to_string(reqid),
|
|
|
|
|
"mode", "transport"};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush
|
|
|
|
|
// state+policy, add the four ESP states, add the four transport policies.
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
// Order matches imscall.setup_sa exactly. Callers validate p.alg with
|
|
|
|
|
// KernelAuth() first; an unknown alg goes to the kernel by its own name
|
|
|
|
|
// and is rejected there, never mapped to a default.
|
2026-07-22 22:53:28 +02:00
|
|
|
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
|
|
|
|
|
const std::string& local = p.local;
|
|
|
|
|
const std::string& pcscf = p.pcscf;
|
|
|
|
|
std::string ikx = std::format("0x{}", imsd::util::ToHex(p.ik));
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
std::string auth = KernelAuth(p.alg).value_or(p.alg);
|
2026-07-22 22:53:28 +02:00
|
|
|
std::vector<std::string> enc = detail::EncArgs(p);
|
|
|
|
|
int plen = imsd::util::Is6(local) ? 128 : 32;
|
|
|
|
|
std::vector<std::vector<std::string>> cmds;
|
|
|
|
|
cmds.push_back({"ip", "xfrm", "state", "flush"});
|
|
|
|
|
cmds.push_back({"ip", "xfrm", "policy", "flush"});
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc));
|
|
|
|
|
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc));
|
|
|
|
|
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc));
|
|
|
|
|
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUs, 2, auth, ikx, enc));
|
2026-07-22 22:53:28 +02:00
|
|
|
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUc, p.portPs, "out", local, pcscf, 1));
|
|
|
|
|
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPs, p.portUc, "in", pcscf, local, 1));
|
|
|
|
|
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUs, p.portPc, "out", local, pcscf, 2));
|
|
|
|
|
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPc, p.portUs, "in", pcscf, local, 2));
|
|
|
|
|
return cmds;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<std::vector<std::string>> BuildTeardownCommands() {
|
|
|
|
|
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
|
|
|
|
|
}
|
|
|
|
|
|
ipsec: build the SAs from the Security-Server mechanism the UE selects
A P-CSCF may answer the challenge with every mechanism it supports and
attach the SPIs and ports to the one it applied. O2 UK's core lists six
ipsec-3gpp entries with q-values, md5 first, and marks the fourth (sha1,
no ealg). imsd took the first ealg= anywhere in the header - the 3DES of
an entry the P-CSCF had not selected - and installed SAs the far end
could not decrypt: every protected packet after the 401 was dropped at
its IPsec layer, over TCP and UDP alike, with nothing to answer.
The 401 handling now parses the Security-Server as a mechanism list
(commas outside quotes, parameters on semicolons, names and values
case-insensitive) and selects the highest-q ipsec-3gpp mechanism that
matches what Security-Client offered (RFC 3329 2.3.1, TS 24.229
5.1.1.5.1): alg equal to ours, ealg (absent = null) equal to the offer.
The SPIs and ports are per registration and come from the entry carrying
them. When no entry matches the offer, the entry carrying the SPIs is
installed as listed and the journal says so; hmac-md5-96 becomes
hmac(md5) with IK as the key. A single-mechanism header selects itself:
on KPN the SAs are the same as before (unit-pinned argv, verified on the
phone). An answer that cannot be used - no ipsec-3gpp entry, a missing
spi/port, an alg, ealg, prot or mod the kernel cannot be handed - defers
the bring-up on the throttle schedule with the reason, before the AKA is
spent, instead of a fatal that would restart imsd every RestartSec with
an initial REGISTER each time; spi-s=0 stays the throttle deferral.
Repeated Security-Server header lines are joined into the one list
Security-Verify echoes.
The journal shows the Security-Server line, the selected mechanism, the
SA parameters, and any ip xfrm command that fails (keys masked) at every
fresh registration - the silence this bug produced had no line to read.
2026-09-19 21:36:57 +02:00
|
|
|
// ---- Security-Server (RFC 3329 §2.2) -----------------------------------
|
|
|
|
|
//
|
|
|
|
|
// The P-CSCF answers the challenge with the mechanisms it supports, each
|
|
|
|
|
// with a preference, plus the SPIs and ports it assigned to this
|
|
|
|
|
// registration. The UE takes the highest-preference mechanism that is
|
|
|
|
|
// in that list AND that it offered (RFC 3329 §2.3.1, TS 24.229
|
|
|
|
|
// §5.1.1.5.1, TS 33.203 §7.2); imsd offers exactly one combination
|
|
|
|
|
// (hmac-sha-1-96 + EALG). The SPIs/ports are per registration, not per
|
|
|
|
|
// mechanism: they come from whichever entry carries them (O2 UK's
|
|
|
|
|
// Mavenir core lists six entries and puts them on the one it applied).
|
|
|
|
|
// imsd ≤ 0.3.5 took the first `ealg=` anywhere in the header and
|
|
|
|
|
// installed 3DES against a P-CSCF expecting the null cipher: every
|
|
|
|
|
// protected packet was undecryptable to it (alyx, 2026-09-17; journal/ims
|
|
|
|
|
// ledger F11).
|
|
|
|
|
|
|
|
|
|
struct SecurityMechanism {
|
|
|
|
|
std::string text; // the mechanism verbatim (trimmed)
|
|
|
|
|
std::string name; // mechanism-name, lower-case ("ipsec-3gpp")
|
|
|
|
|
std::optional<int> qMilli; // q in thousandths (q=0.94 -> 940)
|
|
|
|
|
std::string alg; // lower-case; empty when absent
|
|
|
|
|
std::string ealg; // lower-case; empty when absent (= null cipher)
|
|
|
|
|
std::string prot; // lower-case; empty when absent (= esp)
|
|
|
|
|
std::string mod; // lower-case; empty when absent (= trans)
|
|
|
|
|
std::optional<std::uint32_t> spiPc;
|
|
|
|
|
std::optional<std::uint32_t> spiPs;
|
|
|
|
|
std::optional<int> portPc;
|
|
|
|
|
std::optional<int> portPs;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct SecurityServer {
|
|
|
|
|
std::vector<SecurityMechanism> mechanisms; // in header order
|
|
|
|
|
std::size_t selected = 0; // algorithms: the ipsec-3gpp entry the UE selects
|
|
|
|
|
std::size_t carrier = 0; // SPIs/ports: the entry carrying spi-s (== selected when it does)
|
|
|
|
|
bool offerMatched = false; // selected is what we offered; false = the fallback below
|
|
|
|
|
const SecurityMechanism& Selected() const { return mechanisms[selected]; }
|
|
|
|
|
const SecurityMechanism& Carrier() const { return mechanisms[carrier]; }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
namespace detail {
|
|
|
|
|
inline std::string_view TrimWs(std::string_view s) {
|
|
|
|
|
auto ws = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
|
|
|
|
|
while (!s.empty() && ws(s.front())) s.remove_prefix(1);
|
|
|
|
|
while (!s.empty() && ws(s.back())) s.remove_suffix(1);
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline std::string Lower(std::string_view s) {
|
|
|
|
|
std::string out(s);
|
|
|
|
|
for (char& c : out) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Split on `sep` outside double quotes: a d-qop="auth,auth-int"
|
|
|
|
|
// value must not split the mechanism list.
|
|
|
|
|
inline std::vector<std::string_view> SplitUnquoted(std::string_view s, char sep) {
|
|
|
|
|
std::vector<std::string_view> out;
|
|
|
|
|
bool quoted = false;
|
|
|
|
|
std::size_t start = 0;
|
|
|
|
|
for (std::size_t i = 0; i < s.size(); i++) {
|
|
|
|
|
if (s[i] == '"') quoted = !quoted;
|
|
|
|
|
else if (s[i] == sep && !quoted) { out.push_back(s.substr(start, i - start)); start = i + 1; }
|
|
|
|
|
}
|
|
|
|
|
out.push_back(s.substr(start));
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RFC 3261 qvalue ("0" ["." 0*3DIGIT] / "1" ["." 0*3("0")]) in thousandths.
|
|
|
|
|
inline std::optional<int> ParseQMilli(std::string_view v) {
|
|
|
|
|
std::size_t dot = v.find('.');
|
|
|
|
|
std::string_view whole = v.substr(0, dot);
|
|
|
|
|
std::string_view frac = dot == std::string_view::npos ? std::string_view{} : v.substr(dot + 1);
|
|
|
|
|
if ((whole.empty() && frac.empty()) || whole.size() > 1 || frac.size() > 3) return std::nullopt;
|
|
|
|
|
int q = 0;
|
|
|
|
|
for (char c : whole) { if (c < '0' || c > '9') return std::nullopt; q = (c - '0') * 1000; }
|
|
|
|
|
int scale = 100;
|
|
|
|
|
for (char c : frac) { if (c < '0' || c > '9') return std::nullopt; q += (c - '0') * scale; scale /= 10; }
|
|
|
|
|
return q;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
template <class T>
|
|
|
|
|
inline std::optional<T> ParseWhole(std::string_view v) {
|
|
|
|
|
if (v.empty()) return std::nullopt;
|
|
|
|
|
T out{};
|
|
|
|
|
auto [p, ec] = std::from_chars(v.data(), v.data() + v.size(), out);
|
|
|
|
|
if (ec != std::errc{} || p != v.data() + v.size()) return std::nullopt;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline std::optional<int> ParsePort(std::string_view v) {
|
|
|
|
|
auto p = ParseWhole<int>(v);
|
|
|
|
|
if (!p || *p < 1 || *p > 65535) return std::nullopt;
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Index of the highest-q mechanism satisfying `pred` (absent q loses
|
|
|
|
|
// to any q; ties keep the earlier entry); nullopt when none does.
|
|
|
|
|
template <class Pred>
|
|
|
|
|
inline std::optional<std::size_t> Best(const std::vector<SecurityMechanism>& ms, Pred pred) {
|
|
|
|
|
std::optional<std::size_t> b;
|
|
|
|
|
for (std::size_t i = 0; i < ms.size(); i++) {
|
|
|
|
|
if (!pred(ms[i])) continue;
|
|
|
|
|
if (!b || ms[i].qMilli.value_or(-1) > ms[*b].qMilli.value_or(-1)) b = i;
|
|
|
|
|
}
|
|
|
|
|
return b;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse a Security-Server value and select against our offer. Names,
|
|
|
|
|
// parameter names and alg/ealg/prot/mod values are tokens
|
|
|
|
|
// (case-insensitive) and come back lower-case. `selected` = the
|
|
|
|
|
// highest-q ipsec-3gpp mechanism whose alg (absent = the offered one)
|
|
|
|
|
// and ealg (absent = null) equal what we offered; when none matches,
|
|
|
|
|
// the highest-q ipsec-3gpp entry carrying spi-s, else port-s, else the
|
|
|
|
|
// first ipsec-3gpp entry, with offerMatched = false so the caller can
|
|
|
|
|
// say so. `carrier` = selected when it carries spi-s, else the highest-q
|
|
|
|
|
// ipsec-3gpp entry that does. nullopt when the value holds no ipsec-3gpp
|
|
|
|
|
// mechanism at all.
|
|
|
|
|
std::optional<SecurityServer> ParseSecurityServer(std::string_view value, std::string_view offeredAlg, std::string_view offeredEalg) {
|
|
|
|
|
SecurityServer ss;
|
|
|
|
|
for (std::string_view part : detail::SplitUnquoted(value, ',')) {
|
|
|
|
|
part = detail::TrimWs(part);
|
|
|
|
|
if (part.empty()) continue;
|
|
|
|
|
SecurityMechanism m;
|
|
|
|
|
m.text = std::string(part);
|
|
|
|
|
bool first = true;
|
|
|
|
|
for (std::string_view tok : detail::SplitUnquoted(part, ';')) {
|
|
|
|
|
tok = detail::TrimWs(tok);
|
|
|
|
|
if (first) { m.name = detail::Lower(tok); first = false; continue; }
|
|
|
|
|
if (tok.empty()) continue;
|
|
|
|
|
std::size_t eq = tok.find('=');
|
|
|
|
|
std::string key = detail::Lower(detail::TrimWs(tok.substr(0, eq)));
|
|
|
|
|
std::string_view val = eq == std::string_view::npos ? std::string_view{} : detail::TrimWs(tok.substr(eq + 1));
|
|
|
|
|
if (val.size() >= 2 && val.front() == '"' && val.back() == '"') val = val.substr(1, val.size() - 2);
|
|
|
|
|
if (key == "q") m.qMilli = detail::ParseQMilli(val);
|
|
|
|
|
else if (key == "alg") m.alg = detail::Lower(val);
|
|
|
|
|
else if (key == "ealg") m.ealg = detail::Lower(val);
|
|
|
|
|
else if (key == "prot") m.prot = detail::Lower(val);
|
|
|
|
|
else if (key == "mod") m.mod = detail::Lower(val);
|
|
|
|
|
else if (key == "spi-c") m.spiPc = detail::ParseWhole<std::uint32_t>(val);
|
|
|
|
|
else if (key == "spi-s") m.spiPs = detail::ParseWhole<std::uint32_t>(val);
|
|
|
|
|
else if (key == "port-c") m.portPc = detail::ParsePort(val);
|
|
|
|
|
else if (key == "port-s") m.portPs = detail::ParsePort(val);
|
|
|
|
|
}
|
|
|
|
|
ss.mechanisms.push_back(std::move(m));
|
|
|
|
|
}
|
|
|
|
|
auto ipsec = [](const SecurityMechanism& m) { return m.name == "ipsec-3gpp"; };
|
|
|
|
|
auto carries = [&](const SecurityMechanism& m) { return ipsec(m) && m.spiPs.has_value(); };
|
|
|
|
|
std::string wantAlg = detail::Lower(offeredAlg);
|
|
|
|
|
std::string wantEalg = NormalizeEalg(detail::Lower(offeredEalg));
|
|
|
|
|
auto offered = [&](const SecurityMechanism& m) {
|
|
|
|
|
return ipsec(m) && (m.alg.empty() || m.alg == wantAlg) && NormalizeEalg(m.ealg) == wantEalg;
|
|
|
|
|
};
|
|
|
|
|
auto firstIpsec = std::ranges::find_if(ss.mechanisms, ipsec);
|
|
|
|
|
if (firstIpsec == ss.mechanisms.end()) return std::nullopt;
|
|
|
|
|
if (auto i = detail::Best(ss.mechanisms, offered)) { ss.selected = *i; ss.offerMatched = true; }
|
|
|
|
|
else if (auto i = detail::Best(ss.mechanisms, carries)) ss.selected = *i;
|
|
|
|
|
else if (auto i = detail::Best(ss.mechanisms, [&](const SecurityMechanism& m) { return ipsec(m) && m.portPs.has_value(); })) ss.selected = *i;
|
|
|
|
|
else ss.selected = static_cast<std::size_t>(firstIpsec - ss.mechanisms.begin());
|
|
|
|
|
ss.carrier = ss.selected;
|
|
|
|
|
if (!ss.Selected().spiPs) if (auto i = detail::Best(ss.mechanisms, carries)) ss.carrier = *i;
|
|
|
|
|
return ss;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 22:53:28 +02:00
|
|
|
// What a warm SA in the kernel tells us — enough for a refresh re-REGISTER
|
|
|
|
|
// to re-bind without re-authenticating.
|
|
|
|
|
struct ExistingSa {
|
imsd: a resumed registration echoes the 401's Security-Server list from the state file
A warm resume rebuilt its Security-Verify from the kernel's SA: one
mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329
§2.3.1 has the client mirror the server's whole list. The fresh
registration kept the 401's list in memory, so a registration this
process made fresh echoed it; after a restart, the resumed
registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and
its INVITEs carried the rebuilt entry until the next fresh registration.
KPN's one-entry answer to the default offer is the rebuilt entry byte
for byte; against a six-entry list (O2's) the mirror is broken, which
the RFC has the server answer with a 494, and a refused resume
registers fresh.
The fresh registration now stores the joined Security-Server value in the
state file (security_server, next to the challenge answer) and the resume
takes it from there, logging the mechanism count, when the entry
carrying its SPIs names the SA pair in the kernel: a file an attempt the
process did not live to record left behind (its SAs installed, its 200
not written) names another. A file from before this change holds none.
In both cases the resume falls back to the rebuilt entry, says which,
and the registration's next state write stores that entry, which is what
it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's
recorded list and KPN's. A value with a quote, a backslash or a newline in it is
written empty, which the resume treats as absent; RFC 3329's token
syntax produces none of these, a quoted-string extension parameter
could carry the first two.
Tested on the dev phone (KPN) with the build of this tree: a resume from
a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so
do one from a file whose list names another SA pair and one whose list
is empty; two keepalives at a 120-s interval; a fresh registration
stores KPN's list byte-for-byte as the journal shows it received; a
restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
|
|
|
std::string securityServer; // one-mechanism Security-Server value: the resume's Security-Verify when the state file holds no list for this pair
|
2026-07-22 22:53:28 +02:00
|
|
|
int portPs = 0;
|
|
|
|
|
int portPc = 0;
|
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.
2026-10-10 21:00:28 +02:00
|
|
|
std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one)
|
2026-07-22 22:53:28 +02:00
|
|
|
std::uint32_t spiUs = 0; // our inbound server SPI
|
|
|
|
|
std::uint32_t spiPs = 0;
|
|
|
|
|
std::uint32_t spiPc = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
namespace detail {
|
|
|
|
|
// The whitespace-delimited token immediately after `key` (which should
|
|
|
|
|
// include its trailing space, e.g. "spi "); empty if `key` is absent.
|
|
|
|
|
inline std::string_view TokenAfter(std::string_view text, std::string_view key) {
|
|
|
|
|
std::size_t at = text.find(key);
|
|
|
|
|
if (at == std::string_view::npos) return {};
|
|
|
|
|
std::size_t start = at + key.size();
|
|
|
|
|
std::size_t end = text.find_first_of(" \t\r\n", start);
|
|
|
|
|
return text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Split into blocks, each starting at a line-anchored "src " and
|
|
|
|
|
// running to just before the next one.
|
|
|
|
|
inline std::vector<std::string_view> SrcBlocks(std::string_view text) {
|
|
|
|
|
std::vector<std::string_view> blocks;
|
|
|
|
|
std::size_t i = 0;
|
|
|
|
|
std::optional<std::size_t> cur;
|
|
|
|
|
std::size_t pos = 0;
|
|
|
|
|
while (pos <= text.size()) {
|
|
|
|
|
std::size_t nl = text.find('\n', pos);
|
|
|
|
|
std::string_view line = text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
|
|
|
|
|
if (line.starts_with("src ")) {
|
|
|
|
|
if (cur) blocks.push_back(text.substr(*cur, pos - *cur));
|
|
|
|
|
cur = pos;
|
|
|
|
|
}
|
|
|
|
|
if (nl == std::string_view::npos) break;
|
|
|
|
|
pos = nl + 1;
|
|
|
|
|
}
|
|
|
|
|
if (cur) blocks.push_back(text.substr(*cur));
|
|
|
|
|
(void)i;
|
|
|
|
|
return blocks;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline std::optional<std::uint32_t> ParseHex32(std::string_view tok) {
|
|
|
|
|
if (tok.starts_with("0x") || tok.starts_with("0X")) tok.remove_prefix(2);
|
|
|
|
|
if (tok.empty()) return std::nullopt;
|
|
|
|
|
std::uint32_t v = 0;
|
|
|
|
|
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v, 16);
|
|
|
|
|
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline std::optional<int> ParseDec(std::string_view tok) {
|
|
|
|
|
int v = 0;
|
|
|
|
|
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v);
|
|
|
|
|
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text.
|
|
|
|
|
// Mapping (matches imscall.parse_existing_sa):
|
|
|
|
|
// state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc
|
|
|
|
|
// (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs
|
|
|
|
|
// policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
// Addresses are compared in RFC 5952 form on both sides (the kernel prints
|
|
|
|
|
// that form; PCSCF=/LOCAL= arrive in any spelling), so the spelling in
|
|
|
|
|
// /etc/imsd.env can no longer hide a registration's SAs.
|
2026-07-22 22:53:28 +02:00
|
|
|
// Returns nullopt unless spiPs, spiPc, portPs, portPc are all found.
|
|
|
|
|
std::optional<ExistingSa> ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) {
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
const std::string p = imsd::util::CanonicalAddr(pcscf);
|
|
|
|
|
const std::string l = imsd::util::CanonicalAddr(local);
|
2026-07-22 22:53:28 +02:00
|
|
|
ExistingSa sa;
|
|
|
|
|
bool havePs = false;
|
|
|
|
|
bool havePc = false;
|
|
|
|
|
bool havePortPs = false;
|
|
|
|
|
bool havePortPc = false;
|
|
|
|
|
for (std::string_view b : detail::SrcBlocks(stateText)) {
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
|
|
|
|
|
const std::string dst = imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst "));
|
2026-07-22 22:53:28 +02:00
|
|
|
auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi "));
|
|
|
|
|
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
|
|
|
|
if (!spi || !reqid) continue;
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
if (src == l && dst == p && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
|
|
|
|
|
if (src == l && dst == p && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
|
|
|
|
|
if (src == p && dst == l && *reqid == 1) sa.spiUc = *spi;
|
|
|
|
|
if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi;
|
2026-07-22 22:53:28 +02:00
|
|
|
}
|
|
|
|
|
for (std::string_view b : detail::SrcBlocks(policyText)) {
|
|
|
|
|
if (b.find("dir out") == std::string_view::npos) continue;
|
|
|
|
|
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
|
|
|
|
|
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
|
|
|
|
if (!dport || !reqid) continue;
|
|
|
|
|
if (*reqid == 1) { sa.portPs = *dport; havePortPs = true; }
|
|
|
|
|
else if (*reqid == 2) { sa.portPc = *dport; havePortPc = true; }
|
|
|
|
|
}
|
|
|
|
|
if (!(havePs && havePc && havePortPs && havePortPc)) return std::nullopt;
|
|
|
|
|
sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs);
|
|
|
|
|
return sa;
|
|
|
|
|
}
|
ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).
Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.
A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-09-28 18:28:58 +02:00
|
|
|
|
imsd: a resumed registration echoes the 401's Security-Server list from the state file
A warm resume rebuilt its Security-Verify from the kernel's SA: one
mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329
§2.3.1 has the client mirror the server's whole list. The fresh
registration kept the 401's list in memory, so a registration this
process made fresh echoed it; after a restart, the resumed
registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and
its INVITEs carried the rebuilt entry until the next fresh registration.
KPN's one-entry answer to the default offer is the rebuilt entry byte
for byte; against a six-entry list (O2's) the mirror is broken, which
the RFC has the server answer with a 494, and a refused resume
registers fresh.
The fresh registration now stores the joined Security-Server value in the
state file (security_server, next to the challenge answer) and the resume
takes it from there, logging the mechanism count, when the entry
carrying its SPIs names the SA pair in the kernel: a file an attempt the
process did not live to record left behind (its SAs installed, its 200
not written) names another. A file from before this change holds none.
In both cases the resume falls back to the rebuilt entry, says which,
and the registration's next state write stores that entry, which is what
it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's
recorded list and KPN's. A value with a quote, a backslash or a newline in it is
written empty, which the resume treats as absent; RFC 3329's token
syntax produces none of these, a quoted-string extension parameter
could carry the first two.
Tested on the dev phone (KPN) with the build of this tree: a resume from
a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so
do one from a file whose list names another SA pair and one whose list
is empty; two keepalives at a 120-s interval; a fresh registration
stores KPN's list byte-for-byte as the journal shows it received; a
restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).
Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.
A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-09-28 18:28:58 +02:00
|
|
|
// 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.
|
|
|
|
|
std::vector<std::string> SaLocals(std::string_view stateText, std::string_view pcscf) {
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
const std::string p = imsd::util::CanonicalAddr(pcscf);
|
ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).
Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.
A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-09-28 18:28:58 +02:00
|
|
|
std::vector<std::string> locals;
|
|
|
|
|
for (std::string_view b : detail::SrcBlocks(stateText)) {
|
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.
Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-11 15:55:19 +02:00
|
|
|
const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
|
|
|
|
|
if (src.empty() || imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst ")) != p) continue;
|
|
|
|
|
if (std::ranges::find(locals, src) == locals.end()) locals.push_back(src);
|
ims-pdn-up, imsd: register on the PDN's own address, not the kernel's temporary one
imsd took the first global IPv6 on the ims netdev as its address, and
the kernel lists the temporary (privacy) SLAAC address first: `temporary
dynamic`, preferred for a day and valid for two by its lifetimes (read
2026-09-24). Contact, Via and the four SAs were bound to it, so once the
kernel removes it the registration has lost its address. The address
ModemManager assigned — the one the script installs — sat third on the
list. Seen on the dev phone; a field unit's journal shows the split too
— imsd registered on an address other than the one the script installed
(ims ledger F18).
Hand the assigned address to the daemon as LOCAL through /run/imsd.env,
next to DEV. For a manual run without it, prefer a global address that
is neither `temporary` nor `dynamic`, then any non-temporary one.
A restart after the upgrade finds the registration's SAs on the old
address. It resumes there while that address is still on the link, and
moves to the PDN's address in place: with its next fresh registration,
or when the old address leaves the link first. When the address it is
registered on leaves the netdev and the PDN's own address is not there
to move to, a registered imsd exits at its next recovery step, non-zero,
for a systemd restart, which runs the PDN bring-up again.
Tested on the dev phone (KPN) in builds of this series; not every path
has run there.
2026-09-28 18:28:58 +02:00
|
|
|
}
|
|
|
|
|
return locals;
|
|
|
|
|
}
|
2026-07-22 22:53:28 +02:00
|
|
|
}
|