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(¢iseconds),
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}