diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feec48af..80b5e94c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Ikev2SaInitCryptoErrorCode` to `Ikev2ChildSaKeyMaterialDiagnostic`. ### Added +- `opc-proto-ikev2::nwu`: bounded TS 24.502 configuration and QoS payloads, + opened Child-SA creation/modification, explicit NWu Child/IKE deletion and + caller-ordered AEAD selection. Conditional MOBIKE capability advertisement + is supported; authenticated mobility remains pending (Refs #786). - **Bounded acknowledged configuration history — `opc-persist`:** the existing consensus authority commits exact-head retention decisions with record and encoded-byte limits, authenticated cursor boundaries, and protected rollback diff --git a/crates/opc-proto-ikev2/CONFORMANCE.md b/crates/opc-proto-ikev2/CONFORMANCE.md index 494924eba..b601ae7bd 100644 --- a/crates/opc-proto-ikev2/CONFORMANCE.md +++ b/crates/opc-proto-ikev2/CONFORMANCE.md @@ -235,3 +235,69 @@ exchange, correlates both protocol responses, commits the GTP response for exact replay, and then performs the corresponding Delete Bearer and IKEv2 Child-SA deletion flow. Admission, identifier allocation, key installation, and dataplane programming remain explicit application responsibilities. +## NWu payload profile + +The `nwu` module implements opened payload boundaries from +[TS 24.502 V18.8.0](https://www.etsi.org/deliver/etsi_ts/124500_124599/124502/18.08.00_60/ts_124502v180800p.pdf) +sections 7.3.2.2, 7.4–7.7 and 9.3.1, with generic framing, CP, TS, Notify and +Delete from [RFC 7296](https://www.rfc-editor.org/rfc/rfc7296.html). +These are synthetic constructed/receive claims; no peer capture, live network, +authentication, key custody or XFRM installation is claimed. + +- Constructed and received: IPv4/IPv6/dual CFG_REQUEST and correlated + CFG_REPLY with NAS addresses and NAS_TCP_PORT; 5G_QOS_INFO including zero or + several QFIs, default indication, optional DSCP and additional parameters; + UP addresses and sender-inbound UP_SA_INFO; network-initiated CREATE_CHILD_SA + with complete SA/Nonce/optional KE/all-packet TS payloads; complete replacement + modification; both-initiator Child and IKE deletion. +- Additional QoS uses exact wire units. Characteristics use six octets for + non-GBR, eight for GBR, and ten for delay-critical GBR (including its averaging + window and maximum burst). Known fields have exact lengths and range checks; + rate unit codes above 25 are retained with the standard's 256 Pbps meaning. + Non-GBR characteristics with GBR-only rate/loss parameters are unsupported. + Unknown parameter identifiers and Notification Control are framed, counted, + then discarded as required by this release. Parameters remain optional; this + codec does not reserve or admit QoS resources. +- Protocol ID is ignored for zero-SPI notifies. UP_SA_INFO requires ESP and a + four-octet nonzero SPI; optional future extension bytes are ignored. Spare + QFI/flag bits and CP reserved fields are ignored on receive and cleared by + canonical encoding. N3GPP_BACKOFF_TIMER is explicitly unsupported here. +- Local admission policy rejects repeated known CP attributes/notifies/QFIs/QoS + parameters and repeated SPIs. `Limits` defaults to 65,535 opened bytes and + 128 payloads/CP attributes/SPIs as applicable. Every ignored entry counts. + CP and Delete counts are checked before generic vector allocation. The QoS + one-octet Length and Delete generic 16-bit length bounds are checked before + construction. CP attributes outside this profile remain caller-owned. +- `CreateRequest` validates original IKE responder role for network creation, + one applicable UP address and complete selector ranges. Accepted responses + are matched to both IKE SPIs, message ID, role, proposal/transforms, KE and + all-packet selectors. `AeadPolicy` uses explicit caller suite order, with no + default list, fallback, separate integrity, crypto calls or backend effects. + Whole-IKE selection continues to use `Ikev2SaInitNegotiationPolicy`. +- Modification outcomes distinguish a matching empty acceptance, a matching + error rejection, and an ambiguous caller-declared timeout. The entire QoS + association replaces the old association. A roster must enforce the one + default SA per PDU session invariant; a payload cannot establish that fact. +- TS 24.502 Child Delete echoes the complete received SPI sequence for ordinary + and crossed requests. `validate_complete` compares it with a caller-supplied + authoritative roster. The generic RFC 7296 and TS 24.302 paired-direction + deletion profiles retain their existing behavior. An unanswered initiated + NWu Child Delete reports whole-IKE/all-children discard intent, not just the + original SPI subset. IKE Delete is Protocol ID 1 without SPIs and uses an + empty acknowledgement; its timeout has the same whole-IKE scope. +- Conditional MOBIKE_SUPPORTED advertisement and receiver-ignored capability + extension data are covered. Authenticated + UPDATE_SA_ADDRESSES, address advertisement processing, return-routability, + replay/source rejection, and NAT-T migration are **not implemented by this + change** and keep #786 open. The published additional-address vector is + consumed through generic Notify framing only. The opened lifecycle helpers + accept SK headers; SKF reassembly and peer authentication are separate. + +`tests/nwu.rs` consumes all 15 published `nwu-ike` fixture files, using their +unchanged provenance/digest manifests under `opc-n3iwf-fixtures`. Full CP and +CREATE request literals, complete additional-QoS parameters, IPv6/dual-family +cases, duplicate/length/count mutations, response-correlation failures and +redaction checks are independently assembled synthetic test vectors. The `nwu` +fuzz target checks bounded decode and canonical re-encoding, using exact binary +copies of the published payload seeds. Round trips do not establish external +interoperability. Review and authenticated mobility evidence remain outstanding. diff --git a/crates/opc-proto-ikev2/README.md b/crates/opc-proto-ikev2/README.md index 4b50998aa..d44068974 100644 --- a/crates/opc-proto-ikev2/README.md +++ b/crates/opc-proto-ikev2/README.md @@ -22,6 +22,30 @@ state, retransmission policy, cookie policy, Child SA lifecycle, XFRM/IPsec programming, bearer admission or allocation policy, carrier acceptance evidence, or a production ePDG control-plane stack. +## NWu payload profiles + +`nwu` adds the TS 24.502 V18.8.0 configuration and opened Child-SA payload +profiles tracked by #786. It supports empty per-family CFG_REQUEST attributes, +correlated CFG_REPLY/NAS endpoints, complete QoS associations and additional +QoS parameters, one applicable UP address, network-initiated creation with +all-packet selectors, full replacement modification, and explicit NWu deletion. +`PendingModification` distinguishes acceptance, rejection, and ambiguous timeout; +`PendingChildDelete` requires the ordinary/crossed received-SPI echo. +`PendingIkeDelete` uses Protocol ID 1 with no SPIs and an empty response. + +`AeadPolicy` tries the caller's ordered `AeadSuite` list before peer proposal +order and emits no integrity transform or fallback. Existing +`Ikev2SaInitNegotiationPolicy` supplies ordered whole-IKE-suite selection. +No default downstream suite list is included. Construction and receive helpers +return intent only; protection, key custody, replay admission, roster authority, +retransmission scheduling and backend operations belong to their own boundaries. + +MOBIKE_SUPPORTED is conditional on the original IPv4 request and UE support. +Authenticated MOBIKE address updates, return-routability, source/replay checks +and NAT-T migration are still pending in #786. This payload change does not +close that issue or establish external interoperability. See +[CONFORMANCE.md](CONFORMANCE.md#nwu-payload-profile) for exact scope and evidence. + ## API Shape - `Message<'a>` and `OwnedMessage` provide borrowed and owned IKEv2 messages. diff --git a/crates/opc-proto-ikev2/fuzz/Cargo.toml b/crates/opc-proto-ikev2/fuzz/Cargo.toml index 0f6e7e7a9..9a9f0149a 100644 --- a/crates/opc-proto-ikev2/fuzz/Cargo.toml +++ b/crates/opc-proto-ikev2/fuzz/Cargo.toml @@ -29,3 +29,8 @@ test = false name = "dedicated_bearer" path = "fuzz_targets/dedicated_bearer.rs" test = false + +[[bin]] +name = "nwu" +path = "fuzz_targets/nwu.rs" +test = false diff --git a/crates/opc-proto-ikev2/fuzz/README-nwu.md b/crates/opc-proto-ikev2/fuzz/README-nwu.md new file mode 100644 index 000000000..aeba33041 --- /dev/null +++ b/crates/opc-proto-ikev2/fuzz/README-nwu.md @@ -0,0 +1,8 @@ +# NWu seeds + +The 15 files named for `opc-n3iwf-fixtures/fixtures/nwu-ike/wire/*.hex` +are exact binary decodings of those independently published synthetic vectors. +Their JSON manifests retain source releases, clauses, provenance, outcomes and +SHA-256 digests. They prove payload shape only, not authentication or live mobility. +The fuzz target also exercises complete opened configuration/create/modify/delete +profiles. Mutated corpora belong in a temporary directory, not in this seed set. diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/bounded-spi-size-overflow b/crates/opc-proto-ikev2/fuzz/corpus/nwu/bounded-spi-size-overflow new file mode 100644 index 000000000..7388d452f Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/bounded-spi-size-overflow differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/create-child-sa b/crates/opc-proto-ikev2/fuzz/corpus/nwu/create-child-sa new file mode 100644 index 000000000..c6f0d2e17 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/create-child-sa differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/delete-esp b/crates/opc-proto-ikev2/fuzz/corpus/nwu/delete-esp new file mode 100644 index 000000000..2dd4fdd09 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/delete-esp differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/duplicate-nas-ip4 b/crates/opc-proto-ikev2/fuzz/corpus/nwu/duplicate-nas-ip4 new file mode 100644 index 000000000..4abd3c764 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/duplicate-nas-ip4 differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/malformed-spi-size b/crates/opc-proto-ikev2/fuzz/corpus/nwu/malformed-spi-size new file mode 100644 index 000000000..d89435ea4 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/malformed-spi-size differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/mobility-additional-addresses b/crates/opc-proto-ikev2/fuzz/corpus/nwu/mobility-additional-addresses new file mode 100644 index 000000000..53a16e73a Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/mobility-additional-addresses differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/modify-child-sa b/crates/opc-proto-ikev2/fuzz/corpus/nwu/modify-child-sa new file mode 100644 index 000000000..f1c005b0a Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/modify-child-sa differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/ordering-tcp-before-ip4 b/crates/opc-proto-ikev2/fuzz/corpus/nwu/ordering-tcp-before-ip4 new file mode 100644 index 000000000..d1871ca84 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/ordering-tcp-before-ip4 differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-5g-qos-info b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-5g-qos-info new file mode 100644 index 000000000..b35587498 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-5g-qos-info differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-ip4 b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-ip4 new file mode 100644 index 000000000..90895ae0d Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-ip4 differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-tcp-port b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-tcp-port new file mode 100644 index 000000000..8c935aaa8 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-nas-tcp-port differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-ip4 b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-ip4 new file mode 100644 index 000000000..e573e5215 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-ip4 differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-sa-info b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-sa-info new file mode 100644 index 000000000..13567eae3 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/positive-up-sa-info differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/truncated-nas-ip4 b/crates/opc-proto-ikev2/fuzz/corpus/nwu/truncated-nas-ip4 new file mode 100644 index 000000000..0282c73cc Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/truncated-nas-ip4 differ diff --git a/crates/opc-proto-ikev2/fuzz/corpus/nwu/unknown-critical-payload b/crates/opc-proto-ikev2/fuzz/corpus/nwu/unknown-critical-payload new file mode 100644 index 000000000..5e1012e62 Binary files /dev/null and b/crates/opc-proto-ikev2/fuzz/corpus/nwu/unknown-critical-payload differ diff --git a/crates/opc-proto-ikev2/fuzz/fuzz_targets/nwu.rs b/crates/opc-proto-ikev2/fuzz/fuzz_targets/nwu.rs new file mode 100644 index 000000000..dc3938606 --- /dev/null +++ b/crates/opc-proto-ikev2/fuzz/fuzz_targets/nwu.rs @@ -0,0 +1,104 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use opc_proto_ikev2::{ + nwu::*, Header, HeaderFlags, PayloadChain, PayloadType, EXCHANGE_TYPE_CREATE_CHILD_SA, + EXCHANGE_TYPE_INFORMATIONAL, +}; + +fuzz_target!(|data: &[u8]| { + if data.len() > 8192 { + return; + } + let limits = Limits { + bytes: 8192, + entries: 128, + }; + for body in [Some(data), data.get(4..)].into_iter().flatten() { + if let Ok(Some(notify)) = Notify::decode_body(body) { + let canonical = notify.encode_body().expect("accepted Notify encodes"); + let again = Notify::decode_body(&canonical) + .expect("canonical Notify parses") + .expect("known type"); + assert_eq!( + again.encode_body().expect("canonical Notify encodes"), + canonical + ); + } + if let Ok(qos) = QosInfo::decode(body) { + let canonical = qos.encode().expect("accepted QoS encodes"); + assert_eq!( + QosInfo::decode(&canonical) + .expect("canonical QoS parses") + .encode() + .expect("canonical QoS encodes"), + canonical + ); + } + let _ = AdditionalQos::new(body); + } + let header = Header::new( + 1, + 2, + PayloadType::Encrypted, + EXCHANGE_TYPE_INFORMATIONAL, + HeaderFlags::from_bits(false, false, false), + 7, + ); + for first in [ + PayloadType::Notify, + PayloadType::Configuration, + PayloadType::Delete, + PayloadType::SecurityAssociation, + PayloadType::Unknown(250), + ] { + for p in PayloadChain::new(first, data).iter() { + if let Ok(p) = p { + if p.payload_type == PayloadType::Notify { + let _ = Notify::decode_body(p.body); + } + } + } + let _ = ConfigurationRequest::decode(first, data, limits); + for families in [ + AddressFamilies::Ipv4, + AddressFamilies::Ipv6, + AddressFamilies::Dual, + ] { + for mobike_supported in [false, true] { + let request = ConfigurationRequest { + families, + mobike_supported, + }; + if let Ok(reply) = ConfigurationReply::decode(request, first, data, limits) { + let (first, bytes) = encode_payloads(&reply.payloads().expect("reply builds")) + .expect("reply encodes"); + assert_eq!( + ConfigurationReply::decode(request, first, &bytes, limits) + .expect("canonical reply parses"), + reply + ); + } + } + let mut create = header.clone(); + create.exchange_type = EXCHANGE_TYPE_CREATE_CHILD_SA; + let _ = CreateRequest::decode(&create, first, data, families, limits); + } + if let Ok((_, value)) = Modification::decode(&header, first, data, limits) { + let (first, canonical) = + encode_payloads(&value.payloads().expect("modification builds")) + .expect("modification encodes"); + assert!(Modification::decode(&header, first, &canonical, limits).is_ok()); + } + if let Ok((_, delete)) = ChildDelete::decode(&header, Peer::Network, first, data, limits) { + let (first, canonical) = encode_payloads(&delete.payloads().expect("delete builds")) + .expect("delete encodes"); + assert_eq!( + ChildDelete::decode(&header, Peer::Network, first, &canonical, limits) + .expect("canonical delete parses") + .1, + delete + ); + } + let _ = PendingIkeDelete::decode_request(&header, Peer::Network, first, data, limits); + } +}); diff --git a/crates/opc-proto-ikev2/src/dedicated_bearer.rs b/crates/opc-proto-ikev2/src/dedicated_bearer.rs index a8f9308b6..816561971 100644 --- a/crates/opc-proto-ikev2/src/dedicated_bearer.rs +++ b/crates/opc-proto-ikev2/src/dedicated_bearer.rs @@ -27,7 +27,7 @@ mod exchange; mod qos; pub(crate) use exchange::{ - traffic_selector_payload_is_narrowed, validate_sa_build, validate_sa_view, + traffic_selector_payload_is_narrowed, validate_ke_view, validate_sa_build, validate_sa_view, validate_selected_key_exchange, validate_selected_proposal, Ikev2SelectedKeyExchangeError, }; diff --git a/crates/opc-proto-ikev2/src/dedicated_bearer/exchange.rs b/crates/opc-proto-ikev2/src/dedicated_bearer/exchange.rs index bcd3ae151..4eb78e2a4 100644 --- a/crates/opc-proto-ikev2/src/dedicated_bearer/exchange.rs +++ b/crates/opc-proto-ikev2/src/dedicated_bearer/exchange.rs @@ -1236,7 +1236,7 @@ fn validate_proposal_spi( }) } -fn validate_ke_view( +pub(crate) fn validate_ke_view( sa: &Ikev2SaPayload<'_>, key_exchange: Option<&Ikev2KeyExchangePayload<'_>>, ) -> Result<(), Ikev2DedicatedBearerExchangeError> { diff --git a/crates/opc-proto-ikev2/src/lib.rs b/crates/opc-proto-ikev2/src/lib.rs index f9fc34c3e..c5c6959f2 100644 --- a/crates/opc-proto-ikev2/src/lib.rs +++ b/crates/opc-proto-ikev2/src/lib.rs @@ -34,6 +34,10 @@ //! Child SA installation, XFRM //! programming, or any product-specific 3GPP ePDG policy. //! +//! The separate [`nwu`] module adds bounded TS 24.502 configuration, QoS, +//! opened creation/modification/deletion and caller-ordered AEAD selection. +//! Authenticated MOBIKE address updates remain outside that payload profile. +//! //! Network decoders follow RFC 7296 receiver rules through //! [`Ikev2ValidationProfile::NetworkReceive`]: sender-zero reserved fields and //! higher minor versions are ignored without weakening structural, critical @@ -85,6 +89,7 @@ pub mod message; pub mod nat_detection; pub mod nat_traversal; pub mod notify; +pub mod nwu; pub mod payload; pub mod pcscf_restoration; pub mod pre_admission; diff --git a/crates/opc-proto-ikev2/src/nwu/configuration.rs b/crates/opc-proto-ikev2/src/nwu/configuration.rs new file mode 100644 index 000000000..c8451a5a6 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/configuration.rs @@ -0,0 +1,379 @@ +use super::{once, Address, Error, Limits, Notify}; +use crate::{ + build_ike_auth_cleartext_payload_chain, build_ike_auth_configuration_payload, + Ikev2ConfigurationAttributeBuild, Ikev2ConfigurationPayload, Ikev2ConfigurationPayloadBuild, + Ikev2IkeAuthPayloadBuild, PayloadChain, PayloadType, RawPayload, +}; +use bytes::Bytes; +use opc_protocol::DecodeContext; +use std::fmt; + +/// Requested inner address families. At least one family is always requested. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressFamilies { + /// IPv4 only. + Ipv4, + /// IPv6 only. + Ipv6, + /// Both families. + Dual, +} +impl AddressFamilies { + /// Whether IPv4 is requested. + pub const fn ipv4(self) -> bool { + matches!(self, Self::Ipv4 | Self::Dual) + } + /// Whether IPv6 is requested. + pub const fn ipv6(self) -> bool { + matches!(self, Self::Ipv6 | Self::Dual) + } + fn from_presence(ipv4: bool, ipv6: bool) -> Result { + match (ipv4, ipv6) { + (true, true) => Ok(Self::Dual), + (true, false) => Ok(Self::Ipv4), + (false, true) => Ok(Self::Ipv6), + _ => Err(Error::Missing), + } + } +} + +/// Configuration portion of the UE's IKE_AUTH request (TS 24.502 7.3.2.2). +/// AUTH, SA, and traffic-selector validation remain in their existing codecs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigurationRequest { + /// Requested inner families, using empty CP attribute values. + pub families: AddressFamilies, + /// Whether the UE advertises RFC 4555 support. The N3IWF response includes + /// MOBIKE_SUPPORTED only when IPv4 was requested as well. + pub mobike_supported: bool, +} +impl ConfigurationRequest { + /// Canonical configuration entries to combine with the caller's IKE_AUTH + /// payloads before protecting the exchange. + pub fn payloads(self) -> Result, Error> { + let mut attributes = Vec::new(); + for (present, attribute_type) in [(self.families.ipv4(), 1), (self.families.ipv6(), 8)] { + if present { + attributes.push(Ikev2ConfigurationAttributeBuild { + attribute_type, + value: vec![], + }); + } + } + let mut out = vec![cp_entry(1, attributes)?]; + if self.mobike_supported { + out.push(notify_entry(Notify::MobikeSupported)?); + } + Ok(out) + } + /// Decode the configuration portion of an opened IKE_AUTH request. + /// Bounds count every payload and CP attribute, including ignored entries. + /// Unknown CP attributes and unrelated status notifies are ignored; callers + /// handle their other protocol profiles separately. Duplicate known fields + /// and additional CP payloads are rejected. + pub fn decode(first: PayloadType, bytes: &[u8], limits: Limits) -> Result { + let mut families = None; + let mut mobike = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + match raw.payload_type { + PayloadType::Configuration => { + let cp = cp_decode(raw.body, limits)?; + if cp.config_type != 1 { + return Err(Error::Incompatible); + } + let mut ipv4 = None; + let mut ipv6 = None; + for attr in cp.attributes { + match attr.attribute_type { + 1 | 8 => { + if !attr.value.is_empty() { + return Err(Error::InvalidValue); + } + once( + if attr.attribute_type == 1 { + &mut ipv4 + } else { + &mut ipv6 + }, + (), + )?; + } + _ => (), + } + } + once( + &mut families, + AddressFamilies::from_presence(ipv4.is_some(), ipv6.is_some())?, + )?; + } + PayloadType::Notify => match profile_notify(raw.body)? { + Some(Notify::MobikeSupported) => once(&mut mobike, ())?, + Some(_) => return Err(Error::Incompatible), + None => (), + }, + _ => (), + } + } + Ok(Self { + families: families.ok_or(Error::Missing)?, + mobike_supported: mobike.is_some(), + }) + } +} + +/// NAS endpoint alternatives. The consumer chooses one address and keeps it +/// for the TCP session, as required by TS 24.502 section 8.2.3. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct NasEndpoint { + ipv4: Option
, + ipv6: Option
, + port: u16, +} +impl NasEndpoint { + /// Construct at least one family with its shared NAS_TCP_PORT. + pub fn new(ipv4: Option
, ipv6: Option
, port: u16) -> Result { + AddressFamilies::from_presence(ipv4.is_some(), ipv6.is_some())?; + if ipv4.is_some_and(|a| !a.is_ipv4()) || ipv6.is_some_and(Address::is_ipv4) { + return Err(Error::Incompatible); + } + Ok(Self { ipv4, ipv6, port }) + } + /// Optional IPv4 endpoint. + pub const fn ipv4(self) -> Option
{ + self.ipv4 + } + /// Optional IPv6 endpoint. + pub const fn ipv6(self) -> Option
{ + self.ipv6 + } + /// Explicit port access. Reachability and local port policy are caller-owned. + pub const fn port(self) -> u16 { + self.port + } +} +impl fmt::Debug for NasEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("NasEndpoint([REDACTED])") + } +} + +/// Validated CFG_REPLY plus NAS transport configuration and conditional MOBIKE. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ConfigurationReply { + ipv4: Option
, + ipv6: Option<(Address, u8)>, + nas: NasEndpoint, + mobike: bool, +} +impl fmt::Debug for ConfigurationReply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ConfigurationReply([REDACTED])") + } +} +impl ConfigurationReply { + /// Construct a reply to an exact request. Assigned families must have been + /// requested; each NAS endpoint needs its matching assigned family. IPv6 + /// attributes include a prefix length of 0..=128 (RFC 7296 section 3.15.1). + pub fn new( + request: ConfigurationRequest, + ipv4: Option
, + ipv6: Option<(Address, u8)>, + nas: NasEndpoint, + ) -> Result { + AddressFamilies::from_presence(ipv4.is_some(), ipv6.is_some())?; + if ipv4.is_some_and(|a| !a.is_ipv4()) + || ipv6.is_some_and(|(a, p)| a.is_ipv4() || p > 128) + || (ipv4.is_some() && !request.families.ipv4()) + || (ipv6.is_some() && !request.families.ipv6()) + || (nas.ipv4.is_some() && ipv4.is_none()) + || (nas.ipv6.is_some() && ipv6.is_none()) + { + return Err(Error::Incompatible); + } + Ok(Self { + ipv4, + ipv6, + nas, + mobike: request.mobike_supported && request.families.ipv4(), + }) + } + /// Assigned UE IPv4 address. + pub const fn ipv4(self) -> Option
{ + self.ipv4 + } + /// Assigned UE IPv6 address and prefix length. + pub const fn ipv6(self) -> Option<(Address, u8)> { + self.ipv6 + } + /// Offered NAS endpoint alternatives. + pub const fn nas(self) -> NasEndpoint { + self.nas + } + /// Whether the response advertises MOBIKE, conditional on the request. + pub const fn mobike_supported(self) -> bool { + self.mobike + } + /// Build the configuration entries for the final IKE_AUTH response. + pub fn payloads(self) -> Result, Error> { + let mut attributes = Vec::new(); + if let Some(a) = self.ipv4 { + attributes.push(Ikev2ConfigurationAttributeBuild { + attribute_type: 1, + value: a.octets(), + }); + } + if let Some((a, p)) = self.ipv6 { + let mut value = a.octets(); + value.push(p); + attributes.push(Ikev2ConfigurationAttributeBuild { + attribute_type: 8, + value, + }); + } + let mut out = vec![cp_entry(2, attributes)?]; + for a in [self.nas.ipv4, self.nas.ipv6].into_iter().flatten() { + out.push(notify_entry(Notify::NasAddress(a))?); + } + out.push(notify_entry(Notify::NasTcpPort(self.nas.port))?); + if self.mobike { + out.push(notify_entry(Notify::MobikeSupported)?); + } + Ok(out) + } + /// Decode the configuration portion of a final opened IKE_AUTH response + /// against the original request. Payload order is unrestricted. + pub fn decode( + request: ConfigurationRequest, + first: PayloadType, + bytes: &[u8], + limits: Limits, + ) -> Result { + let mut cp_seen = None; + let mut ipv4 = None; + let mut ipv6 = None; + let mut nas4 = None; + let mut nas6 = None; + let mut port = None; + let mut mobike = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + match raw.payload_type { + PayloadType::Configuration => { + once(&mut cp_seen, ())?; + let cp = cp_decode(raw.body, limits)?; + if cp.config_type != 2 { + return Err(Error::Incompatible); + } + for attr in cp.attributes { + match attr.attribute_type { + 1 => once( + &mut ipv4, + Address::new(std::net::IpAddr::V4(std::net::Ipv4Addr::from( + <[u8; 4]>::try_from(attr.value) + .map_err(|_| Error::InvalidValue)?, + ))), + )?, + 8 => { + if attr.value.len() != 17 { + return Err(Error::InvalidValue); + } + let addr = + Address::new(std::net::IpAddr::V6(std::net::Ipv6Addr::from( + <[u8; 16]>::try_from(&attr.value[..16]) + .map_err(|_| Error::InvalidValue)?, + ))); + once(&mut ipv6, (addr, attr.value[16]))?; + } + _ => (), + } + } + } + PayloadType::Notify => match profile_notify(raw.body)? { + Some(Notify::NasAddress(a)) => { + once(if a.is_ipv4() { &mut nas4 } else { &mut nas6 }, a)? + } + Some(Notify::NasTcpPort(p)) => once(&mut port, p)?, + Some(Notify::MobikeSupported) => once(&mut mobike, ())?, + Some(_) => return Err(Error::Incompatible), + None => (), + }, + _ => (), + } + } + cp_seen.ok_or(Error::Missing)?; + let nas = NasEndpoint::new(nas4, nas6, port.ok_or(Error::Missing)?)?; + let reply = Self::new(request, ipv4, ipv6, nas)?; + if reply.mobike != mobike.is_some() { + return Err(Error::Incompatible); + } + Ok(reply) + } +} + +pub(super) fn chain( + first: PayloadType, + bytes: &[u8], + limits: Limits, +) -> Result, opc_protocol::DecodeError>>, Error> { + limits.check(bytes.len(), 0)?; + Ok( + PayloadChain::new(first, bytes).iter_with_context(DecodeContext { + max_ies: limits.entries, + max_message_len: limits.bytes, + ..DecodeContext::default() + }), + ) +} +pub(super) fn notify_entry(notify: Notify<'_>) -> Result { + Ok(Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Notify, + body: notify.encode_body()?, + }) +} +fn cp_entry( + config_type: u8, + attributes: Vec, +) -> Result { + Ok(Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Configuration, + body: build_ike_auth_configuration_payload(&Ikev2ConfigurationPayloadBuild { + config_type, + attributes, + }) + .map_err(|_| Error::InvalidValue)?, + }) +} +fn cp_decode(body: &[u8], limits: Limits) -> Result, Error> { + limits.check(body.len(), 0)?; + if body.len() > 65_531 { + return Err(Error::Limit); + } + // Preflight count before the generic decoder allocates its attribute vector. + let mut rest = body.get(4..).ok_or(Error::Framing)?; + let mut count = 0; + while !rest.is_empty() { + let (&[_, _, a, b], tail) = rest.split_first_chunk::<4>().ok_or(Error::Framing)?; + rest = tail + .get(usize::from(u16::from_be_bytes([a, b]))..) + .ok_or(Error::Framing)?; + count += 1; + limits.check(body.len(), count)?; + } + Ikev2ConfigurationPayload::decode_body(body).map_err(|_| Error::Framing) +} +fn profile_notify(body: &[u8]) -> Result>, Error> { + let value = crate::Ikev2NotifyPayload::decode_body(body).map_err(|_| Error::Framing)?; + if value.notify_message_type < 16_384 { + return Err(Error::Incompatible); + } + Notify::decode(value) +} + +/// Chain profile payload entries with the existing generic IKE encoder. +/// Callers can append AUTH, SA and other entries before invoking this function. +pub fn encode_payloads( + entries: &[Ikev2IkeAuthPayloadBuild], +) -> Result<(PayloadType, Bytes), Error> { + build_ike_auth_cleartext_payload_chain(entries).map_err(|_| Error::Framing) +} diff --git a/crates/opc-proto-ikev2/src/nwu/create.rs b/crates/opc-proto-ikev2/src/nwu/create.rs new file mode 100644 index 000000000..75f288394 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/create.rs @@ -0,0 +1,366 @@ +use super::{ + configuration::{chain, notify_entry}, + once, Address, AddressFamilies, Error, Limits, Notify, Peer, QosInfo, RequestIdentity, +}; +use crate::{ + build_create_child_sa_rekey_response_payloads, + dedicated_bearer::{ + validate_ke_view, validate_sa_build, validate_sa_view, validate_selected_key_exchange, + validate_selected_proposal, + }, + Header, Ikev2CreateChildSaRekeyResponseBuild, Ikev2IkeAuthPayloadBuild, + Ikev2KeyExchangePayload, Ikev2KeyExchangePayloadBuild, Ikev2NoncePayload, + Ikev2NoncePayloadBuild, Ikev2NotifyPayload, Ikev2SaPayload, Ikev2SaPayloadBuild, + Ikev2TrafficSelectorBuild, Ikev2TrafficSelectorPayload, Ikev2TrafficSelectorPayloadBuild, + PayloadType, EXCHANGE_TYPE_CREATE_CHILD_SA, +}; +use std::fmt; + +/// All-packet selectors for the caller's admitted inner address families. +/// Protocol zero, complete ports, and complete address ranges use the existing +/// RFC 7296 TS representation; TS does not narrow to a QFI or QoS flow. +pub fn all_packet_selectors(families: AddressFamilies) -> Ikev2TrafficSelectorPayloadBuild { + let mut selectors = Vec::new(); + for (present, len, ts_type) in [(families.ipv4(), 4, 7), (families.ipv6(), 16, 8)] { + if present { + selectors.push(Ikev2TrafficSelectorBuild { + ts_type, + ip_protocol_id: 0, + start_port: 0, + end_port: 65_535, + start_address: vec![0; len], + end_address: vec![255; len], + }); + } + } + Ikev2TrafficSelectorPayloadBuild { selectors } +} +fn selector_families(value: &Ikev2TrafficSelectorPayload<'_>) -> Result { + let mut v4 = None; + let mut v6 = None; + for selector in &value.selectors { + if selector.ip_protocol_id != 0 + || selector.start_port != 0 + || selector.end_port != 65_535 + || selector.start_address.iter().any(|b| *b != 0) + || selector.end_address.iter().any(|b| *b != 255) + { + return Err(Error::Incompatible); + } + match selector.ts_type { + 7 if selector.start_address.len() == 4 && selector.end_address.len() == 4 => { + once(&mut v4, ())? + } + 8 if selector.start_address.len() == 16 && selector.end_address.len() == 16 => { + once(&mut v6, ())? + } + _ => return Err(Error::Incompatible), + } + } + match (v4.is_some(), v6.is_some()) { + (true, true) => Ok(AddressFamilies::Dual), + (true, false) => Ok(AddressFamilies::Ipv4), + (false, true) => Ok(AddressFamilies::Ipv6), + _ => Err(Error::Missing), + } +} +fn address_applicable(families: AddressFamilies, address: Address) -> Result<(), Error> { + if if address.is_ipv4() { + families.ipv4() + } else { + families.ipv6() + } { + Ok(()) + } else { + Err(Error::Incompatible) + } +} + +/// Network-initiated new Child-SA construction input. The caller owns proposal +/// policy, SPI allocation, nonce generation, KE state and the unique-default-SA +/// invariant across its full PDU-session roster. +#[derive(Clone)] +pub struct CreateRequestBuild<'a> { + /// ESP proposals from caller-selected cryptographic capabilities. + pub security_association: Ikev2SaPayloadBuild, + /// Caller-generated nonce. + pub nonce: Ikev2NoncePayloadBuild, + /// Optional PFS KE matching an offered DH transform. + pub key_exchange: Option, + /// Inner families admitted by the earlier configuration exchange. + pub inner_families: AddressFamilies, + /// Exactly one UP address, applicable to an admitted family. + pub up_address: Address, + /// Complete association; zero or more QFIs and a typed default indication. + pub qos: QosInfo<'a>, +} +impl fmt::Debug for CreateRequestBuild<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CreateRequestBuild([REDACTED])") + } +} +impl CreateRequestBuild<'_> { + /// Construct SA, Nonce, optional KE, all-packet TSi/TSr and the two NWu + /// notifies. Existing generic ESP and KE validators run before returning. + pub fn payloads(&self) -> Result, Error> { + address_applicable(self.inner_families, self.up_address)?; + validate_sa_build(&self.security_association, false).map_err(|_| Error::InvalidValue)?; + let common = + build_create_child_sa_rekey_response_payloads(&Ikev2CreateChildSaRekeyResponseBuild { + security_association: self.security_association.clone(), + nonce: self.nonce.clone(), + key_exchange: self.key_exchange.clone(), + traffic_selectors_initiator: all_packet_selectors(self.inner_families), + traffic_selectors_responder: all_packet_selectors(self.inner_families), + }) + .map_err(|_| Error::InvalidValue)?; + let sa = Ikev2SaPayload::decode_body(&common.security_association.body) + .map_err(|_| Error::Framing)?; + let ke = common + .key_exchange + .as_ref() + .map(|v| Ikev2KeyExchangePayload::decode_body(&v.body)) + .transpose() + .map_err(|_| Error::Framing)?; + validate_ke_view(&sa, ke.as_ref()).map_err(|_| Error::Incompatible)?; + let mut out = common.into_payloads(); + out.push(notify_entry(Notify::Qos(self.qos))?); + out.push(notify_entry(Notify::UpAddress(self.up_address))?); + Ok(out) + } +} + +/// Opened, structurally validated NWu Child-SA creation request. This value is +/// not evidence of peer authentication or cryptographic policy acceptance. +#[derive(Clone)] +pub struct CreateRequest<'a> { + identity: RequestIdentity, + sa: Ikev2SaPayload<'a>, + nonce: Ikev2NoncePayload<'a>, + ke: Option>, + families: AddressFamilies, + address: Address, + qos: QosInfo<'a>, +} +impl fmt::Debug for CreateRequest<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CreateRequest([REDACTED])") + } +} +impl<'a> CreateRequest<'a> { + /// Decode a network-initiated request against the admitted inner families. + /// Reject rekey notifications, missing or duplicate required fields, several + /// UP addresses, narrowed selectors, and inconsistent KE/proposal shape. + pub fn decode( + header: &Header, + first: PayloadType, + bytes: &'a [u8], + admitted: AddressFamilies, + limits: Limits, + ) -> Result { + let identity = + RequestIdentity::from_request(header, Peer::Network, EXCHANGE_TYPE_CREATE_CHILD_SA)?; + let mut common = Common::default(); + let mut address = None; + let mut qos = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + if raw.payload_type == PayloadType::Notify { + let n = Ikev2NotifyPayload::decode(raw).map_err(|_| Error::Framing)?; + if n.notify_message_type < 16_384 || n.notify_message_type == 16_393 { + return Err(Error::Incompatible); + } + match Notify::decode(n)? { + Some(Notify::UpAddress(v)) => once(&mut address, v)?, + Some(Notify::Qos(v)) => once(&mut qos, v)?, + Some(_) => return Err(Error::Incompatible), + None => (), + } + } else { + common.add(raw)?; + } + } + let (sa, nonce, ke, families) = common.finish(false)?; + if families != admitted { + return Err(Error::Incompatible); + } + let address = address.ok_or(Error::Missing)?; + address_applicable(admitted, address)?; + Ok(Self { + identity, + sa, + nonce, + ke, + families, + address, + qos: qos.ok_or(Error::Missing)?, + }) + } + /// Original request identity for matching the eventual response. + pub const fn identity(&self) -> RequestIdentity { + self.identity + } + /// ESP proposals, ready for caller-ordered policy selection. + pub fn security_association(&self) -> &Ikev2SaPayload<'a> { + &self.sa + } + /// Caller-visible nonce for the existing key-derivation boundary. + pub const fn nonce(&self) -> Ikev2NoncePayload<'a> { + self.nonce + } + /// Optional KE for the existing key-agreement boundary. + pub const fn key_exchange(&self) -> Option> { + self.ke + } + /// One applicable user-plane address. + pub const fn up_address(&self) -> Address { + self.address + } + /// Complete QoS association. + pub const fn qos(&self) -> QosInfo<'a> { + self.qos + } + /// All-packet selector families. + pub const fn families(&self) -> AddressFamilies { + self.families + } + /// Validate an accepted opened response against this request. Exact header + /// correlation, selected-proposal membership and KE relationship are + /// checked, and the NWu all-packet selector invariant is retained. + pub fn accepted_response<'b>( + &self, + header: &Header, + first: PayloadType, + bytes: &'b [u8], + limits: Limits, + ) -> Result, Error> { + self.identity.validate_response(header)?; + let mut common = Common::default(); + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + if raw.payload_type == PayloadType::Notify { + let n = Ikev2NotifyPayload::decode(raw).map_err(|_| Error::Framing)?; + if n.notify_message_type < 16_384 + || n.notify_message_type == 16_393 + || Notify::decode(n)?.is_some() + { + return Err(Error::Incompatible); + } + } else { + common.add(raw)?; + } + } + let (sa, nonce, ke, families) = common.finish(true)?; + if families != self.families { + return Err(Error::Incompatible); + } + let selected = sa.proposals.first().ok_or(Error::Missing)?; + let offered = self + .sa + .proposals + .iter() + .find(|p| p.proposal_number == selected.proposal_number) + .ok_or(Error::Incompatible)?; + validate_selected_proposal(offered, selected).map_err(|_| Error::Incompatible)?; + let selected_group = selected + .transforms + .iter() + .find(|t| t.transform_type == 4) + .map(|t| t.transform_id); + validate_selected_key_exchange( + self.ke.as_ref().map(|k| k.dh_group), + ke.as_ref(), + selected_group, + ) + .map_err(|_| Error::Incompatible)?; + Ok(CreateAccepted { sa, nonce, ke }) + } +} +/// Accepted response payloads after exact request correlation, before key +/// derivation and caller-owned SA installation. +#[derive(Clone)] +pub struct CreateAccepted<'a> { + sa: Ikev2SaPayload<'a>, + nonce: Ikev2NoncePayload<'a>, + ke: Option>, +} +impl fmt::Debug for CreateAccepted<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CreateAccepted([REDACTED])") + } +} +impl<'a> CreateAccepted<'a> { + /// Selected ESP proposal. + pub fn security_association(&self) -> &Ikev2SaPayload<'a> { + &self.sa + } + /// Peer nonce. + pub const fn nonce(&self) -> Ikev2NoncePayload<'a> { + self.nonce + } + /// Optional peer KE. + pub const fn key_exchange(&self) -> Option> { + self.ke + } +} + +#[derive(Default)] +struct Common<'a> { + sa: Option>, + nonce: Option>, + ke: Option>, + tsi: Option>, + tsr: Option>, +} +impl<'a> Common<'a> { + fn add(&mut self, raw: crate::RawPayload<'a>) -> Result<(), Error> { + match raw.payload_type { + PayloadType::SecurityAssociation => once( + &mut self.sa, + Ikev2SaPayload::decode(raw).map_err(|_| Error::Framing)?, + )?, + PayloadType::Nonce => once( + &mut self.nonce, + Ikev2NoncePayload::decode(raw).map_err(|_| Error::Framing)?, + )?, + PayloadType::KeyExchange => once( + &mut self.ke, + Ikev2KeyExchangePayload::decode(raw).map_err(|_| Error::Framing)?, + )?, + PayloadType::TrafficSelectorInitiator => once( + &mut self.tsi, + Ikev2TrafficSelectorPayload::decode(raw).map_err(|_| Error::Framing)?, + )?, + PayloadType::TrafficSelectorResponder => once( + &mut self.tsr, + Ikev2TrafficSelectorPayload::decode(raw).map_err(|_| Error::Framing)?, + )?, + PayloadType::Unknown(_) | PayloadType::VendorId => (), + _ => return Err(Error::Incompatible), + } + Ok(()) + } + fn finish( + self, + response: bool, + ) -> Result< + ( + Ikev2SaPayload<'a>, + Ikev2NoncePayload<'a>, + Option>, + AddressFamilies, + ), + Error, + > { + let sa = self.sa.ok_or(Error::Missing)?; + let nonce = self.nonce.ok_or(Error::Missing)?; + validate_sa_view(&sa, response).map_err(|_| Error::InvalidValue)?; + validate_ke_view(&sa, self.ke.as_ref()).map_err(|_| Error::Incompatible)?; + let tsi = selector_families(&self.tsi.ok_or(Error::Missing)?)?; + let tsr = selector_families(&self.tsr.ok_or(Error::Missing)?)?; + if tsi != tsr { + return Err(Error::Incompatible); + } + Ok((sa, nonce, self.ke, tsi)) + } +} diff --git a/crates/opc-proto-ikev2/src/nwu/lifecycle.rs b/crates/opc-proto-ikev2/src/nwu/lifecycle.rs new file mode 100644 index 000000000..2ec7ffb66 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/lifecycle.rs @@ -0,0 +1,460 @@ +use super::{ + configuration::{chain, notify_entry}, + encode_payloads, once, Error, EspSpi, Limits, Notify, QosInfo, +}; +use crate::{ + build_delete_payload_body, Header, Ikev2DeletePayload, Ikev2IkeAuthPayloadBuild, + Ikev2NotifyPayload, PayloadType, EXCHANGE_TYPE_INFORMATIONAL, +}; +use bytes::Bytes; +use std::{collections::BTreeSet, fmt}; + +/// Original IKE SA role. The UE initiated the IKE SA, even when the network +/// initiates a later CREATE_CHILD_SA or INFORMATIONAL exchange. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Peer { + /// UE, whose original IKE initiator flag is set. + Ue, + /// N3IWF, whose original IKE initiator flag is clear. + Network, +} +impl Peer { + fn initiator(self) -> bool { + self == Self::Ue + } + fn opposite(self) -> Self { + match self { + Self::Ue => Self::Network, + Self::Network => Self::Ue, + } + } +} + +/// Redacted correlation facts for an already opened request. These facts do +/// not prove integrity, peer authentication, or replay-window admission. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RequestIdentity { + initiator_spi: u64, + responder_spi: u64, + message_id: u32, + exchange_type: u8, + sender: Peer, +} +impl fmt::Debug for RequestIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RequestIdentity([REDACTED])") + } +} +impl RequestIdentity { + /// Validate the request's header role and exchange kind. The caller must + /// supply a header authenticated together with the opened payload bytes. + pub fn from_request(header: &Header, sender: Peer, exchange_type: u8) -> Result { + validate_header(header, sender, false, exchange_type)?; + Ok(Self { + initiator_spi: header.initiator_spi, + responder_spi: header.responder_spi, + message_id: header.message_id, + exchange_type, + sender, + }) + } + /// Match response role, message ID, exchange type and both IKE SPIs. + pub fn validate_response(self, header: &Header) -> Result<(), Error> { + validate_header(header, self.sender.opposite(), true, self.exchange_type)?; + if header.initiator_spi != self.initiator_spi + || header.responder_spi != self.responder_spi + || header.message_id != self.message_id + { + return Err(Error::Incompatible); + } + Ok(()) + } + /// Request sender; useful for caller-owned upper-layer notification. + pub const fn sender(self) -> Peer { + self.sender + } +} +fn validate_header( + header: &Header, + sender: Peer, + response: bool, + exchange_type: u8, +) -> Result<(), Error> { + if header.major_version != 2 + || header.initiator_spi == 0 + || header.responder_spi == 0 + || header.exchange_type != exchange_type + || header.flags.initiator() != sender.initiator() + || header.flags.response() != response + || header.next_payload != PayloadType::Encrypted.as_u8() + { + return Err(Error::Incompatible); + } + Ok(()) +} + +/// Full replacement of the QoS association for one sender-owned inbound SPI. +/// Applying this object must replace, rather than append to, the old QFI list, +/// DSCP, default indication, and additional QoS parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Modification<'a> { + /// N3IWF inbound ESP SPI identifying the existing child SA. + pub inbound_spi: EspSpi, + /// Complete replacement association. + pub replacement: QosInfo<'a>, +} +impl<'a> Modification<'a> { + /// Construct the UP_SA_INFO and full 5G_QOS_INFO notification pair. + pub fn payloads(self) -> Result, Error> { + Ok(vec![ + notify_entry(Notify::UpSaInfo(self.inbound_spi))?, + notify_entry(Notify::Qos(self.replacement))?, + ]) + } + /// Decode a network-initiated opened modification request. Both notifies + /// are mandatory singletons; their order is unrestricted. Unknown status + /// notifies and unknown noncritical payloads are ignored after framing. + pub fn decode( + header: &Header, + first: PayloadType, + bytes: &'a [u8], + limits: Limits, + ) -> Result<(RequestIdentity, Self), Error> { + let identity = + RequestIdentity::from_request(header, Peer::Network, EXCHANGE_TYPE_INFORMATIONAL)?; + let mut spi = None; + let mut qos = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + match raw.payload_type { + PayloadType::Notify => { + let value = Ikev2NotifyPayload::decode(raw).map_err(|_| Error::Framing)?; + if value.notify_message_type < 16_384 { + return Err(Error::Incompatible); + } + match Notify::decode(value)? { + Some(Notify::UpSaInfo(v)) => once(&mut spi, v)?, + Some(Notify::Qos(v)) => once(&mut qos, v)?, + Some(_) => return Err(Error::Incompatible), + None => (), + } + } + PayloadType::Unknown(_) | PayloadType::VendorId => (), + _ => return Err(Error::Incompatible), + } + } + Ok(( + identity, + Self { + inbound_spi: spi.ok_or(Error::Missing)?, + replacement: qos.ok_or(Error::Missing)?, + }, + )) + } +} + +/// Peer error code retained without its SPI or notification bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerError(u16); +impl PeerError { + /// Nonzero RFC 7296 error-notify code (below 16384). + pub const fn code(self) -> u16 { + self.0 + } +} +/// Distinct modification results. Only `Accepted` authorizes a caller to apply +/// the complete replacement; a timeout leaves the peer's state unknown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModificationOutcome { + /// Matching, empty INFORMATIONAL response. + Accepted, + /// Matching error response. Preserve the old association locally. + Rejected(PeerError), + /// Caller exhausted its retransmission policy without a valid response. + AmbiguousTimeout, +} +/// A caller-recorded outgoing modification, consumable once at resolution. +#[derive(Debug)] +pub struct PendingModification(RequestIdentity); +impl PendingModification { + /// Record a network-initiated modification after its payloads were built. + pub fn new(header: &Header) -> Result { + Ok(Self(RequestIdentity::from_request( + header, + Peer::Network, + EXCHANGE_TYPE_INFORMATIONAL, + )?)) + } + /// Resolve an authenticated matching response. An empty opened chain is + /// success; a single error notification is rejection. Unexpected content + /// fails closed. This boundary performs no SA mutation. + pub fn response( + self, + header: &Header, + first: PayloadType, + bytes: &[u8], + limits: Limits, + ) -> Result { + self.0.validate_response(header)?; + limits.check(bytes.len(), 0)?; + if first == PayloadType::NoNext && bytes.is_empty() { + return Ok(ModificationOutcome::Accepted); + } + let mut error = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + if raw.payload_type != PayloadType::Notify { + return Err(Error::Incompatible); + } + let value = Ikev2NotifyPayload::decode(raw).map_err(|_| Error::Framing)?; + if !(1..16_384).contains(&value.notify_message_type) { + return Err(Error::Incompatible); + } + once(&mut error, PeerError(value.notify_message_type))?; + } + Ok(ModificationOutcome::Rejected(error.ok_or(Error::Missing)?)) + } + /// Record caller-determined retransmission exhaustion without claiming the + /// peer rejected or accepted the change. + pub fn timeout(self) -> ModificationOutcome { + ModificationOutcome::AmbiguousTimeout + } +} + +/// Whether a peer Delete request crossed the caller's initiated request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteCollision { + /// Only the received request is outstanding. + Ordinary, + /// Both peers initiated deletion concurrently. + Crossed, +} + +/// Explicit TS 24.502 section 7.7 Child-SA Delete profile, distinct from generic +/// RFC 7296 paired-direction responses and the TS 24.302 dedicated-bearer profile. +#[derive(Clone, PartialEq, Eq)] +pub struct ChildDelete { + inbound_spis: Vec, +} +impl fmt::Debug for ChildDelete { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChildDelete") + .field("spi_count", &self.inbound_spis.len()) + .finish() + } +} +impl ChildDelete { + /// Include the complete sender-owned inbound SPI roster supplied by the + /// caller for the released PDU session. This boundary cannot infer roster + /// membership; use `validate_complete` against an authoritative snapshot. + pub fn new(inbound_spis: &[EspSpi], limits: Limits) -> Result { + check_spis(inbound_spis, limits)?; + Ok(Self { + inbound_spis: inbound_spis.to_vec(), + }) + } + /// Explicit access to every received SPI, in wire order. + pub fn inbound_spis(&self) -> &[EspSpi] { + &self.inbound_spis + } + /// Verify exact set equality against the caller's complete roster. No + /// omission, extra SPI, or duplicate is accepted; wire order is irrelevant. + pub fn validate_complete(&self, roster: &[EspSpi]) -> Result<(), Error> { + if self.inbound_spis.len() != roster.len() { + return Err(Error::Incompatible); + } + let expected: BTreeSet<_> = roster.iter().map(|v| v.octets()).collect(); + if expected.len() != roster.len() + || self + .inbound_spis + .iter() + .any(|s| !expected.contains(&s.octets())) + { + return Err(Error::Incompatible); + } + Ok(()) + } + /// Encode a Protocol ID 3, four-octet-SPI Delete payload. + pub fn payloads(&self) -> Result, Error> { + let octets: Vec<_> = self.inbound_spis.iter().map(|s| s.octets()).collect(); + let refs: Vec<_> = octets.iter().map(|s| s.as_slice()).collect(); + Ok(vec![Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Delete, + body: build_delete_payload_body(3, 4, &refs).map_err(|_| Error::InvalidValue)?, + }]) + } + /// Echo the received Delete payload for both ordinary and crossed NWu + /// requests. No error Notify is added. The request's SPI order is retained. + pub fn response_payloads( + &self, + _collision: DeleteCollision, + ) -> Result, Error> { + self.payloads() + } + /// Decode a request from either initiator. Authentication and roster lookup + /// remain caller-owned; this decoder checks only header and payload shape. + pub fn decode( + header: &Header, + sender: Peer, + first: PayloadType, + bytes: &[u8], + limits: Limits, + ) -> Result<(RequestIdentity, Self), Error> { + let identity = RequestIdentity::from_request(header, sender, EXCHANGE_TYPE_INFORMATIONAL)?; + Ok((identity, Self::decode_payloads(first, bytes, limits)?)) + } + fn decode_payloads(first: PayloadType, bytes: &[u8], limits: Limits) -> Result { + let mut result = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + if raw.payload_type != PayloadType::Delete { + return Err(Error::Incompatible); + } + // Preflight SPI size and count before generic allocation, including + // the malicious zero-size / huge-count generic-delete case. + let header = raw.body.get(..4).ok_or(Error::Framing)?; + if header[0] != 3 || header[1] != 4 { + return Err(Error::SpiShape); + } + let count = usize::from(u16::from_be_bytes([header[2], header[3]])); + limits.check(raw.body.len() + 4, count)?; + let value = Ikev2DeletePayload::decode(raw).map_err(|_| Error::Framing)?; + let spis = value + .spis + .iter() + .map(|v| EspSpi::new((*v).try_into().map_err(|_| Error::SpiShape)?)) + .collect::, _>>()?; + once(&mut result, Self::new(&spis, limits)?)?; + } + result.ok_or(Error::Missing) + } +} +fn check_spis(spis: &[EspSpi], limits: Limits) -> Result<(), Error> { + if spis.is_empty() { + return Err(Error::Missing); + } + if spis.len() > (65_535 - 8) / 4 { + return Err(Error::Limit); + } + limits.check(8 + spis.len() * 4, spis.len())?; + let unique: BTreeSet<_> = spis.iter().map(|v| v.octets()).collect(); + if unique.len() != spis.len() { + return Err(Error::Duplicate); + } + Ok(()) +} + +/// Scope of the caller's response to exhausted NWu delete retransmissions. +/// The SDK reports intent and does not discard installed state or notify an AMF. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteOutcome { + /// The peer returned the required acknowledgement. + Acknowledged, + /// Discard this IKE SA and every child SA; notify the caller's upper layers + /// (the AMF on the network side). This is not limited to the original SPI set. + DiscardIkeAndAllChildren, +} +/// An initiated Child-SA Delete awaiting the exact NWu echo. +#[derive(Debug)] +pub struct PendingChildDelete { + identity: RequestIdentity, + request: ChildDelete, +} +impl PendingChildDelete { + /// Record an outgoing request from either peer. + pub fn new(header: &Header, sender: Peer, request: ChildDelete) -> Result { + Ok(Self { + identity: RequestIdentity::from_request(header, sender, EXCHANGE_TYPE_INFORMATIONAL)?, + request, + }) + } + /// Require an exact echoed SPI sequence and a matching authenticated header. + pub fn response( + self, + header: &Header, + first: PayloadType, + bytes: &[u8], + limits: Limits, + ) -> Result { + self.identity.validate_response(header)?; + let received = ChildDelete::decode_payloads(first, bytes, limits)?; + if received != self.request { + return Err(Error::Incompatible); + } + Ok(DeleteOutcome::Acknowledged) + } + /// Escalate an unanswered initiated deletion to the whole IKE SA scope. + pub fn timeout(self) -> DeleteOutcome { + DeleteOutcome::DiscardIkeAndAllChildren + } +} + +/// Protocol ID 1 IKE-SA deletion (no SPIs), initiated by either peer. +#[derive(Debug)] +pub struct PendingIkeDelete(RequestIdentity); +impl PendingIkeDelete { + /// Record an outgoing IKE Delete request. + pub fn new(header: &Header, sender: Peer) -> Result { + Ok(Self(RequestIdentity::from_request( + header, + sender, + EXCHANGE_TYPE_INFORMATIONAL, + )?)) + } + /// Construct its single no-SPI Delete payload. + pub fn payloads() -> Result, Error> { + Ok(vec![Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Delete, + body: build_delete_payload_body(1, 0, &[]).map_err(|_| Error::InvalidValue)?, + }]) + } + /// Decode a matching IKE Delete request from either peer. The response is + /// an empty INFORMATIONAL exchange (RFC 7296 section 1.4.1). + pub fn decode_request( + header: &Header, + sender: Peer, + first: PayloadType, + bytes: &[u8], + limits: Limits, + ) -> Result { + let identity = RequestIdentity::from_request(header, sender, EXCHANGE_TYPE_INFORMATIONAL)?; + let mut seen = None; + for raw in chain(first, bytes, limits)? { + let raw = raw.map_err(|_| Error::Framing)?; + if raw.payload_type != PayloadType::Delete || raw.body != [1, 0, 0, 0] { + return Err(Error::Incompatible); + } + once(&mut seen, ())?; + } + seen.ok_or(Error::Missing)?; + Ok(identity) + } + /// Empty IKE Delete acknowledgement; both IKE SA and all child SAs are in scope. + pub fn response( + self, + header: &Header, + first: PayloadType, + bytes: &[u8], + ) -> Result { + self.0.validate_response(header)?; + if first != PayloadType::NoNext || !bytes.is_empty() { + return Err(Error::Incompatible); + } + Ok(DeleteOutcome::Acknowledged) + } + /// Retransmission exhaustion retains the whole-IKE-SA deletion scope. + pub fn timeout(self) -> DeleteOutcome { + DeleteOutcome::DiscardIkeAndAllChildren + } +} + +/// Canonical empty INFORMATIONAL acknowledgement used by modification and +/// IKE-SA deletion. Child-SA deletion instead requires an echoed Delete payload. +pub fn empty_response() -> (PayloadType, Bytes) { + (PayloadType::NoNext, Bytes::new()) +} + +/// Encode a typed modification's two required notifications. +pub fn encode_modification(value: Modification<'_>) -> Result<(PayloadType, Bytes), Error> { + encode_payloads(&value.payloads()?) +} diff --git a/crates/opc-proto-ikev2/src/nwu/mod.rs b/crates/opc-proto-ikev2/src/nwu/mod.rs new file mode 100644 index 000000000..ec9a07f90 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/mod.rs @@ -0,0 +1,100 @@ +//! Bounded TS 24.502 V18.8.0 NWu payload profiles. +//! +//! These views describe opened IKE payloads; they do not authenticate a peer, +//! install an SA, decide subscriber policy, or select a cryptographic suite. +//! Caller limits and duplicate rejection are explicit local admission policy. +//! Generic IKE and the TS 24.302 dedicated-bearer profile remain separate. + +mod configuration; +mod create; +mod lifecycle; +mod notify; +mod policy; +mod qos; + +pub use configuration::{ + encode_payloads, AddressFamilies, ConfigurationReply, ConfigurationRequest, NasEndpoint, +}; +pub use create::{all_packet_selectors, CreateAccepted, CreateRequest, CreateRequestBuild}; +pub use lifecycle::{ + empty_response, encode_modification, ChildDelete, DeleteCollision, DeleteOutcome, Modification, + ModificationOutcome, Peer, PeerError, PendingChildDelete, PendingIkeDelete, + PendingModification, RequestIdentity, +}; +pub use notify::{Address, EspSpi, Notify}; +pub use policy::{AeadPolicy, AeadSelection, AeadSuite}; +pub use qos::{AdditionalQos, QosInfo, QosParameter}; + +use std::{error::Error as StdError, fmt}; + +/// Redacted profile rejection. No error retains packet or deployment values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// Generic payload framing is invalid. + Framing, + /// A value has the wrong length or encoding. + InvalidValue, + /// The Notify SPI shape does not match its type. + SpiShape, + /// A singleton attribute, notification, QFI, or parameter is repeated. + Duplicate, + /// A required profile field is absent. + Missing, + /// A caller resource limit or a wire length limit is exceeded. + Limit, + /// A known field is incompatible with the selected profile. + Incompatible, + /// A known, unsupported profile field is present. + Unsupported, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Framing => "nwu_framing", + Self::InvalidValue => "nwu_invalid_value", + Self::SpiShape => "nwu_spi_shape", + Self::Duplicate => "nwu_duplicate", + Self::Missing => "nwu_missing", + Self::Limit => "nwu_limit", + Self::Incompatible => "nwu_incompatible", + Self::Unsupported => "nwu_unsupported", + }) + } +} +impl StdError for Error {} + +/// Inclusive caller bounds for opened payloads and configuration attributes. +/// These are resource policy, not limits imposed by TS 24.502. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Maximum opened chain or individual body size in octets. + pub bytes: usize, + /// Maximum payloads or configuration attributes, including ignored fields. + pub entries: usize, +} +impl Default for Limits { + fn default() -> Self { + Self { + bytes: 65_535, + entries: 128, + } + } +} +impl Limits { + pub(super) fn check(self, bytes: usize, entries: usize) -> Result<(), Error> { + if bytes > self.bytes || entries > self.entries { + Err(Error::Limit) + } else { + Ok(()) + } + } +} + +pub(super) fn once(slot: &mut Option, value: T) -> Result<(), Error> { + if slot.is_some() { + return Err(Error::Duplicate); + } + *slot = Some(value); + Ok(()) +} diff --git a/crates/opc-proto-ikev2/src/nwu/notify.rs b/crates/opc-proto-ikev2/src/nwu/notify.rs new file mode 100644 index 000000000..5760c5f46 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/notify.rs @@ -0,0 +1,184 @@ +use super::{Error, QosInfo}; +use crate::{build_ike_auth_notify_payload, Ikev2NotifyPayload, Ikev2NotifyPayloadBuild}; +use std::{ + fmt, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +/// An inner NAS/UP address with redacted diagnostics. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Address(IpAddr); +impl Address { + /// Wrap a caller-owned address without logging it. + pub const fn new(address: IpAddr) -> Self { + Self(address) + } + /// Explicit access to the wire address. + pub const fn value(self) -> IpAddr { + self.0 + } + /// Whether the address belongs to IPv4. + pub const fn is_ipv4(self) -> bool { + self.0.is_ipv4() + } + fn from_wire(data: &[u8], ipv4: bool) -> Result { + Ok(Self(if ipv4 { + IpAddr::V4(Ipv4Addr::from( + <[u8; 4]>::try_from(data).map_err(|_| Error::InvalidValue)?, + )) + } else { + IpAddr::V6(Ipv6Addr::from( + <[u8; 16]>::try_from(data).map_err(|_| Error::InvalidValue)?, + )) + })) + } + pub(super) fn octets(self) -> Vec { + match self.0 { + IpAddr::V4(a) => a.octets().to_vec(), + IpAddr::V6(a) => a.octets().to_vec(), + } + } +} +impl fmt::Debug for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(if self.is_ipv4() { + "Address::Ipv4([REDACTED])" + } else { + "Address::Ipv6([REDACTED])" + }) + } +} + +/// Sender-owned inbound ESP SPI. Zero is not a usable ESP SPI. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct EspSpi([u8; 4]); +impl EspSpi { + /// Construct a nonzero SPI. Allocation and ownership verification are caller-owned. + pub fn new(value: [u8; 4]) -> Result { + if value == [0; 4] { + Err(Error::InvalidValue) + } else { + Ok(Self(value)) + } + } + /// Explicit wire access. + pub const fn octets(self) -> [u8; 4] { + self.0 + } +} +impl fmt::Debug for EspSpi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("EspSpi([REDACTED])") + } +} + +/// Typed TS 24.502 section 9.3.1 notifications and RFC 4555 capability. +/// Unknown Notify types return `None`; known unsupported types return an error. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Notify<'a> { + /// Complete association, including its optional additional QoS information. + Qos(QosInfo<'a>), + /// N3IWF NAS transport address (55502 or 55503). + NasAddress(Address), + /// N3IWF user-plane transport address (55504 or 55505). + UpAddress(Address), + /// NAS TCP destination port (55506). A wire value of zero is retained. + NasTcpPort(u16), + /// Sender-owned inbound ESP SPI (55508). Future extension data is ignored on receive. + UpSaInfo(EspSpi), + /// MOBIKE_SUPPORTED status (16396); receive extension data is ignored. + MobikeSupported, +} +impl fmt::Debug for Notify<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Qos(_) => "Notify::Qos([REDACTED])", + Self::NasAddress(_) => "Notify::NasAddress([REDACTED])", + Self::UpAddress(_) => "Notify::UpAddress([REDACTED])", + Self::NasTcpPort(_) => "Notify::NasTcpPort([REDACTED])", + Self::UpSaInfo(_) => "Notify::UpSaInfo([REDACTED])", + Self::MobikeSupported => "Notify::MobikeSupported", + }) + } +} +impl<'a> Notify<'a> { + /// Decode a Notify body without its generic payload header. + pub fn decode_body(body: &'a [u8]) -> Result, Error> { + if body.len() > 65_531 { + return Err(Error::Limit); + } + Self::decode(Ikev2NotifyPayload::decode_body(body).map_err(|_| Error::Framing)?) + } + /// Validate a generic Notify view, including manually constructed views. + /// Protocol ID is ignored when SPI Size is zero, per RFC 7296 section 3.10. + pub fn decode(value: Ikev2NotifyPayload<'a>) -> Result, Error> { + let t = value.notify_message_type; + if !(55_501..=55_508).contains(&t) && t != 16_396 { + return Ok(None); + } + if value.spi.len() != usize::from(value.spi_size) { + return Err(Error::SpiShape); + } + if value + .spi + .len() + .saturating_add(value.notification_data.len()) + > 65_527 + { + return Err(Error::Limit); + } + if t == 55_508 { + if value.protocol_id != 3 || value.spi_size != 4 { + return Err(Error::SpiShape); + } + return Ok(Some(Self::UpSaInfo(EspSpi::new( + value.spi.try_into().map_err(|_| Error::SpiShape)?, + )?))); + } + if value.spi_size != 0 { + return Err(Error::SpiShape); + } + let data = value.notification_data; + Ok(Some(match t { + 55_501 => Self::Qos(QosInfo::decode(data)?), + 55_502 | 55_503 => Self::NasAddress(Address::from_wire(data, t == 55_502)?), + 55_504 | 55_505 => Self::UpAddress(Address::from_wire(data, t == 55_504)?), + 55_506 => Self::NasTcpPort(u16::from_be_bytes( + data.try_into().map_err(|_| Error::InvalidValue)?, + )), + 55_507 => return Err(Error::Unsupported), + // RFC 4555 section 4.2.1 reserves extension data for future use; + // a receiver MUST ignore it while senders emit an empty field. + 16_396 => Self::MobikeSupported, + _ => return Err(Error::InvalidValue), + })) + } + /// Construct a canonical body through the generic IKE Notify builder. + pub fn encode_body(self) -> Result, Error> { + let (notify_message_type, protocol_id, spi, notification_data) = match self { + Self::Qos(qos) => (55_501, 0, vec![], qos.encode()?), + Self::NasAddress(address) => ( + if address.is_ipv4() { 55_502 } else { 55_503 }, + 0, + vec![], + address.octets(), + ), + Self::UpAddress(address) => ( + if address.is_ipv4() { 55_504 } else { 55_505 }, + 0, + vec![], + address.octets(), + ), + Self::NasTcpPort(port) => (55_506, 0, vec![], port.to_be_bytes().to_vec()), + Self::UpSaInfo(spi) => (55_508, 3, spi.octets().to_vec(), vec![]), + Self::MobikeSupported => (16_396, 0, vec![], vec![]), + }; + build_ike_auth_notify_payload(&Ikev2NotifyPayloadBuild { + protocol_id, + spi, + notify_message_type, + notification_data, + }) + .map_err(|_| Error::InvalidValue) + } +} diff --git a/crates/opc-proto-ikev2/src/nwu/policy.rs b/crates/opc-proto-ikev2/src/nwu/policy.rs new file mode 100644 index 000000000..3215ea065 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/policy.rs @@ -0,0 +1,176 @@ +use super::{CreateRequest, Error, EspSpi}; +use crate::{ + Ikev2ChildSaCryptoProfile, Ikev2DhGroup, Ikev2EncryptionAlgorithm, Ikev2PrfAlgorithm, + Ikev2SaPayloadBuild, Ikev2SaProposalBuild, Ikev2SaTransformBuild, Ikev2TransformAttributeBuild, + Ikev2TransformAttributeBuildValue, +}; + +/// One caller-selected AEAD ESP suite. This optional policy boundary provides +/// no default list and does not change generic IKE/ESP algorithm support. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AeadSuite { + encryption: Ikev2EncryptionAlgorithm, + dh: Option, + esn: bool, +} +impl AeadSuite { + /// Require combined-mode encryption with no separate integrity algorithm. + /// DH group and ESN policy are explicit; neither is silently substituted. + pub fn new( + encryption: Ikev2EncryptionAlgorithm, + dh: Option, + esn: bool, + ) -> Result { + if !encryption.is_aead() { + return Err(Error::Incompatible); + } + Ok(Self { + encryption, + dh, + esn, + }) + } + /// Selected encryption capability. + pub const fn encryption(self) -> Ikev2EncryptionAlgorithm { + self.encryption + } + /// Selected optional PFS group. + pub const fn dh_group(self) -> Option { + self.dh + } + /// Whether extended sequence numbers are selected. + pub const fn extended_sequence_numbers(self) -> bool { + self.esn + } + /// Existing Child-SA KEYMAT profile, with PRF inherited from the IKE SA. + pub const fn crypto_profile(self, prf: Ikev2PrfAlgorithm) -> Ikev2ChildSaCryptoProfile { + Ikev2ChildSaCryptoProfile::new_aead(prf, self.encryption) + } + fn transforms(self) -> Vec { + let mut out = vec![Ikev2SaTransformBuild { + transform_type: 1, + transform_id: self.encryption.transform_id(), + attributes: vec![Ikev2TransformAttributeBuild { + attribute_type: 14, + value: Ikev2TransformAttributeBuildValue::Tv(self.encryption.key_bits()), + }], + }]; + if let Some(dh) = self.dh { + out.push(Ikev2SaTransformBuild { + transform_type: 4, + transform_id: dh.transform_id(), + attributes: vec![], + }); + } + out.push(Ikev2SaTransformBuild { + transform_type: 5, + transform_id: u16::from(self.esn), + attributes: vec![], + }); + out + } +} +/// Strict caller-order ESP selection. No implicit suite, integrity transform, +/// or fallback is added. Selection has no crypto, key, SPI-allocation or backend +/// operation effects; it produces intent for those separate boundaries. +#[derive(Debug, Clone)] +pub struct AeadPolicy { + preferred: Vec, +} +impl AeadPolicy { + /// Configure most-preferred first; reject empty or duplicated suites. + pub fn new(preferred: Vec) -> Result { + if preferred.is_empty() { + return Err(Error::Missing); + } + if preferred.len() > 128 { + return Err(Error::Limit); + } + for (i, v) in preferred.iter().enumerate() { + if preferred[..i].contains(v) { + return Err(Error::Duplicate); + } + } + Ok(Self { preferred }) + } + /// Try caller suite order before peer proposal order. A preferred DH group + /// must match the supplied KE and its exact public-value size. Disjoint or + /// non-AEAD-only offers fail without producing negotiation intent. + pub fn select(&self, request: &CreateRequest<'_>) -> Result { + for suite in self.preferred.iter().copied() { + for p in &request.security_association().proposals { + if p.transforms.iter().any(|t| t.transform_type == 3) { + continue; + } + let encryption = p.transforms.iter().any(|t| { + t.transform_type == 1 + && Ikev2EncryptionAlgorithm::from_sa_transform(t) == Ok(suite.encryption) + }); + let esn = p + .transforms + .iter() + .any(|t| t.transform_type == 5 && t.transform_id == u16::from(suite.esn)); + let dh = match suite.dh { + Some(group) => p + .transforms + .iter() + .any(|t| t.transform_type == 4 && t.transform_id == group.transform_id()), + None => { + !p.transforms.iter().any(|t| t.transform_type == 4) + || p.transforms + .iter() + .any(|t| t.transform_type == 4 && t.transform_id == 0) + } + }; + if !encryption || !esn || !dh { + continue; + } + if let Some(group) = suite.dh { + let Some(ke) = request.key_exchange() else { + continue; + }; + if ke.dh_group != group.transform_id() { + continue; + } + if ke.key_exchange_data.len() != group.public_value_len() { + return Err(Error::InvalidValue); + } + } + return Ok(AeadSelection { + suite, + proposal_number: p.proposal_number, + peer_spi: EspSpi::new(p.spi.try_into().map_err(|_| Error::SpiShape)?)?, + }); + } + } + Err(Error::Incompatible) + } +} +/// Selected suite and peer inbound SPI, before any operation or key derivation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AeadSelection { + suite: AeadSuite, + proposal_number: u8, + peer_spi: EspSpi, +} +impl AeadSelection { + /// Chosen caller suite. + pub const fn suite(self) -> AeadSuite { + self.suite + } + /// Sender-owned inbound SPI from the offered proposal. + pub const fn peer_spi(self) -> EspSpi { + self.peer_spi + } + /// Construct the response SA using a separately allocated local inbound SPI. + pub fn response_sa(self, local_spi: EspSpi) -> Ikev2SaPayloadBuild { + Ikev2SaPayloadBuild { + proposals: vec![Ikev2SaProposalBuild { + proposal_number: self.proposal_number, + protocol_id: 3, + spi: local_spi.octets().to_vec(), + transforms: self.suite.transforms(), + }], + } + } +} diff --git a/crates/opc-proto-ikev2/src/nwu/qos.rs b/crates/opc-proto-ikev2/src/nwu/qos.rs new file mode 100644 index 000000000..0c9a55b32 --- /dev/null +++ b/crates/opc-proto-ikev2/src/nwu/qos.rs @@ -0,0 +1,341 @@ +use super::Error; +use std::fmt; + +/// One supported additional QoS parameter, using exact TS 24.502 wire units. +/// Values are validated by [`AdditionalQos::encode_parameters`]. No rate policy +/// or floating-point unit conversion is performed. Diagnostics redact contents. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum QosParameter<'a> { + /// QoS characteristics (1): resource type, priority, delay, error rate, and + /// conditional averaging window / maximum burst. Exact wire representation. + Characteristics(&'a [u8]), + /// MFBR downlink (2), unit followed by the big-endian value. + MaximumDownlink([u8; 3]), + /// MFBR uplink (3). + MaximumUplink([u8; 3]), + /// GFBR downlink (4). + GuaranteedDownlink([u8; 3]), + /// GFBR uplink (5). + GuaranteedUplink([u8; 3]), + /// Maximum downlink packet loss in tenths of one percent (7). + LossDownlink(u16), + /// Maximum uplink packet loss in tenths of one percent (8). + LossUplink(u16), +} +impl fmt::Debug for QosParameter<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("QosParameter([REDACTED])") + } +} + +/// Validated, borrowed additional QoS parameter list, including the count octet. +/// Unknown parameters and Notification Control (6) are discarded by iteration +/// and canonical encoding as TS 24.502 specifies. Their framing still counts. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AdditionalQos<'a>(&'a [u8]); +impl fmt::Debug for AdditionalQos<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AdditionalQos([REDACTED])") + } +} +impl<'a> AdditionalQos<'a> { + /// Validate a complete list. Known duplicate parameters are rejected by + /// this profile's local admission policy; ignored fields are still bounded. + pub fn new(wire: &'a [u8]) -> Result { + if wire.is_empty() { + return Err(Error::Framing); + } + if wire.len() > 252 { + return Err(Error::Limit); + } + let mut rest = &wire[1..]; + let mut seen = 0u16; + let mut non_gbr = false; + for _ in 0..wire[0] { + let (&[id, len], tail) = rest.split_first_chunk::<2>().ok_or(Error::Framing)?; + let data = tail.get(..usize::from(len)).ok_or(Error::Framing)?; + if let Some(parsed) = parameter(id, data)? { + if let QosParameter::Characteristics(value) = parsed { + non_gbr = value[0] == 2; + } + let bit = 1u16 << id; + if seen & bit != 0 { + return Err(Error::Duplicate); + } + seen |= bit; + } + rest = &tail[usize::from(len)..]; + } + if !rest.is_empty() { + return Err(Error::Framing); + } + if non_gbr && seen & !2 != 0 { + return Err(Error::Incompatible); + } + Ok(Self(wire)) + } + /// Iterate supported parameters. Validation has already checked every entry. + pub fn parameters(self) -> impl Iterator> { + let mut rest = &self.0[1..]; + std::iter::from_fn(move || { + while let Some((&[id, len], tail)) = rest.split_first_chunk::<2>() { + let data = tail.get(..usize::from(len))?; + rest = &tail[usize::from(len)..]; + if let Ok(Some(p)) = parameter(id, data) { + return Some(p); + } + } + None + }) + } + /// Build a bounded list of supported parameters. A full QoS envelope can + /// impose a smaller remaining budget after its QFI list and optional DSCP. + pub fn encode_parameters(parameters: &[QosParameter<'_>]) -> Result, Error> { + if parameters.len() > 7 { + return Err(Error::Limit); + } + let mut out = vec![parameters.len() as u8]; + for p in parameters { + match *p { + QosParameter::Characteristics(data) => { + if data.len() > 10 { + return Err(Error::InvalidValue); + } + out.extend_from_slice(&[1, data.len() as u8]); + out.extend_from_slice(data); + } + QosParameter::MaximumDownlink(data) + | QosParameter::MaximumUplink(data) + | QosParameter::GuaranteedDownlink(data) + | QosParameter::GuaranteedUplink(data) => { + let id = match p { + QosParameter::MaximumDownlink(_) => 2, + QosParameter::MaximumUplink(_) => 3, + QosParameter::GuaranteedDownlink(_) => 4, + _ => 5, + }; + out.extend_from_slice(&[id, 3]); + out.extend_from_slice(&data); + } + QosParameter::LossDownlink(value) | QosParameter::LossUplink(value) => { + out.extend_from_slice(&[ + if matches!(p, QosParameter::LossDownlink(_)) { + 7 + } else { + 8 + }, + 2, + ]); + out.extend_from_slice(&value.to_be_bytes()); + } + } + } + Self::validate_owned(&out)?; + Ok(out) + } + fn validate_owned(wire: &[u8]) -> Result<(), Error> { + AdditionalQos::new(wire).map(|_| ()) + } + fn encode(self) -> Result, Error> { + Self::encode_parameters(&self.parameters().collect::>()) + } +} + +fn parameter(id: u8, data: &[u8]) -> Result>, Error> { + Ok(Some(match id { + 1 => { + let first = *data.first().ok_or(Error::InvalidValue)?; + let len = match first { + 0 => 8, + 1 => 10, + 2 => 6, + _ => return Err(Error::Unsupported), + }; + if data.len() != len + || !(1..=127).contains(&data[1]) + || u16::from_be_bytes([data[2], data[3]]) > 1023 + || data[4] > 9 + || data[5] > 9 + || (len >= 8 && u16::from_be_bytes([data[6], data[7]]) > 4095) + || (len == 10 && u16::from_be_bytes([data[8], data[9]]) > 4095) + { + return Err(Error::InvalidValue); + } + QosParameter::Characteristics(data) + } + 2..=5 => { + // Unit codes above 25 retain their wire value; receivers interpret + // them as 256 Pbps per table 9.3.1.1-2, rather than rejecting them. + let value = data.try_into().map_err(|_| Error::InvalidValue)?; + match id { + 2 => QosParameter::MaximumDownlink(value), + 3 => QosParameter::MaximumUplink(value), + 4 => QosParameter::GuaranteedDownlink(value), + _ => QosParameter::GuaranteedUplink(value), + } + } + 7 | 8 => { + let value = u16::from_be_bytes(data.try_into().map_err(|_| Error::InvalidValue)?); + if value > 1000 { + return Err(Error::InvalidValue); + } + if id == 7 { + QosParameter::LossDownlink(value) + } else { + QosParameter::LossUplink(value) + } + } + _ => return Ok(None), + })) +} + +/// Complete QoS flow association from a 5G_QOS_INFO Notify. +/// Applying a modification replaces the previous association; this value is +/// never a delta. Reserved QFI and flag bits are ignored on receive and cleared +/// by canonical encoding. Duplicate QFIs are rejected by local admission policy. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct QosInfo<'a> { + session: u8, + qfis: &'a [u8], + default: bool, + dscp: Option, + additional: Option>, +} +impl fmt::Debug for QosInfo<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("QosInfo") + .field("qfi_count", &self.qfis.len()) + .field("values", &"[REDACTED]") + .finish() + } +} +impl<'a> QosInfo<'a> { + /// Construct a canonical association. Session IDs are 1..=15; QFIs 1..=63. + /// An empty QFI list is supported. DSCP is an unshifted six-bit value. + pub fn new( + session: u8, + qfis: &'a [u8], + default: bool, + dscp: Option, + additional: Option>, + ) -> Result { + if qfis.iter().any(|q| *q > 63) || dscp.is_some_and(|v| v > 63) { + return Err(Error::InvalidValue); + } + let value = Self { + session, + qfis, + default, + dscp, + additional, + }; + value.validate()?; + Ok(value) + } + fn validate(self) -> Result<(), Error> { + if !(1..=15).contains(&self.session) { + return Err(Error::InvalidValue); + } + let mut seen = 0u64; + for qfi in self.qfis() { + if qfi == 0 { + return Err(Error::InvalidValue); + } + let bit = 1u64 << qfi; + if seen & bit != 0 { + return Err(Error::Duplicate); + } + seen |= bit; + } + // Include all received extension bytes, even those later discarded. + let len = 3 + + self.qfis.len() + + usize::from(self.dscp.is_some()) + + self.additional.map_or(0, |v| v.0.len()); + if len > 255 { + return Err(Error::Limit); + } + Ok(()) + } + /// Decode notification data (starting with the one-octet Length field). + pub fn decode(data: &'a [u8]) -> Result { + if data.len() < 4 || usize::from(data[0]) != data.len() - 1 { + return Err(Error::Framing); + } + let count = usize::from(data[2]); + let qfis = data.get(3..3 + count).ok_or(Error::Framing)?; + let flags = *data.get(3 + count).ok_or(Error::Framing)?; + let mut rest = &data[4 + count..]; + let dscp = if flags & 1 != 0 { + let (&value, tail) = rest.split_first().ok_or(Error::Framing)?; + rest = tail; + if value > 63 { + return Err(Error::InvalidValue); + } + Some(value) + } else { + None + }; + let additional = if flags & 4 != 0 { + Some(AdditionalQos::new(rest)?) + } else { + if !rest.is_empty() { + return Err(Error::Framing); + } + None + }; + let value = Self { + session: data[1], + qfis, + default: flags & 2 != 0, + dscp, + additional, + }; + value.validate()?; + Ok(value) + } + /// Explicit access to the session identifier. + pub const fn session(self) -> u8 { + self.session + } + /// Canonical QFI values, with the two reserved bits removed. + pub fn qfis(self) -> impl ExactSizeIterator + 'a { + self.qfis.iter().map(|v| v & 0x3f) + } + /// Whether this SA is the PDU session's default SA. Roster uniqueness is caller-owned. + pub const fn is_default(self) -> bool { + self.default + } + /// Optional unshifted DSCP. + pub const fn dscp(self) -> Option { + self.dscp + } + /// Optional additional QoS information. + pub const fn additional(self) -> Option> { + self.additional + } + /// Encode the complete association with zero reserved bits. + pub fn encode(self) -> Result, Error> { + self.validate()?; + let extra = self.additional.map(AdditionalQos::encode).transpose()?; + let len = 3 + + self.qfis.len() + + usize::from(self.dscp.is_some()) + + extra.as_ref().map_or(0, Vec::len); + let mut out = Vec::with_capacity(1 + len); + out.extend_from_slice(&[len as u8, self.session, self.qfis.len() as u8]); + out.extend(self.qfis()); + out.push( + u8::from(self.dscp.is_some()) + | (u8::from(self.default) << 1) + | (u8::from(extra.is_some()) << 2), + ); + if let Some(dscp) = self.dscp { + out.push(dscp); + } + if let Some(extra) = extra { + out.extend(extra); + } + Ok(out) + } +} diff --git a/crates/opc-proto-ikev2/tests/nwu.rs b/crates/opc-proto-ikev2/tests/nwu.rs new file mode 100644 index 000000000..d0a29f4ef --- /dev/null +++ b/crates/opc-proto-ikev2/tests/nwu.rs @@ -0,0 +1,1040 @@ +use opc_proto_ikev2::nwu::{Notify, QosInfo}; + +#[test] +fn independent_qos_notify_vector() { + let body = [0, 0, 0xd8, 0xcd, 4, 5, 1, 9, 0]; + let expected = Notify::Qos(must(QosInfo::new(5, &[9], false, None, None))); + assert_eq!(must(Notify::decode_body(&body)), Some(expected)); + assert_eq!(must(expected.encode_body()), body); +} + +fn must(v: Result) -> T { + match v { + Ok(v) => v, + Err(e) => panic!("{e:?}"), + } +} + +use opc_proto_ikev2::{ + nwu::{ + encode_payloads, AdditionalQos, Address, AddressFamilies, ConfigurationReply, + ConfigurationRequest, Error, EspSpi, Limits, NasEndpoint, QosParameter, + }, + Ikev2IkeAuthPayloadBuild, PayloadChain, PayloadType, +}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +fn fixture(name: &str) -> Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../opc-n3iwf-fixtures/fixtures/nwu-ike/wire") + .join(format!("{name}.hex")); + must(std::fs::read_to_string(path)) + .split_whitespace() + .map(|b| must(u8::from_str_radix(b, 16))) + .collect() +} +fn v4(last: u8) -> Address { + Address::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, last))) +} +fn v6(last: u16) -> Address { + Address::new(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, last, + ))) +} +fn entries(values: &[(PayloadType, &[u8])]) -> Vec { + values + .iter() + .map(|(payload_type, body)| Ikev2IkeAuthPayloadBuild { + payload_type: *payload_type, + body: body.to_vec(), + }) + .collect() +} +fn independent_chain(values: &[(u8, &[u8])]) -> (PayloadType, Vec) { + let mut out = Vec::new(); + for (i, (_, body)) in values.iter().enumerate() { + out.extend_from_slice(&[values.get(i + 1).map_or(0, |v| v.0), 0]); + out.extend_from_slice(&((4 + body.len()) as u16).to_be_bytes()); + out.extend_from_slice(body); + } + (PayloadType::from_u8(values.first().map_or(0, |v| v.0)), out) +} + +#[test] +fn published_notify_vectors_and_receiver_protocol_id_rules() { + let qos = must(QosInfo::new(5, &[9], false, None, None)); + let cases = [ + ("positive-nas-ip4", Notify::NasAddress(v4(10))), + ("positive-nas-tcp-port", Notify::NasTcpPort(20000)), + ("positive-5g-qos-info", Notify::Qos(qos)), + ("positive-up-ip4", Notify::UpAddress(v4(11))), + ( + "positive-up-sa-info", + Notify::UpSaInfo(must(EspSpi::new([10, 11, 12, 13]))), + ), + ]; + for (name, expected) in cases { + let wire = fixture(name); + let raw = must(must( + PayloadChain::new(PayloadType::Notify, &wire) + .iter() + .next() + .ok_or(Error::Missing), + )); + assert_eq!(must(Notify::decode_body(raw.body)), Some(expected)); + let (first, encoded) = must(encode_payloads(&entries(&[( + PayloadType::Notify, + &must(expected.encode_body()), + )]))); + assert_eq!(first, PayloadType::Notify); + assert_eq!(encoded.as_ref(), wire); + if !matches!(expected, Notify::UpSaInfo(_)) { + let mut changed = raw.body.to_vec(); + changed[0] = 255; + assert_eq!(must(Notify::decode_body(&changed)), Some(expected)); + } + } + let extended = [3, 4, 0xd8, 0xd4, 10, 11, 12, 13, 0xa5, 0x5a]; + assert_eq!( + must(must(Notify::decode_body(&extended)).ok_or(Error::Missing)).encode_body(), + Ok(extended[..8].to_vec()) + ); + let mut wrong = extended; + wrong[0] = 0; + assert_eq!(Notify::decode_body(&wrong), Err(Error::SpiShape)); + assert_eq!(EspSpi::new([0; 4]), Err(Error::InvalidValue)); + // RFC 4555 section 4.2.1: receiver-ignored capability extension bytes. + let extended_capability = [0xff, 0, 0x40, 0x0c, 0xa5, 0x5a]; + assert_eq!( + must(Notify::decode_body(&extended_capability)), + Some(Notify::MobikeSupported) + ); + assert_eq!( + must(Notify::MobikeSupported.encode_body()), + [0, 0, 0x40, 0x0c] + ); +} + +#[test] +fn malformed_published_notify_and_critical_payload_vectors() { + for name in [ + "malformed-spi-size", + "bounded-spi-size-overflow", + "truncated-nas-ip4", + ] { + let wire = fixture(name); + let result = PayloadChain::new(PayloadType::Notify, &wire) + .iter() + .try_for_each(|p| { + let raw = p.map_err(|_| Error::Framing)?; + Notify::decode_body(raw.body).map(|_| ()) + }); + assert!(result.is_err(), "{name}"); + } + let wire = fixture("unknown-critical-payload"); + assert!(PayloadChain::new(PayloadType::Unknown(250), &wire) + .iter() + .any(|p| p.is_err())); + // This fixture proves only generic receipt; it does not establish a MOBIKE update. + let wire = fixture("mobility-additional-addresses"); + let raw = must(must( + PayloadChain::new(PayloadType::Notify, &wire) + .iter() + .next() + .ok_or(Error::Missing), + )); + assert_eq!(must(Notify::decode_body(raw.body)), None); +} + +#[test] +fn complete_qos_independent_parameters_and_full_replacement_values() { + // TS 24.502 table 9.3.1.1-2: GBR characteristics, exact rate units, + // both loss-rate directions. Authored directly, without the SDK encoder. + let extra = [ + 7, 1, 8, 0, 127, 3, 255, 9, 9, 15, 160, 2, 3, 1, 0, 10, 3, 3, 2, 0, 11, 4, 3, 3, 0, 12, 5, + 3, 255, 0, 13, 7, 2, 3, 232, 8, 2, 0, 1, + ]; + let additional = must(AdditionalQos::new(&extra)); + assert_eq!(additional.parameters().count(), 7); + assert_eq!( + must(AdditionalQos::encode_parameters( + &additional.parameters().collect::>() + )), + extra + ); + let qos = must(QosInfo::new( + 15, + &[1, 9, 63], + true, + Some(46), + Some(additional), + )); + let mut expected = vec![0, 0, 0xd8, 0xcd, 43, 15, 3, 1, 9, 63, 7, 46]; + expected.extend_from_slice(&extra); + // Length covers only octets after itself. + expected[4] = (expected.len() - 5) as u8; + assert_eq!(must(Notify::Qos(qos).encode_body()), expected); + assert_eq!(must(Notify::decode_body(&expected)), Some(Notify::Qos(qos))); + let empty = must(QosInfo::new(15, &[], false, None, None)); + assert_eq!(must(empty.encode()), [3, 15, 0, 0]); + let gbr = [0, 1, 0, 0, 0, 0, 15, 255]; + let critical = [1, 1, 0, 0, 0, 0, 15, 255, 15, 255]; + for data in [&gbr[..], &critical[..]] { + let wire = must(AdditionalQos::encode_parameters(&[ + QosParameter::Characteristics(data), + ])); + assert_eq!(must(AdditionalQos::new(&wire)).parameters().count(), 1); + } +} + +#[test] +fn qos_bounds_duplicates_spares_and_independent_adverse_mutations() { + let qfis: Vec<_> = (1..=63).collect(); + assert!(QosInfo::new(1, &qfis, false, Some(63), None).is_ok()); + for bad in [vec![0], vec![64], vec![9, 9]] { + assert!(QosInfo::new(1, &bad, false, None, None).is_err()); + } + for bad in [0, 16, 255] { + assert!(QosInfo::new(bad, &[], false, None, None).is_err()); + } + assert!(QosInfo::new(1, &[], false, Some(64), None).is_err()); + let reserved = [4, 5, 1, 0xc9, 0xfa]; + assert_eq!( + must(must(QosInfo::decode(&reserved)).encode()), + [4, 5, 1, 9, 2] + ); + // An ignored TLV still needs complete framing and counts toward the wire bound. + assert_eq!( + must(AdditionalQos::new(&[2, 99, 2, 0xaa, 0xbb, 6, 1, 0xff])) + .parameters() + .count(), + 0 + ); + for wire in [ + vec![], + vec![1], + vec![1, 2, 3, 0, 0], + vec![0, 2, 3, 0, 0, 0], + vec![2, 7, 2, 0, 1, 7, 2, 0, 2], + vec![1, 7, 2, 3, 233], + vec![1, 1, 6, 2, 0, 0, 0, 0, 0], + vec![2, 1, 6, 2, 1, 0, 0, 0, 0, 7, 2, 0, 1], + ] { + assert!(AdditionalQos::new(&wire).is_err()); + } + let good = [4, 5, 1, 9, 0]; + for n in 0..good.len() { + assert!(QosInfo::decode(&good[..n]).is_err()); + } + for (index, value) in [(0, 3), (0, 5), (2, 2), (3, 0), (4, 1), (4, 4)] { + let mut bad = good; + bad[index] = value; + assert!(QosInfo::decode(&bad).is_err(), "{index}"); + } + let mut long = vec![1, 99, 187]; + long.extend(vec![0; 187]); // 190 bytes + let extra = must(AdditionalQos::new(&long)); + // 3 fixed + 62 QFIs + 190 = 255 exactly; ignored fields do not erase input budgets. + assert!(QosInfo::new(1, &qfis[..62], false, None, Some(extra)).is_ok()); + assert_eq!( + QosInfo::new(1, &qfis, false, None, Some(extra)), + Err(Error::Limit) + ); + assert_eq!( + QosInfo::new(1, &qfis[..62], false, Some(0), Some(extra)), + Err(Error::Limit) + ); +} + +#[test] +fn independent_configuration_request_and_reply_family_matrix() { + for families in [ + AddressFamilies::Ipv4, + AddressFamilies::Ipv6, + AddressFamilies::Dual, + ] { + for mobike_supported in [false, true] { + let request = ConfigurationRequest { + families, + mobike_supported, + }; + let mut cp = vec![1, 0, 0, 0]; + if families.ipv4() { + cp.extend([0, 1, 0, 0]); + } + if families.ipv6() { + cp.extend([0, 8, 0, 0]); + } + let mut literal = vec![(47, &cp[..])]; + if mobike_supported { + literal.push((41, &[0, 0, 0x40, 0x0c])); + } + let (first, bytes) = independent_chain(&literal); + assert_eq!( + must(ConfigurationRequest::decode( + first, + &bytes, + Limits::default() + )), + request + ); + assert_eq!( + must(encode_payloads(&must(request.payloads()))).1.as_ref(), + bytes + ); + let nas = must(NasEndpoint::new( + families.ipv4().then(|| v4(10)), + families.ipv6().then(|| v6(10)), + 20000, + )); + let reply = must(ConfigurationReply::new( + request, + families.ipv4().then(|| v4(1)), + families.ipv6().then(|| (v6(1), 64)), + nas, + )); + let mut cp = vec![2, 0, 0, 0]; + if families.ipv4() { + cp.extend([0, 1, 0, 4, 192, 0, 2, 1]); + } + if families.ipv6() { + cp.extend([ + 0, 8, 0, 17, 0x20, 1, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 64, + ]); + } + let mut literal = vec![(47, &cp[..])]; + if families.ipv4() { + literal.push((41, &[0, 0, 0xd8, 0xce, 192, 0, 2, 10])); + } + if families.ipv6() { + literal.push(( + 41, + &[ + 0, 0, 0xd8, 0xcf, 0x20, 1, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, + ], + )); + } + literal.push((41, &[0, 0, 0xd8, 0xd2, 0x4e, 0x20])); + if mobike_supported && families.ipv4() { + literal.push((41, &[0, 0, 0x40, 0x0c])); + } + let (first, bytes) = independent_chain(&literal); + assert_eq!( + must(encode_payloads(&must(reply.payloads()))).1.as_ref(), + bytes + ); + assert_eq!( + must(ConfigurationReply::decode( + request, + first, + &bytes, + Limits::default() + )), + reply + ); + literal.reverse(); + let (first, bytes) = independent_chain(&literal); + assert_eq!( + must(ConfigurationReply::decode( + request, + first, + &bytes, + Limits::default() + )), + reply + ); + } + } +} + +#[test] +fn configuration_duplicate_ordering_malformed_and_limit_contracts() { + let request = ConfigurationRequest { + families: AddressFamilies::Ipv4, + mobike_supported: false, + }; + let cp = [2, 0, 0, 0, 0, 1, 0, 4, 192, 0, 2, 1]; + for name in ["duplicate-nas-ip4", "ordering-tcp-before-ip4"] { + let mut bytes = vec![41, 0, 0, 16]; + bytes.extend(cp); + bytes.extend(fixture(name)); + let result = ConfigurationReply::decode( + request, + PayloadType::Configuration, + &bytes, + Limits::default(), + ); + if name.starts_with("duplicate") { + assert_eq!(result, Err(Error::Duplicate)); + } else { + assert_eq!(must(result).nas().port(), 20000); + } + } + let reply = must(ConfigurationReply::new( + request, + Some(v4(1)), + None, + must(NasEndpoint::new(Some(v4(10)), None, 20000)), + )); + let payloads = must(reply.payloads()); + let (first, wire) = must(encode_payloads(&payloads)); + for length in 0..wire.len() { + assert!( + ConfigurationReply::decode(request, first, &wire[..length], Limits::default()).is_err() + ); + } + for i in 0..payloads.len() { + let mut dup = payloads.clone(); + dup.push(payloads[i].clone()); + let (first, wire) = must(encode_payloads(&dup)); + assert_eq!( + ConfigurationReply::decode(request, first, &wire, Limits::default()), + Err(Error::Duplicate) + ); + } + assert!(ConfigurationReply::decode( + request, + first, + &wire, + Limits { + bytes: wire.len() - 1, + entries: 3 + } + ) + .is_err()); + assert!(ConfigurationReply::decode( + request, + first, + &wire, + Limits { + bytes: wire.len(), + entries: 2 + } + ) + .is_err()); + assert!(ConfigurationReply::decode( + request, + first, + &wire, + Limits { + bytes: wire.len(), + entries: 3 + } + ) + .is_ok()); + // CP preflight bounds attributes before generic allocation; reserved bits are receive-only. + let mut many = vec![1, 0, 0, 0, 0x80, 1, 0, 0]; + many.extend([0x7f, 0xff, 0, 0]); + let (first, wire) = independent_chain(&[(47, &many)]); + assert_eq!( + ConfigurationRequest::decode( + first, + &wire, + Limits { + bytes: 128, + entries: 1 + } + ), + Err(Error::Limit) + ); + assert_eq!( + must(ConfigurationRequest::decode( + first, + &wire, + Limits { + bytes: 128, + entries: 2 + } + )), + request + ); + let invalid = [1, 0, 0, 0, 0, 1, 0, 1, 0]; + let (first, wire) = independent_chain(&[(47, &invalid)]); + assert_eq!( + ConfigurationRequest::decode(first, &wire, Limits::default()), + Err(Error::InvalidValue) + ); + assert!(ConfigurationReply::new( + request, + None, + Some((v6(1), 64)), + must(NasEndpoint::new(None, Some(v6(10)), 20000)) + ) + .is_err()); +} + +#[test] +fn diagnostic_surfaces_redact_all_deployment_values() { + let request = ConfigurationRequest { + families: AddressFamilies::Ipv4, + mobike_supported: false, + }; + let reply = must(ConfigurationReply::new( + request, + Some(v4(1)), + None, + must(NasEndpoint::new(Some(v4(10)), None, 20000)), + )); + let values = [ + format!("{reply:?}"), + format!("{:?}", reply.nas()), + format!("{:?}", v4(10)), + format!("{:?}", v6(10)), + format!("{:?}", Notify::NasTcpPort(20000)), + format!( + "{:?}", + Notify::Qos(must(QosInfo::new(5, &[9], true, Some(46), None))) + ), + format!("{:?}", must(EspSpi::new([0xaa, 0xbb, 0xcc, 0xdd]))), + ]; + for value in values { + for secret in ["192.0.2.", "2001:db8", "20000", "aabbccdd", "170, 187"] { + assert!(!value.contains(secret)); + } + } +} + +use opc_proto_ikev2::{ + nwu::{ + ChildDelete, DeleteCollision, DeleteOutcome, Modification, ModificationOutcome, Peer, + PendingChildDelete, PendingIkeDelete, PendingModification, + }, + Header, HeaderFlags, EXCHANGE_TYPE_INFORMATIONAL, +}; +fn header(sender: Peer, response: bool) -> Header { + Header::new( + 1, + 2, + PayloadType::Encrypted, + EXCHANGE_TYPE_INFORMATIONAL, + HeaderFlags::from_bits(sender == Peer::Ue, response, false), + 7, + ) +} +#[test] +fn published_modification_complete_replacement_and_role_checks() { + let wire = fixture("modify-child-sa"); + let request = header(Peer::Network, false); + let (_, value) = must(Modification::decode( + &request, + PayloadType::Notify, + &wire, + Limits::default(), + )); + assert_eq!(value.inbound_spi, must(EspSpi::new([10, 11, 12, 13]))); + assert_eq!(value.replacement.session(), 5); + assert_eq!(value.replacement.qfis().collect::>(), [10]); + assert!(!value.replacement.is_default()); + assert_eq!(value.replacement.dscp(), None); + assert_eq!(value.replacement.additional(), None); + assert_eq!( + must(encode_payloads(&must(value.payloads()))).1.as_ref(), + wire + ); + let mut payloads = must(value.payloads()); + payloads.reverse(); + let (first, bytes) = must(encode_payloads(&payloads)); + assert_eq!( + must(Modification::decode( + &request, + first, + &bytes, + Limits::default() + )) + .1, + value + ); + for wrong in [header(Peer::Ue, false), header(Peer::Network, true)] { + assert!(Modification::decode(&wrong, first, &bytes, Limits::default()).is_err()); + } + payloads.push(payloads[0].clone()); + let (first, bytes) = must(encode_payloads(&payloads)); + assert_eq!( + Modification::decode(&request, first, &bytes, Limits::default()), + Err(Error::Duplicate) + ); +} +#[test] +fn modification_acceptance_rejection_timeout_and_response_correlation_are_distinct() { + let request = header(Peer::Network, false); + let response = header(Peer::Ue, true); + assert_eq!( + must(must(PendingModification::new(&request)).response( + &response, + PayloadType::NoNext, + &[], + Limits::default() + )), + ModificationOutcome::Accepted + ); + let (first, bytes) = independent_chain(&[(41, &[0, 0, 0, 14])]); + let result = must(must(PendingModification::new(&request)).response( + &response, + first, + &bytes, + Limits::default(), + )); + match result { + ModificationOutcome::Rejected(e) => assert_eq!(e.code(), 14), + _ => panic!("wrong outcome"), + } + assert_eq!( + must(PendingModification::new(&request)).timeout(), + ModificationOutcome::AmbiguousTimeout + ); + let mut wrong = response.clone(); + wrong.message_id += 1; + assert!(must(PendingModification::new(&request)) + .response(&wrong, first, &bytes, Limits::default()) + .is_err()); + let (first, bytes) = independent_chain(&[(41, &[0, 0, 0x40, 0x0c])]); + assert!(must(PendingModification::new(&request)) + .response(&response, first, &bytes, Limits::default()) + .is_err()); + assert!(must(PendingModification::new(&request)) + .response(&response, PayloadType::NoNext, &[0], Limits::default()) + .is_err()); +} +#[test] +fn child_delete_echoes_all_inbound_spis_for_both_initiators_and_crossed_requests() { + let spis = [ + must(EspSpi::new([10, 11, 12, 13])), + must(EspSpi::new([10, 11, 12, 14])), + ]; + let deletion = must(ChildDelete::new(&spis, Limits::default())); + assert!(deletion.validate_complete(&[spis[1], spis[0]]).is_ok()); + assert!(deletion.validate_complete(&spis[..1]).is_err()); + assert!(deletion.validate_complete(&[spis[0], spis[0]]).is_err()); + let (first, bytes) = independent_chain(&[(42, &[3, 4, 0, 2, 10, 11, 12, 13, 10, 11, 12, 14])]); + assert_eq!( + must(encode_payloads(&must(deletion.payloads()))).1.as_ref(), + bytes + ); + for (sender, receiver) in [(Peer::Network, Peer::Ue), (Peer::Ue, Peer::Network)] { + let request = header(sender, false); + let response = header(receiver, true); + let (_, received) = must(ChildDelete::decode( + &request, + sender, + first, + &bytes, + Limits::default(), + )); + assert_eq!(received, deletion); + for collision in [DeleteCollision::Ordinary, DeleteCollision::Crossed] { + assert_eq!( + must(encode_payloads(&must( + received.response_payloads(collision) + ))) + .1 + .as_ref(), + bytes + ); + } + assert_eq!( + must( + must(PendingChildDelete::new(&request, sender, deletion.clone())).response( + &response, + first, + &bytes, + Limits::default() + ) + ), + DeleteOutcome::Acknowledged + ); + assert_eq!( + must(PendingChildDelete::new(&request, sender, deletion.clone())).timeout(), + DeleteOutcome::DiscardIkeAndAllChildren + ); + let opposite = must(ChildDelete::new( + &[must(EspSpi::new([0xff; 4]))], + Limits::default(), + )); + let (wrong_first, wrong) = must(encode_payloads(&must(opposite.payloads()))); + assert!( + must(PendingChildDelete::new(&request, sender, deletion.clone())) + .response(&response, wrong_first, &wrong, Limits::default()) + .is_err() + ); + assert!( + must(PendingChildDelete::new(&request, sender, deletion.clone())) + .response(&response, PayloadType::NoNext, &[], Limits::default()) + .is_err() + ); + } + let fixture = fixture("delete-esp"); + let (_, single) = must(ChildDelete::decode( + &header(Peer::Network, false), + Peer::Network, + PayloadType::Delete, + &fixture, + Limits::default(), + )); + assert_eq!(single.inbound_spis(), &spis[..1]); +} +#[test] +fn child_delete_bounds_duplicates_and_ike_delete_have_separate_wire_contracts() { + let spi = must(EspSpi::new([10, 11, 12, 13])); + assert_eq!( + ChildDelete::new(&[spi, spi], Limits::default()), + Err(Error::Duplicate) + ); + assert_eq!( + ChildDelete::new(&[], Limits::default()), + Err(Error::Missing) + ); + assert_eq!( + ChildDelete::new( + &[spi], + Limits { + bytes: 11, + entries: 1 + } + ), + Err(Error::Limit) + ); + assert!(ChildDelete::new( + &[spi], + Limits { + bytes: 12, + entries: 1 + } + ) + .is_ok()); + for body in [ + &[3, 0, 255, 255][..], + &[3, 4, 0, 1, 0, 0, 0, 0], + &[3, 4, 0, 0], + &[1, 0, 0, 0], + ] { + let (first, bytes) = independent_chain(&[(42, body)]); + assert!(ChildDelete::decode( + &header(Peer::Network, false), + Peer::Network, + first, + &bytes, + Limits::default() + ) + .is_err()); + } + let (first, bytes) = independent_chain(&[(42, &[1, 0, 0, 0])]); + assert_eq!( + must(encode_payloads(&must(PendingIkeDelete::payloads()))) + .1 + .as_ref(), + bytes + ); + for (sender, receiver) in [(Peer::Network, Peer::Ue), (Peer::Ue, Peer::Network)] { + let request = header(sender, false); + let response = header(receiver, true); + assert!(PendingIkeDelete::decode_request( + &request, + sender, + first, + &bytes, + Limits::default() + ) + .is_ok()); + assert_eq!( + must(must(PendingIkeDelete::new(&request, sender)).response( + &response, + PayloadType::NoNext, + &[] + )), + DeleteOutcome::Acknowledged + ); + assert_eq!( + must(PendingIkeDelete::new(&request, sender)).timeout(), + DeleteOutcome::DiscardIkeAndAllChildren + ); + assert!(must(PendingIkeDelete::new(&request, sender)) + .response(&response, first, &bytes) + .is_err()); + } +} + +use opc_proto_ikev2::{ + build_create_child_sa_rekey_response_payloads, + nwu::{all_packet_selectors, AeadPolicy, AeadSuite, CreateRequest, CreateRequestBuild}, + Ikev2CreateChildSaRekeyResponseBuild, Ikev2EncryptionAlgorithm, Ikev2NoncePayloadBuild, + Ikev2SaPayloadBuild, Ikev2SaProposalBuild, Ikev2SaTransformBuild, Ikev2TransformAttributeBuild, + Ikev2TransformAttributeBuildValue, EXCHANGE_TYPE_CREATE_CHILD_SA, +}; +fn encr(algorithm: Ikev2EncryptionAlgorithm) -> Ikev2SaTransformBuild { + Ikev2SaTransformBuild { + transform_type: 1, + transform_id: algorithm.transform_id(), + attributes: vec![Ikev2TransformAttributeBuild { + attribute_type: 14, + value: Ikev2TransformAttributeBuildValue::Tv(algorithm.key_bits()), + }], + } +} +fn create_input() -> CreateRequestBuild<'static> { + CreateRequestBuild { + security_association: Ikev2SaPayloadBuild { + proposals: vec![Ikev2SaProposalBuild { + proposal_number: 1, + protocol_id: 3, + spi: vec![10, 11, 12, 13], + transforms: vec![ + encr(Ikev2EncryptionAlgorithm::AesGcm16_128), + Ikev2SaTransformBuild { + transform_type: 5, + transform_id: 0, + attributes: vec![], + }, + ], + }], + }, + nonce: Ikev2NoncePayloadBuild { + nonce: vec![0x55; 16], + }, + key_exchange: None, + inner_families: AddressFamilies::Ipv4, + up_address: v4(11), + qos: must(QosInfo::new(5, &[9], true, None, None)), + } +} +fn create_header(sender: Peer, response: bool) -> Header { + let mut h = header(sender, response); + h.exchange_type = EXCHANGE_TYPE_CREATE_CHILD_SA; + h +} +#[test] +fn independent_full_create_vector_uses_network_initiator_and_all_packet_selectors() { + // Full synthetic SA/Nonce/TS bodies authored independently of SDK builders. + let sa = [ + 0, 0, 0, 32, 1, 3, 4, 2, 10, 11, 12, 13, 3, 0, 0, 12, 1, 0, 0, 20, 0x80, 14, 0, 128, 0, 0, + 0, 8, 5, 0, 0, 0, + ]; + let nonce = [0x55; 16]; + let ts = [ + 1, 0, 0, 0, 7, 0, 0, 16, 0, 0, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, + ]; + let (first, mut wire) = independent_chain(&[(33, &sa), (40, &nonce), (44, &ts), (45, &ts)]); + // Link to the independently published payload-only CREATE notification pair. + let last = 36 + 20 + 24; + wire[last] = 41; + wire.extend(fixture("create-child-sa")); + let input = create_input(); + assert_eq!( + must(encode_payloads(&must(input.payloads()))).1.as_ref(), + wire + ); + let request = must(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default(), + )); + assert_eq!(request.up_address(), v4(11)); + assert_eq!(request.qos().qfis().collect::>(), [9]); + assert!(request.qos().is_default()); + assert!(CreateRequest::decode( + &create_header(Peer::Ue, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + for len in 0..wire.len() { + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire[..len], + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + } + for families in [ + AddressFamilies::Ipv4, + AddressFamilies::Ipv6, + AddressFamilies::Dual, + ] { + let mut input = create_input(); + input.inner_families = families; + input.up_address = if families == AddressFamilies::Ipv6 { + v6(11) + } else { + v4(11) + }; + let (first, wire) = must(encode_payloads(&must(input.payloads()))); + assert_eq!( + must(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + families, + Limits::default() + )) + .families(), + families + ); + } +} +#[test] +fn create_rejects_multiple_up_addresses_duplicates_missing_and_narrowed_selectors() { + let input = create_input(); + let payloads = must(input.payloads()); + for i in 0..payloads.len() { + let mut altered = payloads.clone(); + altered.remove(i); + let (first, wire) = must(encode_payloads(&altered)); + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + altered = payloads.clone(); + altered.push(payloads[i].clone()); + let (first, wire) = must(encode_payloads(&altered)); + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + } + let mut altered = payloads.clone(); + altered.push(Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Notify, + body: must(Notify::UpAddress(v6(11)).encode_body()), + }); + let (first, wire) = must(encode_payloads(&altered)); + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + let mut altered = payloads.clone(); + altered[2].body[12] = 1; + let (first, wire) = must(encode_payloads(&altered)); + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); + let mut altered = payloads; + altered.push(Ikev2IkeAuthPayloadBuild { + payload_type: PayloadType::Notify, + body: vec![3, 4, 0x40, 9, 10, 11, 12, 13], + }); + let (first, wire) = must(encode_payloads(&altered)); + assert!(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default() + ) + .is_err()); +} +#[test] +fn aead_selection_follows_caller_order_without_integrity_or_fallback() { + let mut input = create_input(); + let mut second = input.security_association.proposals[0].clone(); + second.proposal_number = 2; + second.transforms[0] = encr(Ikev2EncryptionAlgorithm::AesGcm16_256); + input.security_association.proposals.push(second); + let (first, wire) = must(encode_payloads(&must(input.payloads()))); + let request = must(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default(), + )); + let s128 = must(AeadSuite::new( + Ikev2EncryptionAlgorithm::AesGcm16_128, + None, + false, + )); + let s256 = must(AeadSuite::new( + Ikev2EncryptionAlgorithm::AesGcm16_256, + None, + false, + )); + for suites in [vec![s256, s128], vec![s128, s256]] { + let selection = must(must(AeadPolicy::new(suites.clone())).select(&request)); + assert_eq!(selection.suite(), suites[0]); + let sa = selection.response_sa(must(EspSpi::new([1, 2, 3, 4]))); + assert!(!sa.proposals[0] + .transforms + .iter() + .any(|t| t.transform_type == 3)); + let response = must(build_create_child_sa_rekey_response_payloads( + &Ikev2CreateChildSaRekeyResponseBuild { + security_association: sa, + nonce: Ikev2NoncePayloadBuild { + nonce: vec![0x66; 16], + }, + key_exchange: None, + traffic_selectors_initiator: all_packet_selectors(AddressFamilies::Ipv4), + traffic_selectors_responder: all_packet_selectors(AddressFamilies::Ipv4), + }, + )); + let (first, wire) = must(encode_payloads(&response.into_payloads())); + assert!(request + .accepted_response( + &create_header(Peer::Ue, true), + first, + &wire, + Limits::default() + ) + .is_ok()); + let mut wrong = create_header(Peer::Ue, true); + wrong.message_id += 1; + assert!(request + .accepted_response(&wrong, first, &wire, Limits::default()) + .is_err()); + } + let absent = must(AeadSuite::new( + Ikev2EncryptionAlgorithm::AesGcm16_192, + None, + false, + )); + assert!(must(AeadPolicy::new(vec![absent])) + .select(&request) + .is_err()); + assert!(AeadSuite::new(Ikev2EncryptionAlgorithm::AesCbc128, None, false).is_err()); + assert!(AeadPolicy::new(vec![]).is_err()); + assert!(AeadPolicy::new(vec![s128, s128]).is_err()); + input.security_association.proposals.truncate(1); + input.security_association.proposals[0].transforms[0] = + encr(Ikev2EncryptionAlgorithm::AesCbc128); + input.security_association.proposals[0] + .transforms + .push(Ikev2SaTransformBuild { + transform_type: 3, + transform_id: 2, + attributes: vec![], + }); + let (first, wire) = must(encode_payloads(&must(input.payloads()))); + let request = must(CreateRequest::decode( + &create_header(Peer::Network, false), + first, + &wire, + AddressFamilies::Ipv4, + Limits::default(), + )); + assert!(must(AeadPolicy::new(vec![s128])).select(&request).is_err()); + // Separate integrity alongside AEAD is invalid even before policy selection. + input.security_association.proposals[0].transforms[0] = + encr(Ikev2EncryptionAlgorithm::AesGcm16_128); + assert!(input.payloads().is_err()); +}