From 8962406c579578b969a54f919d27625536fe8eba Mon Sep 17 00:00:00 2001 From: Keenan Tims Date: Fri, 23 Oct 2026 14:11:16 -0700 Subject: [PATCH] implement auth, improve session stats --- Cargo.lock | 213 ++++++++++++- Cargo.toml | 6 + config.yaml.dist | 31 ++ src/bfd.rs | 697 +++++++++++++++++++++++++++++++++++++++---- src/bin/rust-bfdd.rs | 203 +++++++++++-- src/config.rs | 90 +++++- src/lib.rs | 66 +++- 7 files changed, 1215 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b53b733..7048e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bytemuck" version = "1.25.2" @@ -184,6 +193,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.7.2" @@ -195,6 +219,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "defmt" version = "1.2.0" @@ -223,7 +256,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", ] [[package]] @@ -404,6 +448,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -412,6 +465,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -429,6 +484,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -499,6 +563,16 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if 1.0.5", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -576,6 +650,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -750,19 +833,25 @@ dependencies = [ "bytemuck", "byteorder", "clap", + "digest", "env_logger", "futures 0.3.34", "humantime", "humantime-serde", - "itertools", + "itertools 0.12.1", "log", + "md-5", "nix", "nom", "nom-derive", "proc-bitfield", "rand", "serde", + "serde_bytes", + "serde_valid", "serde_yaml_ng", + "sha1", + "subtle", "tokio", "tokio-timer", ] @@ -795,6 +884,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -815,6 +914,59 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_valid" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a812672d5d6c94c0fa540e62dc318728410c78f40d60ae6c07afc845616da5f6" +dependencies = [ + "indexmap", + "itertools 0.14.0", + "num-traits", + "regex", + "serde", + "serde_json", + "serde_valid_derive", + "serde_valid_literal", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "serde_valid_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539cd22aaf48cb9a8932c8b7f5d54f51719b2f826564f9da1a9902ee411d753c" +dependencies = [ + "itertools 0.14.0", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "serde_valid_literal" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e602b939d2e8e8b47f30f919b27a984183974079de2262ca876c2162657c8" +dependencies = [ + "regex", +] + [[package]] name = "serde_yaml_ng" version = "0.10.0" @@ -828,6 +980,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if 1.0.5", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -872,6 +1035,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -905,13 +1074,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -975,12 +1164,24 @@ dependencies = [ "tokio-executor", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1033,3 +1234,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 23ba023..2c9dd53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,18 +8,24 @@ atomic = "0.6.0" bytemuck = { version = "1.14.1", features = ["derive"] } byteorder = "1.5.0" clap = { version = "4", features = ["derive", "env"] } +digest = "0.11.3" env_logger = "0.11.1" futures = "0.3.30" humantime = "2.4.0" humantime-serde = "1.1.1" itertools = "0.12.1" log = "0.4.20" +md-5 = "0.11.0" nix = { version = "0.31.3", features = ["fs", "socket"] } nom = "7.1.3" nom-derive = "0.10.1" proc-bitfield = "0.3.1" rand = "0.8.5" serde = { version = "1.0.229", features = ["derive"] } +serde_bytes = "0.11.19" +serde_valid = "3.2.0" serde_yaml_ng = "0.10.0" +sha1 = "0.11.0" +subtle = "2.6" tokio = { version = "1.35.1", features = ["net", "full"] } tokio-timer = "0.2.13" diff --git a/config.yaml.dist b/config.yaml.dist index 0654b0a..e7d5ffe 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -39,3 +39,34 @@ peers: # desired_min_tx: 1s # required_min_rx: 1s # detect_mult: 5 + # + # Authentication (RFC 5880 section 6.7), per peer and off by default. Both ends must be configured identically; a + # peer that does not authenticate as configured is discarded. `key_id` is a number (0-255) that must match the remote's. + # Keys are used verbatim (not hashed or derived), so they must be exactly the length shown. Give them as a string; + # use YAML double-quoted escapes (\x00, \x1b, ...) for bytes that are not printable. Strings are UTF-8, so only bytes + # 0x00-0x7f can be written this way. To use bytes 0x80-0xff, give the key as a list of integers instead, one per + # byte (hex is fine), with the same length requirement: + # key: [0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x61, 0x62, 0x63, 0x64, 0x80, 0xff] + # + # Keyed MD5: `key` is exactly 16 bytes. `meticulous` (default true) selects Meticulous Keyed MD5, in which every + # packet must carry a newer sequence number; the remote must be set to the same mode. + # - address: 192.0.2.4 + # auth: + # type: md5 + # key_id: 1 + # key: "0123456789abcdef" + # meticulous: true + # + # Keyed SHA1: `key` is exactly 20 bytes. `meticulous` as above. + # - address: 192.0.2.5 + # auth: + # type: sha1 + # key_id: 1 + # key: "0123456789abcdef\x00\x01\x7f\x1b" + # + # Simple password: 1 to 16 bytes, sent in the clear. Provides no protection against an attacker on the path. + # - address: 192.0.2.6 + # auth: + # type: password + # key_id: 1 + # password: "secret" diff --git a/src/bfd.rs b/src/bfd.rs index 6cb6b36..1b635c8 100644 --- a/src/bfd.rs +++ b/src/bfd.rs @@ -1,16 +1,24 @@ +use digest::Digest; +use log::warn; use std::{ ffi::OsStr, fmt::{self, Display}, - io::Cursor, + io::{Cursor, Write}, ops::{Div, Mul}, time, }; use bytemuck::NoUninit; use byteorder::{BigEndian, WriteBytesExt}; -use nom::{bytes::complete::take, multi::many_m_n, number::complete::be_u8, IResult}; +use nom::{ + bytes::complete::take, + multi::many_m_n, + number::complete::{be_u32, be_u8}, + IResult, +}; use nom_derive::{NomBE, Parse}; use proc_bitfield::*; +use subtle::ConstantTimeEq; #[repr(u8)] #[derive(ConvRaw, Debug, NomBE, PartialEq, Eq, Clone, Copy, NoUninit)] @@ -84,6 +92,21 @@ pub enum BfdAuthType { MetKeyedSHA1 = 5, Reserved, } + +impl Display for BfdAuthType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::None => "none", + Self::SimplePassword => "password", + Self::KeyedMD5 => "md5", + Self::MetKeyedMD5 => "meticulous-md5", + Self::KeyedSHA1 => "sha1", + Self::MetKeyedSHA1 => "meticulous-sha1", + Self::Reserved => "reserved", + }) + } +} + #[repr(transparent)] #[derive(Debug, NomBE, PartialEq, Eq, Clone, Copy, NoUninit, Hash)] pub struct BfdDiscriminator(pub u32); @@ -181,7 +204,7 @@ impl Div for u32 { } bitfield! { - #[derive(NomBE)] + #[derive(Clone, Copy, NomBE)] pub struct BfdFlags(pub u32): Debug { pub vers: u8 @ 29..=31, pub diag: u8 [try_get BfdDiagnostic] @ 24..=28, @@ -197,67 +220,211 @@ bitfield! { } } -#[derive(Debug)] -pub struct BfdAuthSimplePassword(Vec); +#[derive(Clone, Debug, PartialEq)] +pub struct BfdAuthSimplePassword { + pub key_id: u8, + pub password: Vec, +} impl<'a> Parse<&'a [u8]> for BfdAuthSimplePassword { fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self, nom::error::Error<&'a [u8]>> { - let (i, res) = many_m_n(1, 16, be_u8)(i)?; - Ok((i, Self(res))) + let (i, key_id) = be_u8(i)?; + let (i, password) = many_m_n(1, 16, be_u8)(i)?; + Ok((i, Self { key_id, password })) } } -#[derive(Debug, NomBE)] +impl BfdAuthSimplePassword { + fn serialize(&self, wtr: &mut impl Write) -> Result<(), std::io::Error> { + wtr.write_u8(self.key_id)?; + wtr.write_all(&self.password) + } +} + +/// Meticulous and non-meticulous keyed authentication share a wire format and differ only in the +/// Auth Type value and sequence number handling. +#[derive(Clone, Debug, PartialEq)] pub struct BfdAuthKeyedMD5 { - key_id: u8, - _reserved: u8, - seq: u32, - digest: [u8; 16], + pub meticulous: bool, + pub key_id: u8, + pub seq: u32, + pub digest: [u8; 16], } -#[derive(Debug, NomBE)] -pub struct BfdAuthKeyedSHA1 { - key_id: u8, - _reserved: u8, - seq: u32, - hash: [u8; 20], -} +impl BfdAuthKeyedMD5 { + fn parse_with(i: &[u8], meticulous: bool) -> IResult<&[u8], Self> { + let (i, key_id) = be_u8(i)?; + let (i, _reserved) = be_u8(i)?; + let (i, seq) = be_u32(i)?; + let (i, digest) = take(16usize)(i)?; + let digest = digest.try_into().expect("took 16 bytes"); + Ok(( + i, + Self { + meticulous, + key_id, + seq, + digest, + }, + )) + } -#[derive(Debug, NomBE)] -#[nom(Selector = "BfdAuthType", Complete)] -pub enum BfdAuthData { - #[nom(Selector = "BfdAuthType::SimplePassword")] - SimplePassword(BfdAuthSimplePassword), - #[nom(Selector = "BfdAuthType::KeyedMD5")] - KeyedMD5(BfdAuthKeyedMD5), - #[nom(Selector = "BfdAuthType::MetKeyedMD5")] - MetKeyedMD5(BfdAuthKeyedMD5), - #[nom(Selector = "BfdAuthType::KeyedSHA1")] - KeyedSHA1(BfdAuthKeyedSHA1), - #[nom(Selector = "BfdAuthType::MetKeyedSHA1")] - MetKeyedSHA1(BfdAuthKeyedSHA1), -} - -impl BfdAuthData { - fn parse_be_with_length( - i: &[u8], - auth_type: BfdAuthType, - auth_len: u8, - ) -> IResult<&[u8], Self> { - let (new_i, data) = take(auth_len.saturating_sub(2))(i)?; - let (_leftovers, retval) = BfdAuthData::parse_be(data, auth_type)?; - Ok((new_i, retval)) + fn serialize(&self, wtr: &mut impl Write) -> Result<(), std::io::Error> { + wtr.write_u8(self.key_id)?; + wtr.write_u8(0)?; + wtr.write_u32::(self.seq)?; + wtr.write_all(&self.digest) } } -#[derive(Debug, NomBE)] -pub struct BfdAuth { - auth_type: BfdAuthType, - auth_len: u8, - #[nom(Parse = "{ |i| BfdAuthData::parse_be_with_length(i, auth_type, auth_len) }")] - auth_data: BfdAuthData, +#[derive(Clone, Debug, PartialEq)] +pub struct BfdAuthKeyedSHA1 { + pub meticulous: bool, + pub key_id: u8, + pub seq: u32, + pub digest: [u8; 20], } -#[derive(Debug, NomBE)] +impl BfdAuthKeyedSHA1 { + fn parse_with(i: &[u8], meticulous: bool) -> IResult<&[u8], Self> { + let (i, key_id) = be_u8(i)?; + let (i, _reserved) = be_u8(i)?; + let (i, seq) = be_u32(i)?; + let (i, digest) = take(20usize)(i)?; + let digest = digest.try_into().expect("took 20 bytes"); + Ok(( + i, + Self { + meticulous, + key_id, + seq, + digest, + }, + )) + } + + fn serialize(&self, wtr: &mut impl Write) -> Result<(), std::io::Error> { + wtr.write_u8(self.key_id)?; + wtr.write_u8(0)?; + wtr.write_u32::(self.seq)?; + wtr.write_all(&self.digest) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum BfdAuth { + SimplePassword(BfdAuthSimplePassword), + KeyedMD5(BfdAuthKeyedMD5), + KeyedSHA1(BfdAuthKeyedSHA1), +} + +impl<'a> Parse<&'a [u8]> for BfdAuth { + fn parse(i: &'a [u8]) -> IResult<&'a [u8], Self, nom::error::Error<&'a [u8]>> { + let (i, auth_type) = BfdAuthType::parse_be(i)?; + let (i, auth_len) = be_u8(i)?; + let (rest, data) = take(auth_len.saturating_sub(2))(i)?; + let auth = match auth_type { + BfdAuthType::SimplePassword => { + Self::SimplePassword(BfdAuthSimplePassword::parse(data)?.1) + } + BfdAuthType::KeyedMD5 => Self::KeyedMD5(BfdAuthKeyedMD5::parse_with(data, false)?.1), + BfdAuthType::MetKeyedMD5 => Self::KeyedMD5(BfdAuthKeyedMD5::parse_with(data, true)?.1), + BfdAuthType::KeyedSHA1 => Self::KeyedSHA1(BfdAuthKeyedSHA1::parse_with(data, false)?.1), + BfdAuthType::MetKeyedSHA1 => { + Self::KeyedSHA1(BfdAuthKeyedSHA1::parse_with(data, true)?.1) + } + BfdAuthType::None | BfdAuthType::Reserved => { + return Err(nom::Err::Error(nom::error::Error::new( + i, + nom::error::ErrorKind::Verify, + ))) + } + }; + Ok((rest, auth)) + } +} + +impl BfdAuth { + pub fn auth_type(&self) -> BfdAuthType { + match self { + Self::SimplePassword(_) => BfdAuthType::SimplePassword, + Self::KeyedMD5(d) if d.meticulous => BfdAuthType::MetKeyedMD5, + Self::KeyedMD5(_) => BfdAuthType::KeyedMD5, + Self::KeyedSHA1(d) if d.meticulous => BfdAuthType::MetKeyedSHA1, + Self::KeyedSHA1(_) => BfdAuthType::KeyedSHA1, + } + } + + /// Length of the authentication section including the type and length bytes (RFC 5880 4.2-4.4) + pub fn auth_len(&self) -> u8 { + match self { + Self::SimplePassword(p) => 3 + p.password.len() as u8, + Self::KeyedMD5(_) => 24, + Self::KeyedSHA1(_) => 28, + } + } + + /// Sequence number of the keyed variants; simple password has none + pub fn seq(&self) -> Option { + match self { + Self::SimplePassword(_) => None, + Self::KeyedMD5(d) => Some(d.seq), + Self::KeyedSHA1(d) => Some(d.seq), + } + } + + pub fn set_seq(&mut self, seq: u32) { + match self { + Self::SimplePassword(_) => {} + Self::KeyedMD5(d) => d.seq = seq, + Self::KeyedSHA1(d) => d.seq = seq, + } + } + + pub fn meticulous(&self) -> bool { + match self { + Self::SimplePassword(_) => false, + Self::KeyedMD5(d) => d.meticulous, + Self::KeyedSHA1(d) => d.meticulous, + } + } + + pub fn key_id(&self) -> u8 { + match self { + Self::SimplePassword(d) => d.key_id, + Self::KeyedMD5(d) => d.key_id, + Self::KeyedSHA1(d) => d.key_id, + } + } + + /// The Auth Key/Digest field; holds the key before signing and the digest on the wire + pub fn digest_field(&self) -> Option<&[u8]> { + match self { + Self::SimplePassword(_) => None, + Self::KeyedMD5(d) => Some(&d.digest), + Self::KeyedSHA1(d) => Some(&d.digest), + } + } + + fn digest_field_mut(&mut self) -> Option<&mut [u8]> { + match self { + Self::SimplePassword(_) => None, + Self::KeyedMD5(d) => Some(&mut d.digest), + Self::KeyedSHA1(d) => Some(&mut d.digest), + } + } + + fn serialize(&self, wtr: &mut impl Write) -> Result<(), std::io::Error> { + wtr.write_u8(self.auth_type().into())?; + wtr.write_u8(self.auth_len())?; + match self { + Self::SimplePassword(ad) => ad.serialize(wtr), + Self::KeyedMD5(ad) => ad.serialize(wtr), + Self::KeyedSHA1(ad) => ad.serialize(wtr), + } + } +} + +#[derive(Clone, Debug, NomBE)] pub struct BfdPacket { pub flags: BfdFlags, pub my_disc: BfdDiscriminator, @@ -270,9 +437,10 @@ pub struct BfdPacket { } impl BfdPacket { - pub fn serialize(&self) -> Result, std::io::Error> { - // TODO: serialize auth - let buf = [0u8; 24]; + pub fn serialize(&self) -> Result, std::io::Error> { + let auth_len = self.auth.as_ref().map_or(0, BfdAuth::auth_len); + let buf_len = 24 + auth_len as usize; + let buf = Vec::with_capacity(buf_len); let mut wtr = Cursor::new(buf); wtr.write_u32::(self.flags.0)?; wtr.write_u32::(self.my_disc.0)?; @@ -281,7 +449,109 @@ impl BfdPacket { wtr.write_u32::(self.required_min_rx.0)?; wtr.write_u32::(self.required_min_echo_rx.0)?; - Ok(Box::new(wtr.into_inner())) + if let Some(ad) = &self.auth { + ad.serialize(&mut wtr)?; + } + + let buf = wtr.into_inner(); + assert_eq!(buf.len(), buf_len); + + Ok(buf) + } + + fn digest(&self) -> std::io::Result> { + Ok(D::digest(self.serialize()?)) + } + + /// Digest of the serialised packet as it currently stands, i.e. with whatever is in the digest field + fn compute_digest(&self) -> std::io::Result>> { + Ok(match &self.auth { + Some(BfdAuth::KeyedMD5(_)) => Some(self.digest::()?.as_slice().to_vec()), + Some(BfdAuth::KeyedSHA1(_)) => Some(self.digest::()?.as_slice().to_vec()), + Some(BfdAuth::SimplePassword(_)) | None => None, + }) + } + + /// Replaces the key in the digest field with the digest of the packet (RFC 5880 6.7.3) + pub fn sign(&mut self) -> std::io::Result<()> { + if let Some(digest) = self.compute_digest()? { + if let Some(slot) = self.auth.as_mut().and_then(BfdAuth::digest_field_mut) { + slot.copy_from_slice(&digest); + } + } + Ok(()) + } + + pub fn verify(&self, expected: &BfdAuth) -> std::io::Result { + if !self.flags.auth_present() { + warn!("Auth present flag not set; expected authentication. Failed."); + return Ok(false); + }; + if let Some(ad) = &self.auth { + // If the received BFD Control packet does not contain an + // Authentication Section, or the Auth Type is not correct, + // then the received packet MUST be discarded. + match (ad, expected) { + (BfdAuth::SimplePassword(sp), BfdAuth::SimplePassword(expected)) => { + // If the Auth Key ID field does not match the ID of a configured + // password, the received packet MUST be discarded. + if sp.key_id != expected.key_id { + warn!( + "Received key id `{}` doesn't match expected key id `{}`", + sp.key_id, expected.key_id + ); + return Ok(false); + } + // If the Auth Len field is not equal to the length of the password + // selected by the key ID, plus three, the packet MUST be discarded. + // NB: implied by vec comparison + // + // If the Password field does not match the password selected by the + // key ID, the packet MUST be discarded. + Ok(sp.password.ct_eq(&expected.password).into()) + } + (BfdAuth::KeyedMD5(_), BfdAuth::KeyedMD5(_)) + | (BfdAuth::KeyedSHA1(_), BfdAuth::KeyedSHA1(_)) => { + if ad.auth_type() != expected.auth_type() { + warn!( + "Received auth type {:?} doesn't match expected {:?}", + ad.auth_type(), + expected.auth_type() + ); + return Ok(false); + } + // If the Auth Key ID field does not match the ID of a configured + // authentication key, the received packet MUST be discarded. + if ad.key_id() != expected.key_id() { + warn!( + "Received key id `{}` doesn't match expected key id `{}`", + ad.key_id(), + expected.key_id() + ); + return Ok(false); + } + // Replace the contents of the Auth Key/Digest field with the + // authentication key selected by the received Auth Key ID field. + let mut keyed = self.clone(); + match ( + keyed.auth.as_mut().and_then(BfdAuth::digest_field_mut), + expected.digest_field(), + ) { + (Some(slot), Some(key)) => slot.copy_from_slice(key), + _ => return Ok(false), + } + let computed = keyed.compute_digest()?.unwrap_or_default(); + Ok(computed.ct_eq(ad.digest_field().unwrap_or_default()).into()) + } + _ => { + warn!("Received auth type doesn't match expected, authentication failed"); + Ok(false) + } + } + } else { + warn!("Missing auth data, authentication failed"); + Ok(false) + } } } @@ -558,4 +828,323 @@ mod tests { assert_eq!(i.checked_mul(4), Some(BfdInterval::from_secs(4000))); assert_eq!(i.checked_mul(5), None); } + + const MD5_KEY: [u8; 16] = *b"0123456789abcdef"; + const SHA1_KEY: [u8; 20] = *b"0123456789abcdefghij"; + // Independently computed (hashlib) over the full packet with the key in the digest field + const MD5_MET_DIGEST: &str = "d739c4fa15bc2468315d20302f2081d5"; + const MD5_NONMET_DIGEST: &str = "d0a336d884839041be9e4d99aa713044"; + const SHA1_MET_DIGEST: &str = "b47f39e2b986e3c5ba7525244885cf6628d59974"; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn md5_auth(meticulous: bool, key_id: u8, seq: u32, key: [u8; 16]) -> BfdAuth { + BfdAuth::KeyedMD5(BfdAuthKeyedMD5 { + meticulous, + key_id, + seq, + digest: key, + }) + } + + fn sha1_auth(meticulous: bool, key_id: u8, seq: u32, key: [u8; 20]) -> BfdAuth { + BfdAuth::KeyedSHA1(BfdAuthKeyedSHA1 { + meticulous, + key_id, + seq, + digest: key, + }) + } + + fn password_auth(key_id: u8, password: &[u8]) -> BfdAuth { + BfdAuth::SimplePassword(BfdAuthSimplePassword { + key_id, + password: password.to_vec(), + }) + } + + fn auth_packet(auth: Option) -> BfdPacket { + let len = 24 + auth.as_ref().map_or(0, BfdAuth::auth_len); + BfdPacket { + flags: BfdFlags(0) + .with_vers(1) + .with_state(BfdState::Up as u8) + .with_detect_mult(3) + .with_length(len) + .with_auth_present(auth.is_some()), + my_disc: BfdDiscriminator(1), + your_disc: BfdDiscriminator(2), + desired_min_tx: BfdInterval::from_millis(300), + required_min_rx: BfdInterval::from_millis(300), + required_min_echo_rx: BfdInterval::from_micros(0), + auth, + } + } + + /// Sign, serialise and re-parse, as a packet crosses the wire + fn over_the_wire(auth: BfdAuth) -> BfdPacket { + let mut p = auth_packet(Some(auth)); + p.sign().unwrap(); + parse(&p.serialize().unwrap()) + } + + #[test] + fn auth_accessors() { + let md5 = md5_auth(true, 7, 9, MD5_KEY); + assert_eq!(md5.auth_type(), BfdAuthType::MetKeyedMD5); + assert_eq!(md5.auth_len(), 24); + assert_eq!( + (md5.seq(), md5.key_id(), md5.meticulous()), + (Some(9), 7, true) + ); + assert_eq!( + md5_auth(false, 0, 0, MD5_KEY).auth_type(), + BfdAuthType::KeyedMD5 + ); + + let sha1 = sha1_auth(true, 1, 2, SHA1_KEY); + assert_eq!(sha1.auth_type(), BfdAuthType::MetKeyedSHA1); + assert_eq!(sha1.auth_len(), 28); + assert_eq!( + sha1_auth(false, 0, 0, SHA1_KEY).auth_type(), + BfdAuthType::KeyedSHA1 + ); + + let pw = password_auth(3, b"secret"); + assert_eq!(pw.auth_type(), BfdAuthType::SimplePassword); + assert_eq!(pw.auth_len(), 9); + assert_eq!((pw.seq(), pw.key_id(), pw.meticulous()), (None, 3, false)); + assert!(pw.digest_field().is_none()); + } + + #[test] + fn set_seq_only_affects_keyed() { + let mut md5 = md5_auth(true, 1, 0, MD5_KEY); + md5.set_seq(42); + assert_eq!(md5.seq(), Some(42)); + let mut sha1 = sha1_auth(true, 1, 0, SHA1_KEY); + sha1.set_seq(43); + assert_eq!(sha1.seq(), Some(43)); + let mut pw = password_auth(1, b"pw"); + pw.set_seq(44); + assert_eq!(pw.seq(), None); + } + + #[test] + fn keyed_auth_wire_roundtrip() { + for auth in [ + md5_auth(false, 7, 0xdead_beef, MD5_KEY), + md5_auth(true, 7, 1, MD5_KEY), + sha1_auth(false, 255, u32::MAX, SHA1_KEY), + sha1_auth(true, 0, 0, SHA1_KEY), + ] { + let p = auth_packet(Some(auth.clone())); + let bytes = p.serialize().unwrap(); + assert_eq!(bytes.len(), 24 + auth.auth_len() as usize); + assert_eq!(bytes[3] as usize, bytes.len()); + assert_eq!(bytes[24], u8::from(auth.auth_type())); + assert_eq!(bytes[25], auth.auth_len()); + assert_eq!(parse(&bytes).auth, Some(auth)); + } + } + + #[test] + fn password_auth_wire_roundtrip() { + for pw in [&b"a"[..], b"secret", b"0123456789abcdef"] { + let auth = password_auth(5, pw); + let p = auth_packet(Some(auth.clone())); + let bytes = p.serialize().unwrap(); + assert_eq!(bytes.len(), 24 + 3 + pw.len()); + assert_eq!(bytes[24..27], [1, 3 + pw.len() as u8, 5]); + assert_eq!(&bytes[27..], pw); + assert_eq!(parse(&bytes).auth, Some(auth)); + } + } + + #[test] + fn parse_rejects_bad_auth_sections() { + let mut base = UP_PACKET.to_vec(); + base[1] |= 0x04; + // Auth Type 0 (None) and an unassigned type are not valid with the A bit set + for auth_type in [0u8, 6] { + let mut bytes = base.clone(); + bytes.extend_from_slice(&[auth_type, 24, 1, 0, 0, 0, 0, 1]); + bytes.extend_from_slice(&[0; 16]); + assert!(BfdPacket::parse(&bytes).is_err(), "type {auth_type}"); + } + // Truncated digest + let mut bytes = base.clone(); + bytes.extend_from_slice(&[2, 24, 1, 0, 0, 0, 0, 1]); + bytes.extend_from_slice(&[0; 8]); + assert!(BfdPacket::parse(&bytes).is_err()); + } + + #[test] + fn sign_matches_known_md5_digest() { + let mut p = auth_packet(Some(md5_auth(true, 7, 9, MD5_KEY))); + assert_eq!(p.serialize().unwrap()[..4], [0x20, 0xc4, 0x03, 0x30]); + p.sign().unwrap(); + let digest = p.auth.as_ref().unwrap().digest_field().unwrap(); + assert_eq!(hex(digest), MD5_MET_DIGEST); + } + + #[test] + fn sign_matches_known_sha1_digest() { + let mut p = auth_packet(Some(sha1_auth(true, 7, 9, SHA1_KEY))); + p.sign().unwrap(); + let digest = p.auth.as_ref().unwrap().digest_field().unwrap(); + assert_eq!(hex(digest), SHA1_MET_DIGEST); + } + + #[test] + fn meticulous_flag_changes_digest() { + let mut p = auth_packet(Some(md5_auth(false, 7, 9, MD5_KEY))); + p.sign().unwrap(); + let digest = p.auth.as_ref().unwrap().digest_field().unwrap(); + assert_eq!(hex(digest), MD5_NONMET_DIGEST); + } + + #[test] + fn sign_does_not_leak_key() { + for (auth, key) in [ + (md5_auth(true, 7, 9, MD5_KEY), &MD5_KEY[..]), + (sha1_auth(true, 7, 9, SHA1_KEY), &SHA1_KEY[..]), + ] { + let bytes = over_the_wire(auth).serialize().unwrap(); + assert!(!bytes.windows(key.len()).any(|w| w == key)); + } + } + + #[test] + fn sign_is_a_noop_without_keyed_auth() { + let mut none = auth_packet(None); + none.sign().unwrap(); + assert_eq!( + none.serialize().unwrap(), + parse(&UP_PACKET).serialize().unwrap() + ); + + let mut pw = auth_packet(Some(password_auth(1, b"secret"))); + let before = pw.serialize().unwrap(); + pw.sign().unwrap(); + assert_eq!(pw.serialize().unwrap(), before); + } + + #[test] + fn verify_accepts_valid_keyed_packets() { + for (sent, expected) in [ + (md5_auth(true, 7, 9, MD5_KEY), md5_auth(true, 7, 0, MD5_KEY)), + ( + md5_auth(false, 7, 9, MD5_KEY), + md5_auth(false, 7, 0, MD5_KEY), + ), + ( + sha1_auth(true, 7, 9, SHA1_KEY), + sha1_auth(true, 7, 0, SHA1_KEY), + ), + ( + sha1_auth(false, 7, 9, SHA1_KEY), + sha1_auth(false, 7, 0, SHA1_KEY), + ), + ] { + assert!(over_the_wire(sent).verify(&expected).unwrap()); + } + } + + #[test] + fn verify_does_not_depend_on_expected_seq() { + let p = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + assert!(p.verify(&md5_auth(true, 7, 12345, MD5_KEY)).unwrap()); + } + + #[test] + fn verify_rejects_wrong_key() { + let mut other = MD5_KEY; + other[15] ^= 1; + let p = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + assert!(!p.verify(&md5_auth(true, 7, 0, other)).unwrap()); + + let mut other = SHA1_KEY; + other[0] ^= 1; + let p = over_the_wire(sha1_auth(true, 7, 9, SHA1_KEY)); + assert!(!p.verify(&sha1_auth(true, 7, 0, other)).unwrap()); + } + + #[test] + fn verify_rejects_wrong_key_id() { + let p = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + assert!(!p.verify(&md5_auth(true, 8, 0, MD5_KEY)).unwrap()); + let p = over_the_wire(sha1_auth(true, 7, 9, SHA1_KEY)); + assert!(!p.verify(&sha1_auth(true, 8, 0, SHA1_KEY)).unwrap()); + } + + #[test] + fn verify_rejects_meticulous_mismatch() { + let p = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + assert!(!p.verify(&md5_auth(false, 7, 0, MD5_KEY)).unwrap()); + let p = over_the_wire(sha1_auth(false, 7, 9, SHA1_KEY)); + assert!(!p.verify(&sha1_auth(true, 7, 0, SHA1_KEY)).unwrap()); + } + + #[test] + fn verify_rejects_auth_type_mismatch() { + let md5 = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + assert!(!md5.verify(&sha1_auth(true, 7, 0, SHA1_KEY)).unwrap()); + assert!(!md5.verify(&password_auth(7, b"secret")).unwrap()); + let sha1 = over_the_wire(sha1_auth(true, 7, 9, SHA1_KEY)); + assert!(!sha1.verify(&md5_auth(true, 7, 0, MD5_KEY)).unwrap()); + let pw = over_the_wire(password_auth(7, b"secret")); + assert!(!pw.verify(&md5_auth(true, 7, 0, MD5_KEY)).unwrap()); + } + + #[test] + fn verify_rejects_tampered_packet() { + let sent = over_the_wire(md5_auth(true, 7, 9, MD5_KEY)); + let expected = md5_auth(true, 7, 0, MD5_KEY); + assert!(sent.verify(&expected).unwrap()); + + let mut p = sent.clone(); + p.my_disc = BfdDiscriminator(99); + assert!(!p.verify(&expected).unwrap()); + + let mut p = sent.clone(); + p.flags = p.flags.with_state(BfdState::Down as u8); + assert!(!p.verify(&expected).unwrap()); + + let mut p = sent.clone(); + p.desired_min_tx = BfdInterval::from_micros(1); + assert!(!p.verify(&expected).unwrap()); + + let mut p = sent.clone(); + p.auth.as_mut().unwrap().set_seq(10); + assert!(!p.verify(&expected).unwrap()); + + let mut p = sent; + if let Some(BfdAuth::KeyedMD5(d)) = p.auth.as_mut() { + d.digest[0] ^= 1; + } + assert!(!p.verify(&expected).unwrap()); + } + + #[test] + fn verify_rejects_missing_auth() { + let expected = md5_auth(true, 7, 0, MD5_KEY); + assert!(!auth_packet(None).verify(&expected).unwrap()); + // A bit set but no section + let mut p = auth_packet(None); + p.flags = p.flags.with_auth_present(true); + assert!(!p.verify(&expected).unwrap()); + } + + #[test] + fn verify_simple_password() { + let p = over_the_wire(password_auth(5, b"secret")); + assert!(p.verify(&password_auth(5, b"secret")).unwrap()); + assert!(!p.verify(&password_auth(5, b"secreT")).unwrap()); + assert!(!p.verify(&password_auth(5, b"secrets")).unwrap()); + assert!(!p.verify(&password_auth(5, b"secre")).unwrap()); + assert!(!p.verify(&password_auth(6, b"secret")).unwrap()); + } } diff --git a/src/bin/rust-bfdd.rs b/src/bin/rust-bfdd.rs index c00d2d1..808ad8a 100644 --- a/src/bin/rust-bfdd.rs +++ b/src/bin/rust-bfdd.rs @@ -3,7 +3,9 @@ use futures::future; use rust_bfd::config::{Config, ConfigError, ResolvedConfig, ResolvedPeer}; use rust_bfd::events::{EventMessageSink, ScriptHookSink, StateChangeEvent}; use rust_bfd::{bfd::*, events::EventMessage}; -use rust_bfd::{set_ttl_or_hop_limit, AtomicInstant, AtomicIntervalStats, BfdSessionStats}; +use rust_bfd::{ + set_ttl_or_hop_limit, AtomicInstant, AtomicIntervalStats, AuthStats, BfdSessionStats, +}; use std::{ collections::{HashMap, HashSet}, error::Error, @@ -98,6 +100,14 @@ impl Display for BfdError { } impl Error for BfdError {} +/// RFC 5880 6.7.3: a received sequence number must lie in [last, last + 3 * detect_mult] (or [last + 1, ..] when +/// meticulous), treated as an unsigned 32-bit circular number space. +fn seq_in_window(last: u32, seq: u32, detect_mult: u8, meticulous: bool) -> bool { + let lo = last.wrapping_add(meticulous as u32); + let hi = last.wrapping_add(3 * detect_mult as u32); + seq.wrapping_sub(lo) <= hi.wrapping_sub(lo) +} + /// Data structure to store the state of the Bfd machine. The impl does *not* /// implement any Bfd logic, it is merely a thread-safe data structure #[derive(Debug)] @@ -116,10 +126,10 @@ struct BfdSessionState { demand_mode: Atomic, // mutable by rx thread remote_demand_mode: Atomic, // mutable by rx thread detect_mult: Atomic, // mutable by control thread - auth_type: Atomic, // mutable by control thread - rcv_auth_seq: Atomic, // mutable by rx thread - xmit_auth_seq: Atomic, // mutable by tx thread? - auth_seq_known: Atomic, // mutable by rx thread? + rcv_auth_seq: Mutex, // must guard validation against parallel reception + xmit_auth_seq: Mutex, // must guard against OOO parallel transmission e.g. in rx task + auth_seq_known: Atomic, // mutable by rx thread? + auth_type: Atomic, // immutable after construction detection_time: Atomic, // mutable by rx thread poll_mode: Atomic, // mutable by rx thread @@ -193,6 +203,12 @@ impl BfdSessionState { fn auth_type(&self) -> BfdAuthType { self.auth_type.load(ORDERING) } + fn auth_seq_known(&self) -> bool { + self.auth_seq_known.load(ORDERING) + } + fn set_auth_seq_known(&self, value: bool) { + self.auth_seq_known.store(value, ORDERING) + } fn detect_mult(&self) -> u8 { self.detect_mult.load(ORDERING) } @@ -265,6 +281,7 @@ struct BfdSession { // DesiredMinTxInterval actually used for the transmit rate; lags the advertised value during a Poll Sequence effective_desired_min_tx: BfdInterval, event_dispatcher: EventDispatcherHandle, + auth: Option, } impl From<&BfdSessionState> for BfdSessionStats { @@ -283,6 +300,7 @@ impl From<&BfdSessionState> for BfdSessionStats { last_change: state.last_state_change(), detect_time: state.detection_time(), base_interval: BfdInterval::from_secs(0), // TODO: This is a hack and will be patched up in the session + auth: None, // filled in by the session } } } @@ -463,6 +481,9 @@ impl BfdSession { // .connect(SocketAddr::new(remote_addr, CONTROL_PORT)) // .await?; + let auth: Option = peer.auth.map(Into::into); + let auth_type = auth.as_ref().map_or(BfdAuthType::None, BfdAuth::auth_type); + // Incoming packets will come over the channel from the mux, since they don't send to the reciprocal port Self { rx, @@ -473,6 +494,7 @@ impl BfdSession { configured_desired_min_tx, effective_desired_min_tx: slow_desired_min_tx, event_dispatcher, + auth, state: BfdSessionState { control_sock: Arc::new(control_sock), local_ip: local_addr, @@ -488,10 +510,10 @@ impl BfdSession { demand_mode: Atomic::new(false), remote_demand_mode: Atomic::new(false), detect_mult: Atomic::new(peer.detect_mult), - auth_type: Atomic::new(BfdAuthType::None), - rcv_auth_seq: Atomic::new(0), - xmit_auth_seq: Atomic::new(rng.gen()), + rcv_auth_seq: Mutex::new(0), + xmit_auth_seq: Mutex::new(rng.gen()), auth_seq_known: Atomic::new(false), + auth_type: Atomic::new(auth_type), detection_time: Atomic::new(BfdInterval::from_secs(0)), poll_mode: Atomic::new(false), @@ -568,16 +590,41 @@ impl BfdSession { self.transmit_control_packet(false).await; } + async fn auth_stats(&self) -> Option { + let auth = self.auth.as_ref()?; + let uses_seq = !matches!(auth, BfdAuth::SimplePassword(_)); + let rcv_seq = if uses_seq && self.state.auth_seq_known() { + Some(*self.state.rcv_auth_seq.lock().await) + } else { + None + }; + let xmit_seq = if uses_seq { + Some(*self.state.xmit_auth_seq.lock().await) + } else { + None + }; + Some(AuthStats { + auth_type: self.state.auth_type(), + key_id: auth.key_id(), + rcv_seq, + xmit_seq, + }) + } + async fn run(&mut self) { while let Some(cmd) = self.rx.recv().await { + let mut missed_detect_times = 0; match cmd { SessionControlCommand::Quit => return, SessionControlCommand::RxPacket(packet) => { + missed_detect_times = 0; self.receive_control_packet(&packet).await; } SessionControlCommand::GetSessionStats { respond_to } => { - let stats = - BfdSessionStats::from(&self.state).with_base_interval(self.cur_interval); + let auth = self.auth_stats().await; + let stats = BfdSessionStats::from(&self.state) + .with_base_interval(self.cur_interval) + .with_auth(auth); respond_to.send(stats).unwrap() } SessionControlCommand::TxControlPacket { poll_response } => { @@ -585,15 +632,19 @@ impl BfdSession { } SessionControlCommand::ReceiveTimeout => { self.state.set_local_diag(BfdDiagnostic::TimeExpired); + missed_detect_times += 1; self.transition(BfdState::Down).await; } } + if missed_detect_times == 2 { + self.state.set_auth_seq_known(false); + } } println!("Session command channel closed"); } - async fn transmit_control_packet(&self, poll_response: bool) { - let packet = BfdPacket { + async fn transmit_control_packet(&mut self, poll_response: bool) { + let mut packet = BfdPacket { flags: BfdFlags(0) .with_vers(1) .with_diag(self.state.local_diag().into()) @@ -607,18 +658,32 @@ impl BfdSession { .with_cpi(false) .with_demand(self.state.demand_mode()) .with_detect_mult(self.state.detect_mult()) - .with_length(24), + .with_length(24 + self.auth.as_ref().map_or(0, BfdAuth::auth_len)) + .with_auth_present(self.auth.is_some()), my_disc: self.state.local_discr(), your_disc: self.state.remote_discr(), desired_min_tx: self.state.desired_min_tx_interval(), required_min_rx: self.state.required_min_rx_interval(), required_min_echo_rx: BfdInterval::from_secs(0), - auth: None, + auth: self.auth.clone(), // put configured method and key into packet }; let socket = self.state.control_sock.clone(); let dest = self.state.peer_addr; + + // Held until the packet is sent so sequence numbers hit the wire in order + let mut xmit_seq = match packet.auth { + Some(_) => Some(self.state.xmit_auth_seq.lock().await), + None => None, + }; + if let (Some(auth), Some(seq)) = (&mut packet.auth, xmit_seq.as_mut()) { + **seq = seq.wrapping_add(1); + auth.set_seq(**seq); + } + packet.sign().unwrap(); + self.state.record_tx(); debug!("tx packet: {:?}", packet); + if let Err(e) = socket .send_to( packet.serialize().unwrap().as_ref(), @@ -755,7 +820,6 @@ impl BfdSession { warn!("Got packet with auth enabled when we disagree, discarding"); return; } - // If the A bit is clear and authentication is in use (bfd.AuthType is nonzero), the packet MUST be discarded. if !p.flags.auth_present() && self.state.auth_type() != BfdAuthType::None { warn!("Got packet without auth when we expect it, discarding"); @@ -764,8 +828,48 @@ impl BfdSession { // If the A bit is set, the packet MUST be authenticated under the rules of section 6.7, based on the // authentication type in use (bfd.AuthType). This may cause the packet to be discarded. - if p.flags.auth_present() { - unimplemented!("Authentication is not implemented"); + if let Some(expected) = self.auth.as_ref().filter(|_| p.flags.auth_present()) { + let received_seq = p.auth.as_ref().and_then(BfdAuth::seq); + let mut rcv_auth_seq = self.state.rcv_auth_seq.lock().await; + + if let Some(seq) = received_seq { + // If bfd.AuthSeqKnown is 1, examine the Sequence Number field. [...] if the sequence number lies + // outside of the valid range the received packet MUST be discarded. + if self.state.auth_seq_known() { + if !seq_in_window( + *rcv_auth_seq, + seq, + p.flags.detect_mult(), + expected.meticulous(), + ) { + warn!( + "Got packet with out of window sequence number {} (last {}), discarding", + seq, *rcv_auth_seq + ); + return; + } + } else { + // Otherwise (bfd.AuthSeqKnown is 0), set bfd.AuthSeqKnown to 1. + self.state.set_auth_seq_known(true); + } + // bfd.RcvAuthSeq is set to the Sequence Number field. + *rcv_auth_seq = seq; + } + + match p.verify(expected) { + Ok(true) => {} + Ok(false) => { + warn!("Got packet failing authentication, discarding"); + return; + } + Err(e) => { + warn!( + "Error verifying packet authentication ({:?}), discarding", + e + ); + return; + } + } } // Set bfd.RemoteDiscr to the value of My Discriminator. @@ -1083,7 +1187,7 @@ impl Bfdd { } let session = BfdSessionHandle::new( self.local.ip(), - *peer, + peer.clone(), local_discr, self.event_dispatcher.clone(), ) @@ -1164,13 +1268,22 @@ impl Bfdd { } } async fn session_report(&self) -> String { - future::join_all( + let mut stats = future::join_all( self.sessions_by_discr .values() - .map(|session| async { session.get_stats().await.unwrap().to_string() }), + .map(|session| async { session.get_stats().await.unwrap() }), ) - .await - .join("\n\t") + .await; + stats.sort_by_key(|s| s.remote_ip); + stats + .iter() + .flat_map(|s| { + s.to_string() + .lines() + .map(|l| format!("\n {l}")) + .collect::>() + }) + .collect() } async fn handle_datagram(&self, data: &[u8], addr: SocketAddr) { let Ok((_leftover, packet)) = BfdPacket::parse(data) else { @@ -1189,7 +1302,7 @@ impl Bfdd { async fn run(mut self) -> io::Result<()> { info!("rust-bfd starting up"); self.start_peers().await?; - info!("configured sessions: {}", self.session_report().await); + info!("configured sessions:{}", self.session_report().await); let mut sigusr1 = signal(SignalKind::user_defined1())?; let mut buf = [0; 1024]; @@ -1204,14 +1317,14 @@ impl Bfdd { Ok((len, addr)) => { self.handle_datagram(&buf[..len], addr).await; if log_enabled!(log::Level::Debug) { - debug!("configured sessions: {}", self.session_report().await); + debug!("configured sessions:{}", self.session_report().await); } } Err(e) => warn!("Error receiving from socket {e:?}"), }, _ = sigusr1.recv() => { info!("SIGUSR1 received"); - info!("configured sessions: {}", self.session_report().await); + info!("configured sessions:{}", self.session_report().await); } } } @@ -1248,6 +1361,45 @@ async fn main() -> io::Result<()> { mod tests { use super::*; + #[test] + fn seq_window_non_meticulous_bounds() { + // RFC 5880 6.7.3: [RcvAuthSeq, RcvAuthSeq + 3*DetectMult] + assert!(seq_in_window(100, 100, 3, false)); + assert!(seq_in_window(100, 101, 3, false)); + assert!(seq_in_window(100, 109, 3, false)); + assert!(!seq_in_window(100, 110, 3, false)); + assert!(!seq_in_window(100, 99, 3, false)); + } + + #[test] + fn seq_window_meticulous_bounds() { + // RFC 5880 6.7.4: [RcvAuthSeq + 1, RcvAuthSeq + 3*DetectMult] + assert!(!seq_in_window(100, 100, 3, true)); + assert!(seq_in_window(100, 101, 3, true)); + assert!(seq_in_window(100, 109, 3, true)); + assert!(!seq_in_window(100, 110, 3, true)); + assert!(!seq_in_window(100, 99, 3, true)); + } + + #[test] + fn seq_window_scales_with_detect_mult() { + assert!(seq_in_window(0, 3, 1, false)); + assert!(!seq_in_window(0, 4, 1, false)); + assert!(seq_in_window(0, 255 * 3, 255, false)); + assert!(!seq_in_window(0, 255 * 3 + 1, 255, false)); + } + + #[test] + fn seq_window_wraps_at_u32_max() { + assert!(seq_in_window(u32::MAX, u32::MAX, 3, false)); + assert!(!seq_in_window(u32::MAX, u32::MAX, 3, true)); + assert!(seq_in_window(u32::MAX, 0, 3, true)); + assert!(seq_in_window(u32::MAX - 2, 6, 3, true)); + assert!(!seq_in_window(u32::MAX - 2, 7, 3, true)); + assert!(!seq_in_window(u32::MAX - 2, u32::MAX - 3, 3, true)); + assert!(!seq_in_window(5, u32::MAX, 3, true)); + } + #[test] fn jitter_is_uniform_over_rfc_range() { // RFC 5880 6.8.7: each interval is reduced by a random 0-25% (0-10% when DetectMult is 1), so delays are @@ -1384,6 +1536,7 @@ mod tests { desired_min_tx: BfdInterval::from_millis(desired_ms), required_min_rx: BfdInterval::from_millis(required_ms), detect_mult, + auth: None, } } diff --git a/src/config.rs b/src/config.rs index 7ed7032..d4074e9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,8 +17,9 @@ use std::{ }; use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_valid::Validate; -use crate::bfd::BfdInterval; +use crate::bfd::{BfdAuth, BfdAuthKeyedMD5, BfdAuthKeyedSHA1, BfdAuthSimplePassword, BfdInterval}; pub const DEFAULT_CONFIG_PATH: &str = "/etc/rust-bfd/config.yaml"; pub const DEFAULT_HOOKS_DIR: &str = "/etc/rust-bfd/hooks.d"; @@ -29,6 +30,10 @@ pub const ENV_DEFAULTS_DESIRED_MIN_TX: &str = "BFD_DEFAULTS_DESIRED_MIN_TX"; pub const ENV_DEFAULTS_REQUIRED_MIN_RX: &str = "BFD_DEFAULTS_REQUIRED_MIN_RX"; pub const ENV_DEFAULTS_DETECT_MULT: &str = "BFD_DEFAULTS_DETECT_MULT"; +fn default_true() -> bool { + true +} + #[derive(Debug)] pub enum ConfigError { Io { @@ -94,6 +99,71 @@ impl Default for Defaults { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PasswordAuthConfig { + pub key_id: u8, + #[serde(with = "serde_bytes")] + // The password is a binary string, and MUST be 1 to 16 bytes in + // length. + #[validate(min_items = 1)] + #[validate(max_items = 16)] + pub password: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct Md5AuthConfig { + pub key_id: u8, + #[serde(with = "serde_bytes")] + // The key is given verbatim (not derived) and must be exactly 16 bytes + pub key: [u8; 16], + #[serde(default = "default_true")] + pub meticulous: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct Sha1AuthConfig { + pub key_id: u8, + #[serde(with = "serde_bytes")] + // The key is given verbatim (not derived) and must be exactly 20 bytes + pub key: [u8; 20], + #[serde(default = "default_true")] + pub meticulous: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum AuthConfig { + Password(PasswordAuthConfig), + Md5(Md5AuthConfig), + Sha1(Sha1AuthConfig), +} + +impl From for BfdAuth { + fn from(value: AuthConfig) -> Self { + match value { + AuthConfig::Password(ac) => BfdAuth::SimplePassword(BfdAuthSimplePassword { + key_id: ac.key_id, + password: ac.password, + }), + AuthConfig::Md5(ac) => BfdAuth::KeyedMD5(BfdAuthKeyedMD5 { + key_id: ac.key_id, + meticulous: ac.meticulous, + digest: ac.key, + seq: 0, // set during signing + }), + AuthConfig::Sha1(ac) => BfdAuth::KeyedSHA1(BfdAuthKeyedSHA1 { + key_id: ac.key_id, + meticulous: ac.meticulous, + digest: ac.key, + seq: 0, // set during signing + }), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PeerConfig { @@ -112,6 +182,8 @@ pub struct PeerConfig { pub required_min_rx: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub detect_mult: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, } impl PeerConfig { @@ -121,6 +193,7 @@ impl PeerConfig { desired_min_tx: None, required_min_rx: None, detect_mult: None, + auth: None, } } } @@ -253,7 +326,7 @@ impl Config { } /// Validate and apply defaults to each peer. - pub fn resolve(&self) -> Result { + pub fn resolve(self) -> Result { let invalid = |message: String| ConfigError::Invalid(message); if self.bind.is_empty() { @@ -269,7 +342,7 @@ impl Config { let mut seen = HashSet::new(); let peers = self .peers - .iter() + .into_iter() .map(|peer| { if !seen.insert(peer.address) { return Err(invalid(format!("duplicate peer {}", peer.address))); @@ -304,6 +377,7 @@ impl Config { &what("required_min_rx"), )?, detect_mult, + auth: peer.auth, }) }) .collect::, _>>()?; @@ -351,12 +425,13 @@ pub struct ResolvedConfig { pub peers: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedPeer { pub address: IpAddr, pub desired_min_tx: BfdInterval, pub required_min_rx: BfdInterval, pub detect_mult: u8, + pub auth: Option, } #[cfg(test)] @@ -418,7 +493,7 @@ peers: #[test] fn peers_inherit_defaults_and_override() { let resolved = Config::from_yaml(FULL).unwrap().resolve().unwrap(); - let (a, b) = (resolved.peers[0], resolved.peers[1]); + let (a, b) = (&resolved.peers[0], &resolved.peers[1]); assert_eq!(a.desired_min_tx, BfdInterval::from_millis(500)); assert_eq!(a.required_min_rx, BfdInterval::from_millis(250)); assert_eq!(a.detect_mult, 5); @@ -500,10 +575,10 @@ peers: #[test] fn dist_config_is_valid() { let config = Config::from_yaml(include_str!("../config.yaml.dist")).unwrap(); - let resolved = config.resolve().unwrap(); - assert_eq!(resolved.peers.len(), 1); assert_eq!(config.defaults, Defaults::default()); assert_eq!(config.hooks_dir, PathBuf::from(DEFAULT_HOOKS_DIR)); + let resolved = config.resolve().unwrap(); + assert_eq!(resolved.peers.len(), 1); } #[test] @@ -582,6 +657,7 @@ peers: let mut c = base(); c.peers.push(PeerConfig::new(ip("2001:db8::2"))); assert!(c + .clone() .resolve() .unwrap_err() .to_string() diff --git a/src/lib.rs b/src/lib.rs index 6f4469a..0e70845 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -167,6 +167,28 @@ fn fmt_ago(t: Option) -> String { }) } +/// A snapshot of a session's authentication state. Sequence numbers are `None` when not applicable or not yet known. +#[derive(Debug, Clone, Copy)] +pub struct AuthStats { + pub auth_type: BfdAuthType, + pub key_id: u8, + pub rcv_seq: Option, + pub xmit_seq: Option, +} +impl Display for AuthStats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let seq = |s: Option| s.map_or("-".to_string(), |s| s.to_string()); + write!( + f, + "{} KeyId:{} RcvSeq:{} XmitSeq:{}", + self.auth_type, + self.key_id, + seq(self.rcv_seq), + seq(self.xmit_seq) + ) + } +} + #[derive(Debug)] pub struct BfdSessionStats { pub local_ip: IpAddr, @@ -183,8 +205,14 @@ pub struct BfdSessionStats { pub last_change: Instant, pub detect_time: BfdInterval, pub base_interval: BfdInterval, + /// `None` when the session does not use authentication + pub auth: Option, } impl BfdSessionStats { + pub fn with_auth(mut self, auth: Option) -> Self { + self.auth = auth; + self + } pub fn with_base_interval(mut self, base_interval: BfdInterval) -> Self { self.base_interval = base_interval; self @@ -212,6 +240,10 @@ impl Display for BfdSessionStats { )?; writeln!(f, " Rx: {}", self.rx)?; writeln!(f, " Tx: {}", self.tx)?; + match &self.auth { + Some(auth) => writeln!(f, " Auth: {auth}")?, + None => writeln!(f, " Auth: none")?, + } write!( f, " Last Up: {} Last Down: {}", @@ -360,6 +392,7 @@ mod tests { last_change: Instant::now(), detect_time: BfdInterval::from_millis(900), base_interval: BfdInterval::from_secs(1), + auth: None, } } @@ -367,7 +400,7 @@ mod tests { fn session_stats_display_never() { let out = session_stats(None, None).to_string(); let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines.len(), 5); + assert_eq!(lines.len(), 6); assert_eq!( lines[0].split_whitespace().collect::>(), ["192.0.2.2", "Up", "2", "1"] @@ -381,7 +414,36 @@ mod tests { " Rx: 2 pkts, interval min/avg/max 300/300/300 ms" ); assert_eq!(lines[3], " Tx: 1 pkts, interval min/avg/max -/-/- ms"); - assert_eq!(lines[4], " Last Up: never Last Down: never"); + assert_eq!(lines[4], " Auth: none"); + assert_eq!(lines[5], " Last Up: never Last Down: never"); + } + + #[test] + fn session_stats_display_auth() { + let out = session_stats(None, None) + .with_auth(Some(AuthStats { + auth_type: BfdAuthType::MetKeyedSHA1, + key_id: 3, + rcv_seq: Some(100), + xmit_seq: Some(7), + })) + .to_string(); + assert!( + out.contains("\n Auth: meticulous-sha1 KeyId:3 RcvSeq:100 XmitSeq:7\n"), + "{out}" + ); + let out = session_stats(None, None) + .with_auth(Some(AuthStats { + auth_type: BfdAuthType::KeyedMD5, + key_id: 1, + rcv_seq: None, + xmit_seq: Some(7), + })) + .to_string(); + assert!( + out.contains("\n Auth: md5 KeyId:1 RcvSeq:- XmitSeq:7\n"), + "{out}" + ); } #[test]