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 unit tests — the `ip xfrm` setup command sequence (byte-exact
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
// 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).
2026-07-22 22:53:28 +02:00
import std ;
import Imsd ;
using namespace imsd : : ipsec ;
namespace {
int Failures = 0 ;
void Check ( bool cond , std : : string_view msg ) {
if ( ! cond ) {
std : : println ( std : : cerr , " FAIL: {} " , msg ) ;
+ + Failures ;
}
}
std : : string Join ( const std : : vector < std : : string > & v ) {
std : : string s ;
for ( const auto & t : v ) { s + = t ; s + = ' ' ; }
if ( ! s . empty ( ) ) s . pop_back ( ) ;
return s ;
}
// `ip xfrm state` in the shape of a registered FP6's kernel state
// (P-CSCF ::105, UE ...db43).
constexpr std : : string_view State =
" src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43 \n "
" \t proto esp spi 0x0a84ab2f reqid 2 mode transport \n "
" \t replay-window 0 \n "
" \t auth-trunc hmac(sha1) 0x00112233445566778899aabbccddeeff 96 \n "
" \t enc cbc(aes) 0xffeeddccbbaa99887766554433221100 \n "
" src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105 \n "
" \t proto esp spi 0x0526f2c1 reqid 2 mode transport \n "
" \t enc cbc(aes) 0xffeeddccbbaa99887766554433221100 \n "
" src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43 \n "
" \t proto esp spi 0x0071acd4 reqid 1 mode transport \n "
" \t lastused 2026-07-19 20:42:01 \n "
" src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105 \n "
" \t proto esp spi 0x0526f2c0 reqid 1 mode transport \n "
" \t lastused 2026-07-19 20:42:01 \n " ;
constexpr std : : string_view Policy =
" src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 33421 dport 45062 \n "
" \t dir in priority 0 \n "
" \t tmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43 \n "
" \t \t proto esp reqid 2 mode transport \n "
" src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45062 dport 33421 \n "
" \t dir out priority 0 \n "
" \t tmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105 \n "
" \t \t proto esp reqid 2 mode transport \n "
" src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 6000 dport 45061 \n "
" \t dir in priority 0 \n "
" \t tmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43 \n "
" \t \t proto esp reqid 1 mode transport \n "
" src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45061 dport 6000 \n "
" \t dir out priority 0 \n "
" \t tmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105 \n "
" \t \t proto esp reqid 1 mode transport \n " ;
}
int main ( ) {
// ---- warm-SA reader against real device output
{
auto sa = ParseExistingSa ( State , Policy , " 2001:db8::105 " , " 2001:db8:1111:2222:3333:4444:5555:db43 " ) ;
Check ( sa . has_value ( ) , " existing SA parses " ) ;
Check ( sa & & sa - > portPs = = 6000 , " port-s (reqid1 out dport) = 6000 " ) ;
Check ( sa & & sa - > portPc = = 33421 , " port-c (reqid2 out dport) = 33421 " ) ;
Check ( sa & & sa - > spiUc = = 0x0071acd4u , " spi_uc (P->UE reqid1) " ) ;
Check ( sa & & sa - > spiUs = = 0x0a84ab2fu , " spi_us (P->UE reqid2) " ) ;
Check ( sa & & sa - > spiPs = = 0x0526f2c0u , " spi_ps (UE->P reqid1) " ) ;
Check ( sa & & sa - > spiPc = = 0x0526f2c1u , " spi_pc (UE->P reqid2) " ) ;
Check ( sa & & sa - > securityServer = = " ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " " spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000 " , " reconstructed Security-Server value " ) ;
}
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
// the environment's spelling must not matter: upper case, uncompressed,
// bracketed — the kernel prints RFC 5952 form and both sides are
// canonicalised before the compare
{
auto sa = ParseExistingSa ( State , Policy , " 2001:DB8::105 " , " 2001:DB8:1111:2222:3333:4444:5555:DB43 " ) ;
Check ( sa & & sa - > spiPs = = 0x0526f2c0u & & sa - > portPs = = 6000 , " ParseExistingSa: upper-case spellings find the SAs " ) ;
auto sa2 = ParseExistingSa ( State , Policy , " [2001:0db8:0000:0000:0000:0000:0000:0105] " , " 2001:db8:1111:2222:3333:4444:5555:db43 " ) ;
Check ( sa2 & & sa2 - > spiPc = = 0x0526f2c1u , " ParseExistingSa: uncompressed, bracketed P-CSCF finds the SAs " ) ;
Check ( ! ParseExistingSa ( State , Policy , " 2001:db8::106 " , " 2001:db8:1111:2222:3333:4444:5555:db43 " ) , " ParseExistingSa: another P-CSCF still finds nothing " ) ;
auto locals = SaLocals ( State , " 2001:DB8:0:0:0:0:0:105 " ) ;
Check ( locals . size ( ) = = 1 & & locals [ 0 ] = = " 2001:db8:1111:2222:3333:4444:5555:db43 " , " SaLocals: any spelling of the P-CSCF, canonical locals out " ) ;
}
2026-07-22 22:53:28 +02:00
// incomplete input -> nullopt
Check ( ! ParseExistingSa ( " src a dst b \n \t spi 0x1 reqid 1 mode transport \n " , " " , " b " , " a " ) . has_value ( ) , " missing pieces -> nullopt " ) ;
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
{
// a registration still on another address of the link, plus an SA to
// another P-CSCF that is none of ours
std : : string two = std : : format ( " {}src 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd dst 2001:db8::105 \n \t proto esp spi 0x00000001 reqid 1 mode transport \n src 2001:db8:1111:2222:eeee:ffff:0:1 dst 2001:db8::999 \n \t proto esp spi 0x00000002 reqid 1 mode transport \n " , State ) ;
auto locals = SaLocals ( two , " 2001:db8::105 " ) ;
Check ( locals . size ( ) = = 2 & & locals [ 0 ] = = " 2001:db8:1111:2222:3333:4444:5555:db43 " & & locals [ 1 ] = = " 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd " , " SaLocals: every local address with an SA to the P-CSCF, once each, in kernel order " ) ;
Check ( SaLocals ( State , " 2001:db8::999 " ) . empty ( ) , " SaLocals: no SA to that P-CSCF -> none " ) ;
}
2026-07-22 22:53:28 +02:00
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
// ---- OwnXfrmDeleteCommands: imsd's own entries out of the kernel by exact key; everything else stays
{
const std : : string ue = " 2001:db8:1111:2222:3333:4444:5555:db43 " ;
// a policy-based VPN's: a tunnel policy (v6, reqid 1), an IPv4 one to 0.0.0.0/0, a tunnel SA
const std : : string vpn = " src 2001:db8::1/128 dst 2001:db8::2/128 \n \t dir out priority 0 \n \t tmpl src 2001:db8::1 dst 2001:db8::2 \n \t \t proto esp reqid 1 mode tunnel \n " ;
const std : : string vpn4 = " src 192.0.2.10/32 dst 0.0.0.0/0 \n \t dir out priority 383615 ptype main \n \t tmpl src 192.0.2.10 dst 192.0.2.1 \n \t \t proto esp reqid 1 mode tunnel \n " ;
const std : : string tunState = " src 2001:db8::1 dst 2001:db8::2 \n \t proto esp spi 0x00000777 reqid 7 mode tunnel \n \t replay-window 32 \n " ;
// another transport-mode user's reqid 1 + 2 pair on other ports, with its SA between other addresses
const std : : string otherOut2 = " src " + ue + " /128 dst 2001:db8::106/128 sport 1 dport 2 \n \t dir out priority 0 \n \t tmpl src " + ue + " dst 2001:db8::106 \n \t \t proto esp reqid 2 mode transport \n " ;
const std : : string otherOut1 = " src " + ue + " /128 dst 2001:db8::106/128 sport 3 dport 4 \n \t dir out priority 0 \n \t tmpl src " + ue + " dst 2001:db8::106 \n \t \t proto esp reqid 1 mode transport \n " ;
const std : : string otherState = " src 2001:db8::7 dst 2001:db8::106 \n \t proto esp spi 0x00000106 reqid 2 mode transport \n \t replay-window 0 \n " ;
auto dels = OwnXfrmDeleteCommands ( State , Policy , ue ) ;
Check ( dels . size ( ) = = 8 , " OwnXfrmDeleteCommands: our four states and four policies -> 8 deletes " ) ;
Check ( dels . size ( ) = = 8 & & Join ( dels [ 0 ] ) = = " ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0a84ab2f " , " OwnXfrmDeleteCommands: a state by src, dst and SPI, byte-exact " ) ;
Check ( dels . size ( ) = = 8 & & Join ( dels [ 4 ] ) = = " ip xfrm policy delete src 2001:db8::105/128 dst " + ue + " /128 sport 33421 dport 45062 dir in " , " OwnXfrmDeleteCommands: a policy by its selector and direction, byte-exact " ) ;
Check ( dels . size ( ) = = 8 & & Join ( dels [ 1 ] ) = = " ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c1 " & & Join ( dels [ 2 ] ) = = " ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0071acd4 " & & Join ( dels [ 3 ] ) = = " ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c0 " ,
" OwnXfrmDeleteCommands: the other three states byte-exact, in the kernel's order " ) ;
Check ( dels . size ( ) = = 8 & & Join ( dels [ 5 ] ) = = " ip xfrm policy delete src " + ue + " /128 dst 2001:db8::105/128 sport 45062 dport 33421 dir out " & & Join ( dels [ 6 ] ) = = " ip xfrm policy delete src 2001:db8::105/128 dst " + ue + " /128 sport 6000 dport 45061 dir in " & & Join ( dels [ 7 ] ) = = " ip xfrm policy delete src " + ue + " /128 dst 2001:db8::105/128 sport 45061 dport 6000 dir out " ,
" OwnXfrmDeleteCommands: the other three policies byte-exact, in the kernel's order " ) ;
auto besideVpn = OwnXfrmDeleteCommands ( std : : string ( State ) + tunState , std : : string ( Policy ) + vpn + vpn4 , ue ) ;
Check ( besideVpn . size ( ) = = 8 & & std : : ranges : : none_of ( besideVpn , [ ] ( const auto & c ) { auto j = Join ( c ) ; return j . contains ( " 2001:db8::2 " ) | | j . contains ( " 192.0.2.10 " ) | | j . contains ( " 0x00000777 " ) ; } ) ,
" OwnXfrmDeleteCommands: a VPN's tunnel SA and two tunnel policies beside ours -> ours only, theirs untouched " ) ;
Check ( OwnXfrmDeleteCommands ( otherState , otherOut2 + otherOut1 , ue ) . empty ( ) , " OwnXfrmDeleteCommands: another transport-mode reqid 1 + 2 pair on other ports, with its SA between other addresses -> nothing deleted " ) ;
Check ( OwnXfrmDeleteCommands ( std : : string ( State ) + otherState , std : : string ( Policy ) + otherOut2 + otherOut1 , ue ) . size ( ) = = 8 , " OwnXfrmDeleteCommands: the same beside our pair -> ours only " ) ;
Check ( OwnXfrmDeleteCommands ( " " , " " , ue ) . empty ( ) , " OwnXfrmDeleteCommands: nothing -> nothing " ) ;
Check ( OwnXfrmDeleteCommands ( State , " " , ue ) . size ( ) = = 4 , " OwnXfrmDeleteCommands: policy-less states of imsd's on the UE's address are deleted (a setup whose policy add failed leaves them) " ) ;
Check ( OwnXfrmDeleteCommands ( otherState , " " , ue ) . empty ( ) , " OwnXfrmDeleteCommands: a policy-less state between other addresses is left " ) ;
// the fingerprint selects, not a pair: a lone transport-mode reqid-1 policy carrying the protected port between other addresses goes, with the reqid-1 state between them (the §12 residual)
const std : : string loneOther = " src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dport 4 \n \t dir out priority 0 \n \t tmpl src 2001:db8::7 dst 2001:db8::106 \n \t \t proto esp reqid 1 mode transport \n " ;
const std : : string loneOtherState = " src 2001:db8::7 dst 2001:db8::106 \n \t proto esp spi 0x00000107 reqid 1 mode transport \n \t replay-window 0 \n " ;
Check ( OwnXfrmDeleteCommands ( loneOtherState , loneOther , ue ) . size ( ) = = 2 , " OwnXfrmDeleteCommands: a lone policy carrying the fingerprint between other addresses, and the reqid-1 state in its direction, are deleted (the fingerprint selects, not a pair) " ) ;
// a reqid-1/2 transport-mode state on the UE's address goes whoever made it: the other user's SA between the UE address and its peer, its policies on other ports staying (the §12 residual)
const std : : string otherOnUe = " src " + ue + " dst 2001:db8::106 \n \t proto esp spi 0x00000108 reqid 1 mode transport \n \t replay-window 0 \n " ;
auto onUe = OwnXfrmDeleteCommands ( otherOnUe , otherOut2 + otherOut1 , ue ) ;
Check ( onUe . size ( ) = = 1 & & Join ( onUe [ 0 ] ) = = " ip xfrm state delete src " + ue + " dst 2001:db8::106 proto esp spi 0x00000108 " , " OwnXfrmDeleteCommands: another transport-mode user's reqid-1 state on the UE's address is deleted whoever made it; its policies on other ports stay (the §12 residual) " ) ;
Check ( OwnXfrmDeleteCommands ( " src " + ue + " dst 2001:db8::106 \n \t proto ah spi 0x00000109 reqid 1 mode transport \n \t replay-window 0 \n " , otherOut2 + otherOut1 , ue ) . empty ( ) , " OwnXfrmDeleteCommands: an AH reqid-1 transport state on the UE's address is not selected (the delete names proto esp) " ) ;
// the selector as `ip xfrm policy` prints it: a fingerprinted policy with a protocol (and a dev) keeps them in its delete, a missing port is no empty token
const std : : string protoOther = " src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 \n \t dir out priority 0 \n \t tmpl src 2001:db8::7 dst 2001:db8::106 \n \t \t proto esp reqid 1 mode transport \n " ;
auto protoDels = OwnXfrmDeleteCommands ( " " , protoOther , ue ) ;
Check ( protoDels . size ( ) = = 1 & & Join ( protoDels [ 0 ] ) = = " ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 dir out " , " OwnXfrmDeleteCommands: a fingerprinted policy with a protocol in its selector is deleted by that selector (proto kept; the tmpl's 'proto esp' is not the selector's) " ) ;
const std : : string portlessOther = " src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 \n \t dir out priority 0 \n \t tmpl src 2001:db8::7 dst 2001:db8::106 \n \t \t proto esp reqid 1 mode transport \n " ;
auto portlessDels = OwnXfrmDeleteCommands ( " " , portlessOther , ue ) ;
Check ( portlessDels . size ( ) = = 1 & & Join ( portlessDels [ 0 ] ) = = " ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 dir out " , " OwnXfrmDeleteCommands: a selector without a dport and with a dev -> no empty token, the dev kept " ) ;
// the SA reader's ports come from our pair, never from a foreign transport policy that happens to carry ports
auto saBeside = ParseExistingSa ( State , std : : string ( Policy ) + otherOut2 , " 2001:db8::105 " , ue ) ;
Check ( saBeside & & saBeside - > portPc = = 33421 & & saBeside - > portPs = = 6000 , " ParseExistingSa: a foreign transport policy's ports beside ours are not taken " ) ;
}
2026-07-22 22:53:28 +02:00
// ---- fresh setup command sequence
{
SaParams p ;
p . local = " 2001:db8::db43 " ;
p . pcscf = " 2001:db8::105 " ;
p . ik = std : : vector < std : : uint8_t > ( 16 , 0xAB ) ;
p . ck = std : : vector < std : : uint8_t > ( 16 , 0xCD ) ;
p . spiUc = 100 ; p . spiUs = 200 ; p . spiPc = 300 ; p . spiPs = 400 ;
p . portUc = 45061 ; p . portUs = 45062 ; p . portPs = 6000 ; p . portPc = 33421 ;
auto cmds = BuildSetupCommands ( p ) ;
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
Check ( cmds . size ( ) = = 8 , " 4 states + 4 policies, no flush (OwnXfrmDeleteCommands takes imsd's previous entries out) " ) ;
2026-07-22 22:53:28 +02:00
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
Check ( Join ( cmds [ 0 ] ) = =
2026-07-22 22:53:28 +02:00
" ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
" proto esp spi 400 mode transport reqid 1 "
" auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
" enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd " ,
" state add reqid1 outbound byte-exact " ) ;
// first out policy: sport PORT_UC dport PORT_PS reqid 1
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
Check ( Join ( cmds [ 4 ] ) = = " ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " " sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " " dst 2001:db8::105 proto esp reqid 1 mode transport " , " policy add out reqid1 byte-exact " ) ;
2026-07-22 22:53:28 +02:00
// v4 uses /32
SaParams p4 = p ;
p4 . local = " 10.0.0.2 " ; p4 . pcscf = " 10.0.0.1 " ;
auto c4 = BuildSetupCommands ( p4 ) ;
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
Check ( c4 [ 4 ] [ 5 ] = = " 10.0.0.2/32 " , " v4 prefix length /32 " ) ;
2026-07-22 22:53:28 +02:00
}
// null cipher -> cipher_null + empty key arg
{
SaParams p ;
p . local = " ::2 " ; p . pcscf = " ::1 " ;
p . ik = std : : vector < std : : uint8_t > ( 16 , 0x01 ) ;
p . ck = { } ;
p . ealg = " null " ;
auto cmds = BuildSetupCommands ( p ) ;
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
auto & st = cmds [ 0 ] ;
2026-07-22 22:53:28 +02:00
Check ( st [ st . size ( ) - 3 ] = = " enc " & & st [ st . size ( ) - 2 ] = = " cipher_null " & & st [ st . size ( ) - 1 ] . empty ( ) , " null cipher: cipher_null + empty key " ) ;
}
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
// 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 ) ;
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.
OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).
Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
Check ( cmds [ 0 ] [ 17 ] = = " hmac(md5) " & & cmds [ 0 ] [ 18 ] = = " 0x01010101010101010101010101010101 " & & cmds [ 0 ] [ 19 ] = = " 96 " , " md5: auth-trunc hmac(md5) IK 96 " ) ;
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
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 " ) ;
}
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 resume's Security-Verify: the stored list only for the SA pair the kernel holds
{
using Source = ResumeSecurityVerify : : Source ;
ExistingSa sa ;
sa . spiPc = 129427852u ; sa . spiPs = 133937015u ; sa . portPc = 6051 ; sa . portPs = 6060 ;
sa . securityServer = " ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=129427852; spi-s=133937015; port-c=6051; port-s=6060 " ;
auto ok = ChooseResumeSecurityVerify ( O2 , sa , " null " ) ;
Check ( ok . source = = Source : : Stored & & ok . value = = O2 & & ok . mechanisms = = 6 , " resume: O2's six-entry list whose carrier is the kernel's pair -> the list verbatim, six mechanisms " ) ;
auto okAes = ChooseResumeSecurityVerify ( O2 , sa , " aes-cbc " ) ;
Check ( okAes . source = = Source : : Stored & & okAes . value = = O2 , " resume: the same under the aes offer (the carrier does not depend on the offer) " ) ;
constexpr std : : pair < std : : string_view , std : : string_view > off [ ] = {
{ " spi-c " , " ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427853;spi-s=133937015;port-c=6051;port-s=6060 " } ,
{ " spi-s " , " ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937016;port-c=6051;port-s=6060 " } ,
{ " port-c " , " ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6052;port-s=6060 " } ,
{ " port-s " , " ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6061 " } ,
} ;
for ( const auto & [ field , text ] : off ) {
auto r = ChooseResumeSecurityVerify ( text , sa , " null " ) ;
Check ( r . source = = Source : : OtherSa & & r . value = = sa . securityServer , std : : format ( " resume: {} off by one -> the rebuilt entry " , field ) ) ;
}
auto offSpiS = ChooseResumeSecurityVerify ( off [ 1 ] . second , sa , " null " ) ;
Check ( offSpiS . storedSpiPs & & * offSpiS . storedSpiPs = = 133937016u , " resume: the stored spi-s is reported for the log line " ) ;
auto none = ChooseResumeSecurityVerify ( " " , sa , " null " ) ;
Check ( none . source = = Source : : NoneStored & & none . value = = sa . securityServer , " resume: nothing stored -> the rebuilt entry " ) ;
auto junk = ChooseResumeSecurityVerify ( " not a list " , sa , " null " ) ;
Check ( junk . source = = Source : : OtherSa & & junk . value = = sa . securityServer & & ! junk . storedSpiPs , " resume: an unparseable value -> the rebuilt entry, no spi-s to report " ) ;
ExistingSa kpn ;
kpn . spiPc = 49889723u ; kpn . spiPs = 49889722u ; kpn . portPc = 33142 ; kpn . portPs = 6000 ;
kpn . securityServer = " ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=49889723; spi-s=49889722; port-c=33142; port-s=6000 " ;
auto k = ChooseResumeSecurityVerify ( kpn . securityServer , kpn , " aes-cbc " ) ;
Check ( k . source = = Source : : Stored & & k . value = = kpn . securityServer & & k . mechanisms = = 1 , " resume: KPN's one-entry list for its pair -> the list, one mechanism " ) ;
}
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
// ---- 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 " ) ;
}
2026-07-22 22:53:28 +02:00
if ( Failures = = 0 ) std : : println ( " Ipsec: all tests passed " ) ;
return Failures ;
}