Compare commits
4 changed files with 38 additions and 372 deletions
|
|
@ -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`). 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 |
|
||||
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import Imsd;
|
|||
namespace {
|
||||
|
||||
// ---- static config (env-overridable, same knobs as imsd.py) ---------------
|
||||
constexpr const char* Version = "0.3.6";
|
||||
constexpr const char* Version = "0.3.5";
|
||||
constexpr const char* BusName = "net.catcrafts.IMS1";
|
||||
constexpr const char* ObjPath = "/net/catcrafts/IMS1";
|
||||
constexpr const char* Iface = "net.catcrafts.IMS1";
|
||||
|
|
@ -96,16 +96,6 @@ 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) {
|
||||
|
|
@ -129,6 +119,16 @@ 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,41 +1038,15 @@ 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);
|
||||
// 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 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)");
|
||||
|
||||
auto aka = Authenticate(rand16, autn16);
|
||||
if (!aka) throw std::runtime_error("USIM AKA failed");
|
||||
|
|
@ -1083,12 +1057,9 @@ 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)) {
|
||||
int rc = RunCmd(cmd);
|
||||
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
|
||||
}
|
||||
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp))
|
||||
RunCmd(cmd);
|
||||
|
||||
ctx_.securityServer = ss_;
|
||||
portPs_ = portPs;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ 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 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.
|
||||
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.
|
||||
|
||||
Pure std C++ (the shell runs the commands and captures the text). Command
|
||||
shapes and the parser are pinned by tests/Ipsec.
|
||||
|
|
@ -42,36 +40,9 @@ 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
|
||||
|
|
@ -86,13 +57,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 auth, 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 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", std::string(auth), std::string(ikx), "96"};
|
||||
"auth-trunc", "hmac(sha1)", std::string(ikx), "96"};
|
||||
a.insert(a.end(), enc.begin(), enc.end());
|
||||
return a;
|
||||
}
|
||||
|
|
@ -112,23 +83,20 @@ 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. 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.
|
||||
// Order matches imscall.setup_sa exactly.
|
||||
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, 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::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::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));
|
||||
|
|
@ -140,169 +108,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Ipsec unit tests — the `ip xfrm` setup command sequence (byte-exact
|
||||
// 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).
|
||||
// argv) and the warm-SA reader, fed `ip xfrm state`/`policy` output in the
|
||||
// exact shape captured from a registered FP6 (addresses/keys synthetic).
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
|
|
@ -121,114 +119,6 @@ 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue