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.
This commit is contained in:
Jorijn van der Graaf 2026-09-19 21:36:57 +02:00
commit 52736295a1
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
4 changed files with 371 additions and 37 deletions

View file

@ -160,7 +160,7 @@ PCSCF=2001:db8::105
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file (`/var/lib/imsd/imsreg.state`) records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |

View file

@ -96,6 +96,16 @@ ProcResult RunCapture(const std::vector<std::string>& argv) {
// Run argv, discard output, return exit code.
int RunCmd(const std::vector<std::string>& argv) { return RunCapture(argv).rc; }
// argv as one journal line with key material (the "0x…" tokens) masked.
std::string RedactKeys(const std::vector<std::string>& argv) {
std::string s;
for (const auto& a : argv) {
if (!s.empty()) s += ' ';
s += a.starts_with("0x") ? "<key>" : a;
}
return s;
}
// ---- small text extractors (401 header params, qmicli "completed:") -------
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) {
@ -119,16 +129,6 @@ std::optional<long> IntAfter(std::string_view text, std::string_view key) {
return v;
}
std::string EalgAfter(std::string_view ss) {
std::size_t at = ss.find("ealg=");
if (at == std::string_view::npos) return "null";
std::size_t i = at + 5;
std::size_t start = i;
auto ok = [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; };
while (i < ss.size() && ok(ss[i])) i++;
return std::string(ss.substr(start, i - start));
}
// value after "completed:" — integer or the hex ("AB:CD:..") token
std::optional<long> CompletedInt(std::string_view out) {
return IntAfter(out, "completed: ");
@ -1038,15 +1038,41 @@ private:
if (!raw || raw->size() < 32) throw std::runtime_error("bad nonce");
std::vector<std::uint8_t> rand16(raw->begin(), raw->begin() + 16);
std::vector<std::uint8_t> autn16(raw->begin() + 16, raw->begin() + 32);
auto ssv = imsd::sip::Header(resp, "Security-Server");
if (!ssv) throw std::runtime_error("no Security-Server");
ss_ = std::string(*ssv);
int portPs = static_cast<int>(IntAfter(ss_, "port-s=").value_or(0));
int portPc = static_cast<int>(IntAfter(ss_, "port-c=").value_or(0));
std::uint32_t spiPs = static_cast<std::uint32_t>(IntAfter(ss_, "spi-s=").value_or(0));
std::uint32_t spiPc = static_cast<std::uint32_t>(IntAfter(ss_, "spi-c=").value_or(0));
std::string ealg = EalgAfter(ss_);
if (spiPs == 0) throw ThrottledError("fresh-SA throttle active (spi-s=0)");
// Every Security-Server instance is one comma-list (RFC 3261
// §7.3.1); the joined value is what Security-Verify echoes — RFC
// 3329 §2.2 wants the whole list back, not the chosen entry.
auto ssLines = imsd::sip::Headers(resp, "Security-Server");
if (ssLines.empty()) throw std::runtime_error("no Security-Server");
ss_.clear();
for (std::string_view line : ssLines) {
if (!ss_.empty()) ss_ += ',';
ss_ += line;
}
Log(std::format("Security-Server: {}", ss_));
// Algorithms from the mechanism we select against our offer (RFC
// 3329 §2.3.1), SPIs/ports from the entry carrying them (O2 lists
// six and marks one; ims ledger F11). An answer we cannot use is a
// deferral on the throttle schedule, not a fatal: a fatal restarts
// imsd every RestartSec, an initial REGISTER each time.
auto ss = imsd::ipsec::ParseSecurityServer(ss_, "hmac-sha-1-96", ealgOffer_);
if (!ss) throw ThrottledError("Security-Server has no ipsec-3gpp mechanism");
const imsd::ipsec::SecurityMechanism& mech = ss->Selected();
const imsd::ipsec::SecurityMechanism& carrier = ss->Carrier();
Log(std::format("selected mechanism {} of {}{}: {}", ss->selected + 1, ss->mechanisms.size(), ss->offerMatched ? "" : " (none matches our offer; taking the P-CSCF's)", mech.text));
if (ss->carrier != ss->selected) Log(std::format("SPIs/ports from mechanism {}: {}", ss->carrier + 1, carrier.text));
if (carrier.spiPs == 0u) throw ThrottledError("fresh-SA throttle active (spi-s=0)");
if (!carrier.spiPs || !carrier.spiPc || !carrier.portPs || !carrier.portPc) throw ThrottledError("Security-Server carries no spi-c/spi-s/port-c/port-s");
std::string alg = mech.alg.empty() ? "hmac-sha-1-96" : mech.alg; // absent: what we offered
std::string ealg = imsd::ipsec::NormalizeEalg(mech.ealg); // absent: the null cipher (RFC 3329)
if (!imsd::ipsec::KernelAuth(alg)) throw ThrottledError(std::format("Security-Server alg={} unsupported", alg));
if (!imsd::ipsec::KnownEalg(ealg)) throw ThrottledError(std::format("Security-Server ealg={} unsupported", ealg));
if (!mech.prot.empty() && mech.prot != "esp") throw ThrottledError(std::format("Security-Server prot={} unsupported", mech.prot));
if (!mech.mod.empty() && mech.mod != "trans") throw ThrottledError(std::format("Security-Server mod={} unsupported", mech.mod));
std::uint32_t spiPs = *carrier.spiPs;
std::uint32_t spiPc = *carrier.spiPc;
int portPs = *carrier.portPs;
int portPc = *carrier.portPc;
Log(std::format("SA: alg={} ealg={}{} spi-c={} spi-s={} port-c={} port-s={}", alg, ealg, mech.ealg.empty() ? " (absent)" : "", spiPc, spiPs, portPc, portPs));
auto aka = Authenticate(rand16, autn16);
if (!aka) throw std::runtime_error("USIM AKA failed");
@ -1057,9 +1083,12 @@ private:
sp.ik = aka->ik; sp.ck = aka->ck;
sp.spiUc = spiUc; sp.spiUs = spiUs; sp.spiPc = spiPc; sp.spiPs = spiPs;
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
sp.alg = alg;
sp.ealg = ealg;
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp))
RunCmd(cmd);
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
int rc = RunCmd(cmd);
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
}
ctx_.securityServer = ss_;
portPs_ = portPs;

View file

@ -8,12 +8,14 @@ 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
server port), reqid 2 the terminating server flow. Integrity is HMAC-SHA1-96
keyed with IK; confidentiality is the negotiated ealg keyed with CK. This
module turns the negotiated SPIs/ports/keys into the exact argv lists the
daemon shell hands to `ip`, and — for a warm resume — reconstructs the SPIs,
ports, and Security-Server value back out of `ip xfrm state`/`policy` text so
a refresh re-REGISTER can advertise the SPIs actually in use.
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
`ip`; for a warm resume it reconstructs the SPIs, ports, and Security-Server
value back out of `ip xfrm state`/`policy` text so a refresh re-REGISTER can
advertise the SPIs actually in use.
Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec.
@ -40,9 +42,36 @@ export namespace imsd::ipsec {
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
std::string alg = "hmac-sha-1-96"; // hmac-sha-1-96 | hmac-md5-96
std::string ealg = "aes-cbc"; // aes-cbc | des-ede3-cbc/3des | null
};
// 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";
}
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
@ -57,13 +86,13 @@ export namespace imsd::ipsec {
return {"enc", "cipher_null", ""};
}
inline std::vector<std::string> StateAdd(std::string_view src, std::string_view dst, std::uint32_t spi, int reqid, std::string_view ikx, const std::vector<std::string>& enc) {
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) {
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),
"auth-trunc", "hmac(sha1)", std::string(ikx), "96"};
"auth-trunc", std::string(auth), std::string(ikx), "96"};
a.insert(a.end(), enc.begin(), enc.end());
return a;
}
@ -83,20 +112,23 @@ export namespace imsd::ipsec {
// 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.
// Order matches imscall.setup_sa exactly.
// 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.
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));
std::string auth = KernelAuth(p.alg).value_or(p.alg);
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"});
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, ikx, enc));
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUs, 2, ikx, enc));
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));
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));
@ -108,6 +140,169 @@ export namespace imsd::ipsec {
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
}
// ---- 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;
}
// What a warm SA in the kernel tells us — enough for a refresh re-REGISTER
// to re-bind without re-authenticating.
struct ExistingSa {

View file

@ -3,8 +3,10 @@
// lint-disable-file fixed-width-types
// Imsd:Ipsec unit tests — the `ip xfrm` setup command sequence (byte-exact
// argv) and the warm-SA reader, fed `ip xfrm state`/`policy` output in the
// exact shape captured from a registered FP6 (addresses/keys synthetic).
// argv), the warm-SA reader, fed `ip xfrm state`/`policy` output in the
// exact shape captured from a registered FP6 (addresses/keys synthetic), and
// the Security-Server parser, fed the operator lines as captured (O2 UK's
// six-mechanism Mavenir shape verbatim, KPN's single mechanism).
import std;
import Imsd;
@ -119,6 +121,114 @@ int main() {
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
}
// md5 integrity as the P-CSCF selected it: hmac(md5), IK as the key
{
SaParams p;
p.local = "::2"; p.pcscf = "::1";
p.ik = std::vector<std::uint8_t>(16, 0x01);
p.ck = {};
p.alg = "hmac-md5-96"; p.ealg = "null";
auto cmds = BuildSetupCommands(p);
Check(cmds[2][17] == "hmac(md5)" && cmds[2][18] == "0x01010101010101010101010101010101" && cmds[2][19] == "96", "md5: auth-trunc hmac(md5) IK 96");
Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names");
Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)");
}
// ---- Security-Server: O2 UK (alyx report 11, 2026-09-17) verbatim —
// six mechanisms, the SPIs/ports on the fourth (sha1, no ealg); the
// first `ealg=` in the header is the unselected 3DES entry.
constexpr std::string_view O2 =
"ipsec-3gpp;q=0.88;alg=hmac-md5-96;mod=trans,"
"ipsec-3gpp;q=0.9;alg=hmac-md5-96;mod=trans;ealg=des-ede3-cbc,"
"ipsec-3gpp;q=0.92;alg=hmac-md5-96;mod=trans;ealg=aes-cbc,"
"ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6060,"
"ipsec-3gpp;q=0.96;alg=hmac-sha-1-96;mod=trans;ealg=des-ede3-cbc,"
"ipsec-3gpp;q=0.98;alg=hmac-sha-1-96;mod=trans;ealg=aes-cbc";
{
// alyx's offer (EALG=null): the entry O2 marked is also the only one matching the offer
auto ss = ParseSecurityServer(O2, "hmac-sha-1-96", "null");
Check(ss.has_value() && ss->mechanisms.size() == 6, "O2/null: six mechanisms");
Check(ss && ss->selected == 3 && ss->carrier == 3 && ss->offerMatched, "O2/null: the fourth mechanism (sha1, no ealg, the SPIs) selected, offer matched");
if (ss) {
const auto& m = ss->Selected();
Check(m.name == "ipsec-3gpp" && m.mod == "trans" && m.prot.empty(), "O2/null: name, mod=trans, prot absent");
Check(m.qMilli == 940, "O2/null: q=0.94");
Check(m.alg == "hmac-sha-1-96", "O2/null: alg from the selected mechanism");
Check(m.ealg.empty(), "O2/null: ealg absent on the selected mechanism (null), NOT the header's first ealg= (3DES)");
Check(m.spiPc == 129427852u && m.spiPs == 133937015u, "O2/null: SPIs");
Check(m.portPc == 6051 && m.portPs == 6060, "O2/null: ports");
Check(m.text == "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6060", "O2/null: selected text verbatim");
Check(ss->mechanisms[1].ealg == "des-ede3-cbc" && !ss->mechanisms[1].spiPs, "O2/null: the 3DES entry is parsed but carries no SPIs");
}
// the default offer (EALG=aes-cbc): the spec picks sha1+aes (q=0.98); the SPIs stay with the marked entry
auto sa = ParseSecurityServer(O2, "hmac-sha-1-96", "aes-cbc");
Check(sa && sa->selected == 5 && sa->offerMatched, "O2/aes: sha1+aes-cbc (q=0.98) selected for the aes offer");
Check(sa && sa->carrier == 3 && sa->Carrier().spiPs == 133937015u && sa->Carrier().portPs == 6060, "O2/aes: SPIs/ports from the entry carrying them");
Check(sa && sa->Selected().ealg == "aes-cbc" && sa->Selected().alg == "hmac-sha-1-96", "O2/aes: algorithms from the selected entry");
// an offer O2 does not list (3des alias -> des-ede3-cbc): sha1+3des (q=0.96)
auto s3 = ParseSecurityServer(O2, "hmac-sha-1-96", "3des");
Check(s3 && s3->selected == 4 && s3->offerMatched && s3->carrier == 3, "O2/3des: the alias matches des-ede3-cbc (q=0.96), SPIs from the marked entry");
}
// ---- KPN: one mechanism, spaces after the separators, explicit ealg
{
auto ss = ParseSecurityServer("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", "hmac-sha-1-96", "aes-cbc");
Check(ss && ss->mechanisms.size() == 1 && ss->selected == 0 && ss->carrier == 0 && ss->offerMatched, "KPN: single mechanism selected, offer matched");
Check(ss && ss->Selected().alg == "hmac-sha-1-96" && ss->Selected().ealg == "aes-cbc", "KPN: alg + ealg");
Check(ss && ss->Selected().spiPc == 49889723u && ss->Selected().spiPs == 49889722u && ss->Selected().portPc == 33142 && ss->Selected().portPs == 6000, "KPN: SPIs + ports");
Check(ss && ss->Selected().qMilli == 100, "KPN: q=0.1");
auto nul = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null; spi-c=33998503; spi-s=33998502; port-c=33021; port-s=6000", "hmac-sha-1-96", "null");
Check(nul && nul->Selected().ealg == "null" && nul->offerMatched, "KPN: explicit ealg=null matches the null offer");
// the P-CSCF answers a cipher we did not offer: still the only entry, installed as answered, flagged
auto other = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null; spi-c=33998503; spi-s=33998502; port-c=33021; port-s=6000", "hmac-sha-1-96", "aes-cbc");
Check(other && other->selected == 0 && other->carrier == 0 && !other->offerMatched, "single entry not matching the offer: selected via the spi-s fallback, offerMatched=false");
// the fresh-SA throttle answer: spi-s present and zero
auto thr = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=1; spi-s=0; port-c=33102; port-s=6000", "hmac-sha-1-96", "aes-cbc");
Check(thr && thr->Carrier().spiPs.has_value() && *thr->Carrier().spiPs == 0, "KPN throttle: spi-s=0 is present, not absent");
// no SPIs anywhere: parsed, nothing to build from
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");
}
// ---- 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)
auto a1 = ParseSecurityServer("ipsec-3gpp;q=0.1;alg=hmac-sha-1-96;ealg=aes-cbc;spi-c=11;spi-s=12;port-c=13;port-s=14,ipsec-3gpp;q=0.2;alg=hmac-md5-96;ealg=aes-cbc;spi-c=11;spi-s=12;port-c=13;port-s=14", "hmac-sha-1-96", "aes-cbc");
Check(a1 && a1->selected == 0 && a1->offerMatched && a1->Selected().alg == "hmac-sha-1-96", "every entry carries SPIs, md5 preferred: the offered sha1 is selected, not the highest q");
// two sha1 entries with different ciphers, both carrying SPIs: the offered cipher wins over q (audit A3)
auto a3 = ParseSecurityServer("ipsec-3gpp;q=0.9;alg=hmac-sha-1-96;ealg=des-ede3-cbc;spi-c=1;spi-s=2;port-c=3;port-s=4,ipsec-3gpp;q=0.8;alg=hmac-sha-1-96;ealg=aes-cbc;spi-c=5;spi-s=6;port-c=7;port-s=8", "hmac-sha-1-96", "aes-cbc");
Check(a3 && a3->selected == 1 && a3->Carrier().spiPs == 6u, "offered cipher wins over a higher-q cipher we did not offer");
// two matching entries: the higher q
auto two = ParseSecurityServer("ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;ealg=aes-cbc;spi-c=1;spi-s=2;port-c=3;port-s=4,ipsec-3gpp;q=0.9;alg=hmac-sha-1-96;ealg=aes-cbc;spi-c=5;spi-s=6;port-c=7;port-s=8", "hmac-sha-1-96", "aes-cbc");
Check(two && two->selected == 1 && two->Selected().spiPs == 6u, "two matching entries: highest q selected");
// nothing matches the offer (md5-only list): fallback = the highest-q carrier, flagged
auto md5 = ParseSecurityServer("ipsec-3gpp;q=0.5;alg=hmac-md5-96;spi-c=1;spi-s=2;port-c=3;port-s=4,ipsec-3gpp;q=0.9;alg=hmac-md5-96;ealg=aes-cbc;spi-c=5;spi-s=6;port-c=7;port-s=8", "hmac-sha-1-96", "aes-cbc");
Check(md5 && md5->selected == 1 && !md5->offerMatched && md5->carrier == 1, "no match: highest-q carrier, offerMatched=false");
// no q at all, nothing matching: the first carrier
auto noq = ParseSecurityServer("ipsec-3gpp;alg=hmac-md5-96;spi-c=1;spi-s=2;port-c=3;port-s=4,ipsec-3gpp;alg=hmac-md5-96;ealg=aes-cbc;spi-c=5;spi-s=6;port-c=7;port-s=8", "hmac-sha-1-96", "aes-cbc");
Check(noq && noq->selected == 0 && !noq->offerMatched, "no q, no match: first carrier");
// absent alg on the entry counts as the offered one
auto noalg = ParseSecurityServer("ipsec-3gpp;ealg=aes-cbc;spi-c=1;spi-s=2;port-c=3;port-s=4", "hmac-sha-1-96", "aes-cbc");
Check(noalg && noalg->offerMatched, "absent alg matches the offer");
// only non-ipsec mechanisms: nothing for us
Check(!ParseSecurityServer("tls;q=0.2,digest;d-alg=MD5", "hmac-sha-1-96", "aes-cbc").has_value(), "tls/digest only -> nullopt");
// a quoted comma must not split the list; tokens are case-insensitive
auto q = ParseSecurityServer("digest;d-alg=MD5;d-qop=\"auth,auth-int\",IPSEC-3GPP; ALG=HMAC-SHA-1-96; EALG=AES-CBC; SPI-C=11; SPI-S=12; PORT-C=13; PORT-S=14", "HMAC-SHA-1-96", "AES-CBC");
Check(q && q->mechanisms.size() == 2 && q->mechanisms[0].name == "digest", "quoted comma stays inside the digest mechanism");
Check(q && q->selected == 1 && q->offerMatched && q->Selected().name == "ipsec-3gpp" && q->Selected().alg == "hmac-sha-1-96" && q->Selected().ealg == "aes-cbc" && q->Selected().spiPs == 12u && q->Selected().portPs == 14, "case-insensitive names and values, lower-cased; offer compared case-insensitively");
// prot/mod parsed for the caller to refuse
auto tun = ParseSecurityServer("ipsec-3gpp;alg=hmac-sha-1-96;ealg=aes-cbc;prot=ah;mod=tun;spi-c=1;spi-s=2;port-c=3;port-s=4", "hmac-sha-1-96", "aes-cbc");
Check(tun && tun->Selected().prot == "ah" && tun->Selected().mod == "tun", "prot/mod parsed");
// spi-s beyond 32 bits, a port beyond 16 bits or zero: absent, not garbage
auto bad = ParseSecurityServer("ipsec-3gpp;alg=hmac-sha-1-96;ealg=aes-cbc;spi-c=1;spi-s=4294967296;port-c=0;port-s=70000", "hmac-sha-1-96", "aes-cbc");
Check(bad && !bad->Selected().spiPs && !bad->Selected().portPs && !bad->Selected().portPc && bad->Selected().spiPc == 1u, "out-of-range spi-s/port-s and port 0 are absent");
// q syntax
auto q1 = ParseSecurityServer("ipsec-3gpp;q=1;spi-s=1", "hmac-sha-1-96", "null");
Check(q1 && q1->Selected().qMilli == 1000, "q=1 -> 1000");
auto qx = ParseSecurityServer("ipsec-3gpp;q=x;spi-s=1", "hmac-sha-1-96", "null");
Check(qx && !qx->Selected().qMilli, "q=x -> no q");
Check(!ParseSecurityServer("", "hmac-sha-1-96", "null").has_value() && !ParseSecurityServer(" , ", "hmac-sha-1-96", "null").has_value(), "empty value -> nullopt");
Check(NormalizeEalg("") == "null" && NormalizeEalg("3des") == "des-ede3-cbc" && NormalizeEalg("aes-cbc") == "aes-cbc", "NormalizeEalg");
}
if (Failures == 0) std::println("Ipsec: all tests passed");
return Failures;
}