Skip to main content

netget/server/vrrp/
codec.rs

1//! Pure VRRP (v2/v3) and CARP packet codec. **No I/O, no async, no `AppState`.**
2//!
3//! This file is the part of the protocol that can actually be proved in this environment.
4//! Nothing here opens a socket, so every function is exercised by
5//! `tests/server/vrrp/codec_test.rs` against literal specification bytes in both directions.
6//! The transport in [`super`] is a thin layer over it.
7//!
8//! # Three packet formats, one IP protocol number
9//!
10//! IP protocol **112** carries three different things:
11//!
12//! * **VRRPv2** — RFC 3768 §5.1. Advertisement interval in **whole seconds**, one octet. An
13//!   authentication-type octet (kept, but RFC 3768 removed every method, so it is 0) and
14//!   eight octets of zeroed authentication data at the end.
15//! * **VRRPv3** — RFC 5798 §5.2. The authentication-type octet becomes four reserved bits,
16//!   and the interval becomes a **12-bit centisecond** field. The trailing authentication
17//!   data is gone. The checksum additionally covers an IP pseudo-header.
18//! * **CARP** — OpenBSD's replacement, which squats the same protocol number with a
19//!   completely different 36-octet layout. It is **not** VRRP.
20//!
21//! ## The first octet does not tell them apart
22//!
23//! VRRPv2's version/type octet is `0x21` (version 2, type 1 = advertisement). CARP's is
24//! *also* `0x21` (`CARP_VERSION` 2, `CARP_ADVERTISEMENT` 1). A dispatcher that switches on
25//! the first byte will decode a CARP advertisement as a VRRPv2 one carrying seven virtual
26//! addresses, because CARP's authentication-length octet sits exactly where VRRP's
27//! count-IP-addresses octet does. That is why the server takes a `variant` startup parameter
28//! rather than sniffing: only the operator knows which protocol the segment is running.
29//! [`Advertisement::decode`] takes the variant explicitly for the same reason.
30//!
31//! ## Two things implementations get wrong, both pinned to literal bytes
32//!
33//! 1. **Seconds versus centiseconds.** A one-second interval is `0x01` in VRRPv2 and
34//!    `0x064` (100) in VRRPv3. Writing seconds into the v3 field yields an advertisement
35//!    claiming a 10-millisecond interval, and a real peer's master-down timer acts on it.
36//! 2. **The v3 checksum is not the v2 checksum.** VRRPv2 (RFC 3768 §5.3.8) sums the VRRP
37//!    message alone. VRRPv3 (RFC 5798 §5.2.8) sums an IP pseudo-header *and* the message,
38//!    with next-header 112. The same bytes therefore carry different checksums under the two
39//!    versions, and [`VrrpAdvertisement::encode`] refuses to guess: a v3 encode without a
40//!    [`PseudoHeader`] is an error, never a silently-wrong packet.
41
42use anyhow::{bail, ensure, Context, Result};
43use sha1::{Digest, Sha1};
44use std::net::Ipv4Addr;
45
46// ---------------------------------------------------------------------------
47// Constants
48// ---------------------------------------------------------------------------
49
50/// IANA protocol number shared by VRRP and CARP.
51pub const IP_PROTOCOL_VRRP: u8 = 112;
52
53/// The IPv4 VRRP multicast group (RFC 5798 §5.1.1.2).
54pub const VRRP_MULTICAST_IPV4: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 18);
55
56/// The IPv6 VRRP multicast group, `FF02::12` (RFC 5798 §5.1.2.2).
57///
58/// Declared for completeness and to name the thing that is missing: **no IPv6 transport is
59/// implemented**, and [`VrrpAdvertisement`] carries IPv4 virtual addresses only.
60pub const VRRP_MULTICAST_IPV6: std::net::Ipv6Addr =
61    std::net::Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0x12);
62
63/// The only VRRP packet type there is (RFC 3768 §5.3.2, RFC 5798 §5.2.2).
64pub const VRRP_TYPE_ADVERTISEMENT: u8 = 1;
65
66pub const VRRP_VERSION_2: u8 = 2;
67pub const VRRP_VERSION_3: u8 = 3;
68
69/// Priority 255 is reserved for the router that **owns** the virtual addresses
70/// (RFC 3768 §5.3.4). Claiming it asserts the addresses are configured on this interface.
71pub const VRRP_PRIORITY_ADDRESS_OWNER: u8 = 255;
72
73/// Priority 0 means the current master is **resigning**, and it exists so a backup can take
74/// over immediately instead of waiting out its master-down interval (RFC 3768 §6.4.3).
75pub const VRRP_PRIORITY_RESIGN: u8 = 0;
76
77/// RFC 3768 §5.3.5: no authentication method is defined; the field is sent as zero.
78pub const VRRP_AUTH_TYPE_NONE: u8 = 0;
79
80/// VRRPv2 fixed header, before the virtual addresses.
81pub const VRRP_V2_HEADER_LEN: usize = 8;
82/// VRRPv2's two zeroed authentication-data words, after the virtual addresses.
83pub const VRRP_V2_AUTH_DATA_LEN: usize = 8;
84/// VRRPv3 fixed header, before the virtual addresses. No trailing authentication data.
85pub const VRRP_V3_HEADER_LEN: usize = 8;
86
87/// Largest value the VRRPv3 12-bit Max Advertisement Interval field can hold, in
88/// centiseconds — 40.95 seconds.
89pub const VRRP_V3_MAX_INTERVAL_CENTISECONDS: u16 = 0x0fff;
90
91/// `CARP_VERSION` in OpenBSD's `ip_carp.h`.
92pub const CARP_VERSION: u8 = 2;
93/// `CARP_ADVERTISEMENT` in OpenBSD's `ip_carp.h`.
94pub const CARP_TYPE_ADVERTISEMENT: u8 = 1;
95/// A CARP advertisement is always exactly this long: 8 octets of header, an 8-octet counter
96/// and a 20-octet SHA-1 HMAC.
97pub const CARP_HEADER_LEN: usize = 36;
98/// `carp_authlen` counts 32-bit words of counter + HMAC: (8 + 20) / 4 = 7.
99pub const CARP_AUTH_LEN_WORDS: u8 = 7;
100/// OpenBSD's `CARP_KEY_LEN` — the passphrase is zero-padded (or truncated) to this length.
101pub const CARP_KEY_LEN: usize = 20;
102
103// ---------------------------------------------------------------------------
104// Checksum
105// ---------------------------------------------------------------------------
106
107/// The standard 16-bit one's-complement checksum of RFC 1071.
108///
109/// Big-endian 16-bit words, an odd trailing octet padded with zero, carries folded in, result
110/// complemented. The defining property a receiver relies on is that running this over a buffer
111/// that already contains its own correct checksum yields 0 — which is exactly what
112/// [`checksum_is_valid`] tests.
113pub fn internet_checksum(data: &[u8]) -> u16 {
114    let mut sum: u32 = 0;
115    let mut chunks = data.chunks_exact(2);
116    for chunk in &mut chunks {
117        sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
118    }
119    if let Some(&last) = chunks.remainder().first() {
120        sum += u16::from_be_bytes([last, 0]) as u32;
121    }
122    while (sum >> 16) != 0 {
123        sum = (sum & 0xffff) + (sum >> 16);
124    }
125    !(sum as u16)
126}
127
128/// The IPv4 pseudo-header VRRPv3 folds into its checksum (RFC 5798 §5.2.8).
129///
130/// Twelve octets: source address, destination address, a zero octet, the protocol number
131/// (112) and the VRRP message length. This is the same shape TCP and UDP use, and it is the
132/// reason a VRRPv3 advertisement's checksum depends on *where it is going* — the identical
133/// message body sent to `224.0.0.18` and to a unicast peer carries different checksums.
134///
135/// VRRPv2 has no pseudo-header at all (RFC 3768 §5.3.8 sums the message alone).
136///
137/// IPv6 is not implemented: RFC 5798 points at the 40-octet RFC 2460 §8.1 pseudo-header for
138/// that case, and this server has no IPv6 transport to exercise it.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct PseudoHeader {
141    pub source: Ipv4Addr,
142    pub destination: Ipv4Addr,
143}
144
145impl PseudoHeader {
146    pub fn new(source: Ipv4Addr, destination: Ipv4Addr) -> Self {
147        Self {
148            source,
149            destination,
150        }
151    }
152
153    /// The twelve octets, for a VRRP message of `message_len` octets.
154    pub fn bytes(&self, message_len: usize) -> Result<[u8; 12]> {
155        let length = u16::try_from(message_len)
156            .with_context(|| format!("VRRP message of {message_len} octets exceeds 65535"))?;
157        let mut out = [0u8; 12];
158        out[0..4].copy_from_slice(&self.source.octets());
159        out[4..8].copy_from_slice(&self.destination.octets());
160        out[8] = 0;
161        out[9] = IP_PROTOCOL_VRRP;
162        out[10..12].copy_from_slice(&length.to_be_bytes());
163        Ok(out)
164    }
165}
166
167/// Which bytes a checksum covers. Reported on every event so an operator can see what was
168/// actually verified rather than assuming.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum ChecksumScope {
171    /// VRRPv2 and CARP: the message alone.
172    Message,
173    /// VRRPv3: an IPv4 pseudo-header followed by the message.
174    PseudoHeaderAndMessage,
175}
176
177impl ChecksumScope {
178    pub fn as_str(self) -> &'static str {
179        match self {
180            ChecksumScope::Message => "message",
181            ChecksumScope::PseudoHeaderAndMessage => "pseudo_header_and_message",
182        }
183    }
184}
185
186/// True when `message` carries a correct checksum in place.
187///
188/// `pseudo` must be `Some` for VRRPv3 and `None` for VRRPv2/CARP; supplying the wrong one
189/// produces a false negative, which is why the caller derives it from the decoded version
190/// rather than guessing.
191pub fn checksum_is_valid(message: &[u8], pseudo: Option<&PseudoHeader>) -> bool {
192    match pseudo {
193        None => internet_checksum(message) == 0,
194        Some(pseudo) => {
195            let Ok(prefix) = pseudo.bytes(message.len()) else {
196                return false;
197            };
198            let mut buffer = Vec::with_capacity(prefix.len() + message.len());
199            buffer.extend_from_slice(&prefix);
200            buffer.extend_from_slice(message);
201            internet_checksum(&buffer) == 0
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// VRRP
208// ---------------------------------------------------------------------------
209
210/// One VRRP advertisement, version 2 or version 3.
211///
212/// The interval is held in **seconds** because that is what a person and a model reason in;
213/// [`Self::interval_field`] does the version-dependent scaling in exactly one place, and the
214/// codec test pins its output at the wire offsets for both versions.
215#[derive(Debug, Clone, PartialEq)]
216pub struct VrrpAdvertisement {
217    /// 2 or 3.
218    pub version: u8,
219    /// Virtual Router Identifier, 1..=255. 0 is not a valid VRID.
220    pub vrid: u8,
221    /// 0 resigns, 255 claims address ownership, higher wins the election.
222    pub priority: u8,
223    /// Advertisement interval in seconds. VRRPv2 carries whole seconds in one octet;
224    /// VRRPv3 carries centiseconds in a 12-bit field.
225    pub advert_interval_seconds: f64,
226    /// VRRPv2 only. RFC 3768 defines no method, so this is 0 in anything conformant; it is
227    /// surfaced rather than discarded because a non-zero value on the wire says the sender is
228    /// running RFC 2338-era authentication.
229    pub auth_type: u8,
230    /// The virtual IPv4 addresses this router is advertising. May be empty on the wire, but a
231    /// real advertisement always carries at least one.
232    pub addresses: Vec<Ipv4Addr>,
233    /// The checksum as it appeared on the wire. Ignored by [`Self::encode`], which always
234    /// recomputes.
235    pub checksum: u16,
236}
237
238impl VrrpAdvertisement {
239    /// True when this advertisement is the master resigning (RFC 3768 §6.4.3).
240    pub fn is_resignation(&self) -> bool {
241        self.priority == VRRP_PRIORITY_RESIGN
242    }
243
244    /// True when the sender claims to own the virtual addresses (RFC 3768 §5.3.4).
245    pub fn is_address_owner(&self) -> bool {
246        self.priority == VRRP_PRIORITY_ADDRESS_OWNER
247    }
248
249    /// Which bytes this version's checksum covers.
250    pub fn checksum_scope(&self) -> ChecksumScope {
251        if self.version >= VRRP_VERSION_3 {
252            ChecksumScope::PseudoHeaderAndMessage
253        } else {
254            ChecksumScope::Message
255        }
256    }
257
258    /// The raw interval field for this version.
259    ///
260    /// **This is the seconds-versus-centiseconds trap.** VRRPv2 puts whole seconds in one
261    /// octet (RFC 3768 §5.3.7); VRRPv3 puts centiseconds in twelve bits (RFC 5798 §5.2.7).
262    /// One second is `1` under v2 and `100` under v3.
263    pub fn interval_field(&self) -> Result<u16> {
264        ensure!(
265            self.advert_interval_seconds.is_finite() && self.advert_interval_seconds > 0.0,
266            "advert_interval must be a positive number of seconds, got {}",
267            self.advert_interval_seconds
268        );
269        match self.version {
270            VRRP_VERSION_2 => {
271                let seconds = self.advert_interval_seconds;
272                ensure!(
273                    (seconds - seconds.round()).abs() < 1e-9,
274                    "VRRPv2 carries the advertisement interval in WHOLE SECONDS in a single \
275                     octet, so {seconds}s cannot be expressed. Use version 3, whose field is \
276                     in centiseconds, for sub-second intervals."
277                );
278                let seconds = seconds.round();
279                ensure!(
280                    (1.0..=255.0).contains(&seconds),
281                    "VRRPv2 advertisement interval must be 1..=255 seconds, got {seconds}"
282                );
283                Ok(seconds as u16)
284            }
285            VRRP_VERSION_3 => {
286                let centiseconds = (self.advert_interval_seconds * 100.0).round();
287                ensure!(
288                    (1.0..=(VRRP_V3_MAX_INTERVAL_CENTISECONDS as f64)).contains(&centiseconds),
289                    "VRRPv3 carries the advertisement interval in CENTISECONDS in a 12-bit \
290                     field, so it must be 0.01..=40.95 seconds; {}s is {centiseconds} \
291                     centiseconds",
292                    self.advert_interval_seconds
293                );
294                Ok(centiseconds as u16)
295            }
296            other => bail!("unsupported VRRP version {other} (expected 2 or 3)"),
297        }
298    }
299
300    /// Validate every field without needing a pseudo-header.
301    ///
302    /// Separate from [`Self::encode`] so `execute_action` can reject a bad action where the
303    /// error reaches the model, rather than in the transport where it reaches only a log.
304    pub fn validate(&self) -> Result<()> {
305        ensure!(
306            self.version == VRRP_VERSION_2 || self.version == VRRP_VERSION_3,
307            "unsupported VRRP version {} (expected 2 or 3)",
308            self.version
309        );
310        ensure!(self.vrid != 0, "VRID 0 is not a valid virtual router id");
311        ensure!(
312            self.addresses.len() <= 255,
313            "a VRRP advertisement can carry at most 255 addresses, got {}",
314            self.addresses.len()
315        );
316        if self.version == VRRP_VERSION_3 {
317            ensure!(
318                self.auth_type == VRRP_AUTH_TYPE_NONE,
319                "VRRPv3 has no authentication-type field — those four bits are reserved and \
320                 must be zero (RFC 5798 §5.2.5); got auth_type {}",
321                self.auth_type
322            );
323        }
324        self.interval_field()?;
325        Ok(())
326    }
327
328    /// Serialise, computing the checksum.
329    ///
330    /// `pseudo` **must** be `Some` for version 3 and is ignored for version 2. A v3 encode
331    /// with `None` is an error rather than a message-only checksum, because a silently-wrong
332    /// checksum is discarded by every real peer and looks exactly like the server being down.
333    pub fn encode(&self, pseudo: Option<&PseudoHeader>) -> Result<Vec<u8>> {
334        self.validate()?;
335        let interval = self.interval_field()?;
336
337        let mut message = Vec::new();
338        message.push((self.version << 4) | VRRP_TYPE_ADVERTISEMENT);
339        message.push(self.vrid);
340        message.push(self.priority);
341        message.push(self.addresses.len() as u8);
342
343        match self.version {
344            VRRP_VERSION_2 => {
345                message.push(self.auth_type);
346                message.push(interval as u8);
347            }
348            _ => {
349                // Four reserved bits, then the top four bits of the 12-bit interval.
350                message.push(((interval >> 8) & 0x0f) as u8);
351                message.push((interval & 0xff) as u8);
352            }
353        }
354
355        message.extend_from_slice(&[0, 0]); // checksum placeholder
356        for address in &self.addresses {
357            message.extend_from_slice(&address.octets());
358        }
359        if self.version == VRRP_VERSION_2 {
360            // RFC 3768 §5.3.10 keeps the two authentication-data words and requires zeros.
361            message.extend_from_slice(&[0u8; VRRP_V2_AUTH_DATA_LEN]);
362        }
363
364        let checksum = match self.version {
365            VRRP_VERSION_2 => internet_checksum(&message),
366            _ => {
367                let pseudo = pseudo.context(
368                    "VRRPv3 checksums cover an IP pseudo-header (RFC 5798 §5.2.8), so the \
369                     source and destination addresses are required to encode one. Encoding \
370                     without them would produce a packet every conformant peer discards.",
371                )?;
372                let prefix = pseudo.bytes(message.len())?;
373                let mut buffer = Vec::with_capacity(prefix.len() + message.len());
374                buffer.extend_from_slice(&prefix);
375                buffer.extend_from_slice(&message);
376                internet_checksum(&buffer)
377            }
378        };
379        message[6..8].copy_from_slice(&checksum.to_be_bytes());
380        Ok(message)
381    }
382
383    /// Parse an advertisement. The caller has already decided this is VRRP and not CARP.
384    pub fn decode(data: &[u8]) -> Result<Self> {
385        ensure!(
386            data.len() >= VRRP_V2_HEADER_LEN,
387            "VRRP message truncated: {} octets, need at least {}",
388            data.len(),
389            VRRP_V2_HEADER_LEN
390        );
391
392        let version = data[0] >> 4;
393        let packet_type = data[0] & 0x0f;
394        ensure!(
395            packet_type == VRRP_TYPE_ADVERTISEMENT,
396            "VRRP packet type {packet_type} is not an advertisement (the only type defined)"
397        );
398        ensure!(
399            version == VRRP_VERSION_2 || version == VRRP_VERSION_3,
400            "unsupported VRRP version {version} (expected 2 or 3). Note that a CARP \
401             advertisement also starts 0x21 and must be decoded as CARP, not as VRRPv2."
402        );
403
404        let vrid = data[1];
405        let priority = data[2];
406        let count = data[3] as usize;
407        let checksum = u16::from_be_bytes([data[6], data[7]]);
408
409        let (auth_type, advert_interval_seconds) = if version == VRRP_VERSION_2 {
410            (data[4], data[5] as f64)
411        } else {
412            let centiseconds = (((data[4] & 0x0f) as u16) << 8) | data[5] as u16;
413            (VRRP_AUTH_TYPE_NONE, centiseconds as f64 / 100.0)
414        };
415
416        let needed = VRRP_V2_HEADER_LEN + count * 4;
417        ensure!(
418            data.len() >= needed,
419            "VRRP message declares {count} address(es) but is only {} octets; {needed} needed",
420            data.len()
421        );
422
423        let mut addresses = Vec::with_capacity(count);
424        for i in 0..count {
425            let offset = VRRP_V2_HEADER_LEN + i * 4;
426            addresses.push(Ipv4Addr::new(
427                data[offset],
428                data[offset + 1],
429                data[offset + 2],
430                data[offset + 3],
431            ));
432        }
433
434        Ok(Self {
435            version,
436            vrid,
437            priority,
438            advert_interval_seconds,
439            auth_type,
440            addresses,
441            checksum,
442        })
443    }
444}
445
446// ---------------------------------------------------------------------------
447// CARP
448// ---------------------------------------------------------------------------
449
450/// One CARP advertisement (OpenBSD `struct carp_header`).
451///
452/// **CARP is not VRRP.** It shares only the IP protocol number. There is no CARP RFC; the
453/// layout below is taken from OpenBSD's `sys/netinet/ip_carp.h`:
454///
455/// ```text
456///  0      version(4) | type(4)   = 0x21 — the same first octet as a VRRPv2 advertisement
457///  1      carp_vhid                virtual host id
458///  2      carp_advskew             advertisement skew; LOWER WINS, the inverse of VRRP
459///  3      carp_authlen             32-bit words of counter + HMAC, always 7
460///  4      carp_demote              demotion counter
461///  5      carp_advbase             advertisement base interval, whole seconds
462///  6.. 8  carp_cksum               one's-complement checksum over these 36 octets
463///  8..16  carp_counter             64-bit replay counter, big-endian on the wire
464/// 16..36  carp_md                  HMAC-SHA1
465/// ```
466///
467/// The election runs on `advbase` + `advskew`/256 seconds: the host that advertises soonest
468/// wins, so a **lower** skew is stronger. That is the opposite direction from VRRP's
469/// priority, and it is the single easiest thing to get backwards when treating the two as one
470/// protocol.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct CarpAdvertisement {
473    pub version: u8,
474    pub vhid: u8,
475    pub advskew: u8,
476    pub auth_length_words: u8,
477    pub demote: u8,
478    pub advbase: u8,
479    pub counter: u64,
480    pub hmac: [u8; 20],
481    /// The checksum as it appeared on the wire; ignored by [`Self::encode`].
482    pub checksum: u16,
483}
484
485impl CarpAdvertisement {
486    /// The advertisement interval this host is claiming, in seconds.
487    pub fn interval_seconds(&self) -> f64 {
488        self.advbase as f64 + (self.advskew as f64 / 256.0)
489    }
490
491    pub fn validate(&self) -> Result<()> {
492        ensure!(
493            self.version == CARP_VERSION,
494            "unsupported CARP version {} (OpenBSD's CARP_VERSION is {CARP_VERSION})",
495            self.version
496        );
497        ensure!(self.vhid != 0, "CARP vhid 0 is not a valid virtual host id");
498        ensure!(
499            self.advbase >= 1,
500            "CARP advbase must be at least 1 second; 0 would advertise continuously"
501        );
502        Ok(())
503    }
504
505    pub fn encode(&self) -> Result<Vec<u8>> {
506        self.validate()?;
507        let mut message = Vec::with_capacity(CARP_HEADER_LEN);
508        message.push((self.version << 4) | CARP_TYPE_ADVERTISEMENT);
509        message.push(self.vhid);
510        message.push(self.advskew);
511        message.push(self.auth_length_words);
512        message.push(self.demote);
513        message.push(self.advbase);
514        message.extend_from_slice(&[0, 0]); // checksum placeholder
515        message.extend_from_slice(&self.counter.to_be_bytes());
516        message.extend_from_slice(&self.hmac);
517        debug_assert_eq!(message.len(), CARP_HEADER_LEN);
518
519        let checksum = internet_checksum(&message);
520        message[6..8].copy_from_slice(&checksum.to_be_bytes());
521        Ok(message)
522    }
523
524    pub fn decode(data: &[u8]) -> Result<Self> {
525        ensure!(
526            data.len() >= CARP_HEADER_LEN,
527            "CARP advertisement is {} octets, must be {CARP_HEADER_LEN}",
528            data.len()
529        );
530        let version = data[0] >> 4;
531        let packet_type = data[0] & 0x0f;
532        ensure!(
533            packet_type == CARP_TYPE_ADVERTISEMENT,
534            "CARP packet type {packet_type} is not an advertisement"
535        );
536        let mut hmac = [0u8; 20];
537        hmac.copy_from_slice(&data[16..36]);
538        Ok(Self {
539            version,
540            vhid: data[1],
541            advskew: data[2],
542            auth_length_words: data[3],
543            demote: data[4],
544            advbase: data[5],
545            checksum: u16::from_be_bytes([data[6], data[7]]),
546            counter: u64::from_be_bytes([
547                data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
548            ]),
549            hmac,
550        })
551    }
552}
553
554/// Build the CARP HMAC exactly as OpenBSD's `carp_hmac_prepare` / `carp_hmac_generate` do.
555///
556/// The key is the passphrase, zero-padded or truncated to [`CARP_KEY_LEN`] — OpenBSD's
557/// `ifconfig carp pass` copies the raw bytes into a 20-octet field without hashing them
558/// first. The message is `version || type || vhid || each virtual address || counter`, with
559/// the counter in the same big-endian form it takes on the wire, and **version and type as
560/// two separate octets** (`0x02`, `0x01`) rather than the packed `0x21` of the header.
561///
562/// **Unverified against a live peer.** This is derived from reading `sys/netinet/ip_carp.c`;
563/// no OpenBSD `carp` interface has ever accepted a packet from this code. The HMAC-SHA1
564/// underneath is a different matter: the hash is the `sha1` crate's and the RFC 2104
565/// construction is pinned to RFC 2202's vectors. What remains unproven is the CARP-specific
566/// *input ordering* assembled here — which fields, in which order, with which key derivation
567/// — not the primitive computing over it. See `src/server/vrrp/CLAUDE.md`.
568pub fn carp_hmac(passphrase: &[u8], vhid: u8, addresses: &[Ipv4Addr], counter: u64) -> [u8; 20] {
569    let mut key = [0u8; CARP_KEY_LEN];
570    let take = passphrase.len().min(CARP_KEY_LEN);
571    key[..take].copy_from_slice(&passphrase[..take]);
572
573    let mut message = vec![CARP_VERSION, CARP_TYPE_ADVERTISEMENT, vhid];
574    for address in addresses {
575        message.extend_from_slice(&address.octets());
576    }
577    message.extend_from_slice(&counter.to_be_bytes());
578
579    hmac_sha1(&key, &message)
580}
581
582// ---------------------------------------------------------------------------
583// SHA-1 / HMAC-SHA1
584// ---------------------------------------------------------------------------
585
586/// SHA-1 (RFC 3174 / FIPS 180-4) as a fixed-size array, over the `sha1` crate.
587///
588/// This is a thin adapter, not an implementation: the `vrrp` feature declares `dep:sha1` and
589/// the hashing is entirely the crate's. It exists only so the rest of this file can take a
590/// `[u8; 20]` without threading `GenericArray` and `Digest` through every caller.
591///
592/// `tests/server/vrrp/codec_test.rs` runs the FIPS 180 / RFC 3174 vectors and a 0..=130 length
593/// sweep through it anyway. Those do not assert that SHA-1 is correct — that is the crate's
594/// problem — they assert that **this adapter drives it correctly**: hashing the wrong buffer,
595/// dropping an `update` or truncating the digest would pass every other test in that file and
596/// fail these.
597pub fn sha1(data: &[u8]) -> [u8; 20] {
598    let mut out = [0u8; 20];
599    out.copy_from_slice(&Sha1::digest(data));
600    out
601}
602
603/// HMAC-SHA1 (RFC 2104), block size 64.
604///
605/// The construction is ours because no HMAC crate is reachable from this feature — `hmac` is
606/// optional and gated behind `tor` — but the hashing underneath is the `sha1` crate's. That
607/// makes the key padding and the inner/outer ordering the only part written here, and it is
608/// exactly the part RFC 2202's vectors check: an implementation that swapped `0x36` and
609/// `0x5c`, forgot to hash an over-long key, or concatenated the wrong way round fails them
610/// immediately.
611pub fn hmac_sha1(key: &[u8], data: &[u8]) -> [u8; 20] {
612    const BLOCK: usize = 64;
613
614    // RFC 2104 §2: a key longer than the block size is replaced by its own hash, and any key
615    // is then zero-padded to the block size.
616    let mut padded = [0u8; BLOCK];
617    if key.len() > BLOCK {
618        padded[..20].copy_from_slice(&sha1(key));
619    } else {
620        padded[..key.len()].copy_from_slice(key);
621    }
622
623    let ipad: Vec<u8> = padded.iter().map(|byte| byte ^ 0x36).collect();
624    let opad: Vec<u8> = padded.iter().map(|byte| byte ^ 0x5c).collect();
625
626    let mut inner = Sha1::new();
627    inner.update(&ipad);
628    inner.update(data);
629    let inner = inner.finalize();
630
631    let mut outer = Sha1::new();
632    outer.update(&opad);
633    outer.update(inner);
634
635    let mut out = [0u8; 20];
636    out.copy_from_slice(&outer.finalize());
637    out
638}
639
640// ---------------------------------------------------------------------------
641// Variant dispatch
642// ---------------------------------------------------------------------------
643
644/// Which of the two protocols sharing IP protocol 112 a server speaks.
645///
646/// This is a configuration choice, not something inferred from the wire: a CARP
647/// advertisement and a VRRPv2 advertisement have the same first octet (`0x21`), and CARP's
648/// authentication-length field sits where VRRP's address count does, so a sniffing decoder
649/// reads a CARP packet as a VRRPv2 packet claiming seven virtual addresses.
650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
651pub enum Variant {
652    Vrrp,
653    Carp,
654}
655
656impl Variant {
657    pub fn from_name(name: &str) -> Result<Self> {
658        match name.trim().to_ascii_lowercase().as_str() {
659            "vrrp" | "vrrpv2" | "vrrpv3" | "rfc3768" | "rfc5798" => Ok(Variant::Vrrp),
660            "carp" | "openbsd" => Ok(Variant::Carp),
661            other => bail!("unknown variant '{other}' (expected 'vrrp' or 'carp')"),
662        }
663    }
664
665    pub fn as_str(self) -> &'static str {
666        match self {
667            Variant::Vrrp => "vrrp",
668            Variant::Carp => "carp",
669        }
670    }
671}
672
673/// A decoded packet of either variant.
674#[derive(Debug, Clone, PartialEq)]
675pub enum Advertisement {
676    Vrrp(VrrpAdvertisement),
677    Carp(CarpAdvertisement),
678}
679
680impl Advertisement {
681    /// Decode according to the variant the operator configured.
682    pub fn decode(variant: Variant, data: &[u8]) -> Result<Self> {
683        match variant {
684            Variant::Vrrp => VrrpAdvertisement::decode(data).map(Advertisement::Vrrp),
685            Variant::Carp => CarpAdvertisement::decode(data).map(Advertisement::Carp),
686        }
687    }
688
689    pub fn variant(&self) -> Variant {
690        match self {
691            Advertisement::Vrrp(_) => Variant::Vrrp,
692            Advertisement::Carp(_) => Variant::Carp,
693        }
694    }
695}