From cab12a32cd2332ac5de7280f1c0c5a709995561f Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Thu, 9 Nov 2023 14:11:02 +0100 Subject: [PATCH] WIP --- Cargo.lock | 139 ++++++++++++ Cargo.toml | 1 + ntp-proto/Cargo.toml | 1 + ntp-proto/src/keyset.rs | 5 +- ntp-proto/src/lib.rs | 5 +- ntp-proto/src/packet/crypto.rs | 265 ++++++++++++++++++++++- ntp-proto/src/packet/extension_fields.rs | 169 +++++++++++++-- ntp-proto/src/packet/mod.rs | 18 +- ntp-proto/src/peer.rs | 60 ++++- 9 files changed, 617 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 247d2c496..bcc03ed24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,12 @@ version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -155,6 +161,12 @@ dependencies = [ "digest", ] +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + [[package]] name = "core-foundation" version = "0.9.3" @@ -200,6 +212,34 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89b8c6a2e4b1f45971ad09761aafb85514a84744b67a95e32c3cc1352d1f65c" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "platforms", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dbl" version = "0.3.2" @@ -209,6 +249,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -220,12 +270,42 @@ dependencies = [ "subtle", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fiat-crypto" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f69037fe1b785e84986b4f2cbcf647381876a00671d25ceef715d7812dd7e1dd" + [[package]] name = "generic-array" version = "0.14.7" @@ -369,6 +449,7 @@ dependencies = [ "aead", "aes-siv", "arbitrary", + "ed25519-dalek", "md-5", "rand", "rustls", @@ -465,6 +546,22 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "platforms" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ab3f592e6fb464fc9712d8d6e6912de6473954635fd76a589d832cffcbb0" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -554,6 +651,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.21.8" @@ -645,6 +751,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" + [[package]] name = "serde" version = "1.0.191" @@ -685,6 +797,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -694,6 +817,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" + [[package]] name = "socket2" version = "0.5.4" @@ -716,6 +845,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "subtle" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 61130df27..9a9aa72ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ rustls-native-certs = "0.6.0" # crypto aead = "0.5.0" aes-siv = "0.7.0" +ed25519-dalek = { version = "2.0.0", features = ["rand_core"] } # Note: md5 is needed to calculate ReferenceIDs for IPv6 addresses per RFC5905 md-5 = "0.10.0" zeroize = "1.5" diff --git a/ntp-proto/Cargo.toml b/ntp-proto/Cargo.toml index 3fc13d1ab..2e95868cf 100644 --- a/ntp-proto/Cargo.toml +++ b/ntp-proto/Cargo.toml @@ -30,6 +30,7 @@ thiserror.workspace = true aead.workspace = true aes-siv.workspace = true zeroize.workspace = true +ed25519-dalek.workspace = true [dev-dependencies] rustls-pemfile.workspace = true diff --git a/ntp-proto/src/keyset.rs b/ntp-proto/src/keyset.rs index 4fbc544d2..e2718bd0d 100644 --- a/ntp-proto/src/keyset.rs +++ b/ntp-proto/src/keyset.rs @@ -11,6 +11,7 @@ use crate::{ AesSivCmac256, AesSivCmac512, Cipher, CipherHolder, CipherProvider, CipherType, DecryptError, EncryptResult, ExtensionField, }, + NtpTimestamp, }; pub struct DecodedServerCookie { @@ -210,7 +211,7 @@ impl KeySet { let nonce = &cookie[6..22]; let ciphertext = cookie[22..].get(..cipher_text_length).ok_or(DecryptError)?; - let plaintext = key.decrypt(nonce, ciphertext, &[])?; + let plaintext = key.decrypt(nonce, ciphertext, &[], NtpTimestamp::default())?; let [b0, b1, ref key_bytes @ ..] = plaintext[..] else { return Err(DecryptError); @@ -266,6 +267,7 @@ impl KeySet { impl CipherProvider for KeySet { fn get(&self, etype: CipherType, context: &[ExtensionField<'_>]) -> Option> { match etype { + CipherType::None => None, CipherType::Nts => { let mut decoded = None; @@ -281,6 +283,7 @@ impl CipherProvider for KeySet { decoded.map(CipherHolder::DecodedServerCookie) } + CipherType::Ed25519 => None, } } } diff --git a/ntp-proto/src/lib.rs b/ntp-proto/src/lib.rs index 997759ee9..73952ccb9 100644 --- a/ntp-proto/src/lib.rs +++ b/ntp-proto/src/lib.rs @@ -43,8 +43,9 @@ mod exports { #[cfg(feature = "__internal-fuzz")] pub use super::packet::ExtensionField; pub use super::packet::{ - Cipher, CipherProvider, EncryptResult, ExtensionHeaderVersion, NoCipher, - NtpAssociationMode, NtpLeapIndicator, NtpPacket, PacketParsingError, + Cipher, CipherProvider, Ed25519Private, Ed25519Public, EncryptResult, + ExtensionHeaderVersion, NoCipher, NtpAssociationMode, NtpLeapIndicator, NtpPacket, + PacketParsingError, }; #[cfg(feature = "__internal-fuzz")] pub use super::peer::fuzz_measurement_from_packet; diff --git a/ntp-proto/src/packet/crypto.rs b/ntp-proto/src/packet/crypto.rs index 79eed53b9..507831eca 100644 --- a/ntp-proto/src/packet/crypto.rs +++ b/ntp-proto/src/packet/crypto.rs @@ -1,9 +1,12 @@ +use std::io::Write; + use aes_siv::{siv::Aes128Siv, siv::Aes256Siv, Key, KeyInit}; +use ed25519_dalek::{Signer, Verifier}; use rand::Rng; use tracing::error; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::keyset::DecodedServerCookie; +use crate::{keyset::DecodedServerCookie, NtpTimestamp}; use super::extension_fields::ExtensionField; @@ -59,9 +62,12 @@ pub struct EncryptResult { pub ciphertext_length: usize, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CipherType { + #[default] + None, Nts, + Ed25519, } pub trait Cipher: Sync + Send + ZeroizeOnDrop + 'static { @@ -75,6 +81,11 @@ pub trait Cipher: Sync + Send + ZeroizeOnDrop + 'static { /// - encrypts `plaintext_length` bytes from the buffer /// - puts the nonce followed by the ciphertext into the buffer /// - returns the size of the nonce and ciphertext + /// + /// For Ed25519 type ciphers it should + /// - Write a certificate of the short-term key signed with the long term key + /// - Put a signature of the asociated data into the buffer as ciphertext + /// - return the size of the ciphertext and that of the certificate as nonce_length fn encrypt( &self, buffer: &mut [u8], @@ -88,6 +99,7 @@ pub trait Cipher: Sync + Send + ZeroizeOnDrop + 'static { nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], + transmit_timestamp: NtpTimestamp, ) -> Result, DecryptError>; fn key_bytes(&self) -> &[u8]; @@ -113,13 +125,34 @@ pub trait CipherProvider { pub struct NoCipher; -impl CipherProvider for NoCipher { - fn get<'a>( +impl ZeroizeOnDrop for NoCipher {} + +impl Cipher for NoCipher { + fn etype(&self) -> CipherType { + CipherType::None + } + + fn encrypt( &self, - _etype: CipherType, - _context: &[ExtensionField<'_>], - ) -> Option> { - None + _buffer: &mut [u8], + _plaintext_length: usize, + _associated_data: &[u8], + ) -> std::io::Result { + Err(std::io::ErrorKind::Other.into()) + } + + fn decrypt( + &self, + _nonce: &[u8], + _ciphertext: &[u8], + _associated_data: &[u8], + _transmit_timestamp: NtpTimestamp, + ) -> Result, DecryptError> { + Err(DecryptError) + } + + fn key_bytes(&self) -> &[u8] { + &[] } } @@ -217,6 +250,7 @@ impl Cipher for AesSivCmac256 { nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], + _transmit_timestamp: NtpTimestamp, ) -> Result, DecryptError> { let mut siv = Aes128Siv::new(&self.key); siv.decrypt([associated_data, nonce], ciphertext) @@ -295,6 +329,7 @@ impl Cipher for AesSivCmac512 { nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], + _transmit_timestamp: NtpTimestamp, ) -> Result, DecryptError> { let mut siv = Aes256Siv::new(&self.key); siv.decrypt([associated_data, nonce], ciphertext) @@ -313,6 +348,150 @@ impl std::fmt::Debug for AesSivCmac512 { } } +#[derive(Debug, Clone)] +pub struct Ed25519Public { + public_key: ed25519_dalek::VerifyingKey, +} + +impl Ed25519Public { + pub fn new(key: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH]) -> Option { + Some(Ed25519Public { + public_key: ed25519_dalek::VerifyingKey::from_bytes(&key).ok()?, + }) + } +} + +// This is a white lie, we don't leak anything problematic on drop +impl ZeroizeOnDrop for Ed25519Public {} + +impl Cipher for Ed25519Public { + fn etype(&self) -> CipherType { + CipherType::Ed25519 + } + + fn encrypt( + &self, + _buffer: &mut [u8], + _plaintext_length: usize, + _associated_data: &[u8], + ) -> std::io::Result { + // Can't encrypt with a public key + Err(std::io::ErrorKind::Other.into()) + } + + fn decrypt( + &self, + nonce: &[u8], + ciphertext: &[u8], + associated_data: &[u8], + transmit_timestamp: NtpTimestamp, + ) -> Result, DecryptError> { + if nonce.len() != 104 { + return Err(DecryptError); + } + if ciphertext.len() != 64 { + return Err(DecryptError); + } + let cert_signature = ed25519_dalek::Signature::from_bytes(nonce[0..64].try_into().unwrap()); + self.public_key + .verify(&nonce[64..104], &cert_signature) + .map_err(|_| DecryptError)?; + let valid_after = NtpTimestamp::from_seconds_nanos_since_ntp_era( + u32::from_be_bytes(nonce[96..100].try_into().unwrap()), + 0, + ); + let valid_before = NtpTimestamp::from_seconds_nanos_since_ntp_era( + u32::from_be_bytes(nonce[100..104].try_into().unwrap()), + 0, + ); + if transmit_timestamp <= valid_after || transmit_timestamp >= valid_before { + return Err(DecryptError); + } + let short_term_key = + ed25519_dalek::VerifyingKey::from_bytes(nonce[64..96].try_into().unwrap()) + .map_err(|_| DecryptError)?; + let message_signature = + ed25519_dalek::Signature::from_bytes(ciphertext[0..64].try_into().unwrap()); + short_term_key + .verify(associated_data, &message_signature) + .map_err(|_| DecryptError)?; + Ok(vec![]) + } + + fn key_bytes(&self) -> &[u8] { + todo!() + } +} + +pub struct Ed25519Private { + certificate: Vec, + short_term_key: ed25519_dalek::SecretKey, +} + +impl Ed25519Private { + pub fn new( + short_term_key: [u8; ed25519_dalek::SECRET_KEY_LENGTH], + certificate: Vec, + ) -> Self { + Ed25519Private { + certificate, + short_term_key, + } + } +} + +impl ZeroizeOnDrop for Ed25519Private {} + +impl Drop for Ed25519Private { + fn drop(&mut self) { + self.certificate.zeroize(); + self.short_term_key.zeroize(); + } +} + +impl Cipher for Ed25519Private { + fn etype(&self) -> CipherType { + CipherType::Ed25519 + } + + fn encrypt( + &self, + buffer: &mut [u8], + plaintext_length: usize, + associated_data: &[u8], + ) -> std::io::Result { + if plaintext_length != 0 { + return Err(std::io::ErrorKind::Other.into()); + } + let mut cursor = std::io::Cursor::new(buffer); + cursor.write_all(&self.certificate)?; + + let signer = ed25519_dalek::SigningKey::from_bytes(&self.short_term_key); + let signature = signer.sign(associated_data); + cursor.write_all(&signature.to_bytes())?; + + Ok(EncryptResult { + nonce_length: self.certificate.len(), + ciphertext_length: 64, + }) + } + + fn decrypt( + &self, + _nonce: &[u8], + _ciphertext: &[u8], + _associated_data: &[u8], + _transmit_timestamp: NtpTimestamp, + ) -> Result, DecryptError> { + // We don't support verification with the private key + Err(DecryptError) + } + + fn key_bytes(&self) -> &[u8] { + todo!() + } +} + #[cfg(test)] pub struct IdentityCipher { nonce_length: usize, @@ -364,6 +543,7 @@ impl Cipher for IdentityCipher { nonce: &[u8], ciphertext: &[u8], associated_data: &[u8], + _transmit_timestamp: NtpTimestamp, ) -> Result, DecryptError> { debug_assert!(associated_data.is_empty()); @@ -379,8 +559,67 @@ impl Cipher for IdentityCipher { #[cfg(test)] mod tests { + use rand::SeedableRng; + use super::*; + #[test] + fn test_ed25519() { + let mut csprng = rand::rngs::StdRng::seed_from_u64(0); + let long_term_key = ed25519_dalek::SigningKey::generate(&mut csprng); + + let short_term_key = ed25519_dalek::SigningKey::generate(&mut csprng); + let mut certificate_data = vec![]; + certificate_data.extend_from_slice(&short_term_key.verifying_key().to_bytes()); + certificate_data.extend_from_slice(&3600u32.to_be_bytes()); + certificate_data.extend_from_slice(&7200u32.to_be_bytes()); + + let cert_signature = long_term_key.sign(&certificate_data); + let mut certificate = vec![]; + certificate.extend_from_slice(&cert_signature.to_bytes()); + certificate.extend_from_slice(&certificate_data); + + let privkey = Ed25519Private { + certificate, + short_term_key: short_term_key.to_bytes(), + }; + + let pubkey = Ed25519Public { + public_key: long_term_key.verifying_key(), + }; + + let testdata = [0, 1, 2, 3, 4, 5, 6, 7]; + + let mut buffer = [0; 168]; + + assert!(privkey.encrypt(&mut buffer, 0, &testdata).is_ok()); + + assert!(pubkey + .decrypt( + &buffer[0..104], + &buffer[104..168], + &testdata, + NtpTimestamp::from_seconds_nanos_since_ntp_era(1000, 512327) + ) + .is_err()); + assert!(pubkey + .decrypt( + &buffer[0..104], + &buffer[104..168], + &testdata, + NtpTimestamp::from_seconds_nanos_since_ntp_era(6000, 512327) + ) + .is_ok()); + assert!(pubkey + .decrypt( + &buffer[0..104], + &buffer[104..168], + &testdata, + NtpTimestamp::from_seconds_nanos_since_ntp_era(8000, 512327) + ) + .is_err()); + } + #[test] fn test_aes_siv_cmac_256() { let mut testvec: Vec = (0..16).collect(); @@ -395,6 +634,7 @@ mod tests { &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], &[], + NtpTimestamp::default(), ) .unwrap(); assert_eq!(result, (0..16).collect::>()); @@ -413,7 +653,8 @@ mod tests { .decrypt( &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], - &[2] + &[2], + NtpTimestamp::default(), ) .is_err()); let result = key @@ -421,6 +662,7 @@ mod tests { &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], &[1], + NtpTimestamp::default(), ) .unwrap(); assert_eq!(result, (0..16).collect::>()); @@ -440,6 +682,7 @@ mod tests { &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], &[], + NtpTimestamp::default(), ) .unwrap(); assert_eq!(result, (0..16).collect::>()); @@ -458,7 +701,8 @@ mod tests { .decrypt( &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], - &[2] + &[2], + NtpTimestamp::default(), ) .is_err()); let result = key @@ -466,6 +710,7 @@ mod tests { &testvec[..nonce_length], &testvec[nonce_length..(nonce_length + ciphertext_length)], &[1], + NtpTimestamp::default(), ) .unwrap(); assert_eq!(result, (0..16).collect::>()); diff --git a/ntp-proto/src/packet/extension_fields.rs b/ntp-proto/src/packet/extension_fields.rs index 33380327d..f8a5f49b5 100644 --- a/ntp-proto/src/packet/extension_fields.rs +++ b/ntp-proto/src/packet/extension_fields.rs @@ -3,7 +3,7 @@ use std::{ io::{Cursor, Write}, }; -use crate::keyset::DecodedServerCookie; +use crate::{keyset::DecodedServerCookie, NtpTimestamp}; #[cfg(feature = "ntpv5")] use crate::packet::v5::extension_fields::{ReferenceIdRequest, ReferenceIdResponse}; @@ -31,6 +31,8 @@ enum ExtensionFieldTypeId { ReferenceIdRequest, #[cfg(feature = "ntpv5")] ReferenceIdResponse, + Ed25519Request, + Ed25519Response, } impl ExtensionFieldTypeId { @@ -48,6 +50,8 @@ impl ExtensionFieldTypeId { 0xF503 => Self::ReferenceIdRequest, #[cfg(feature = "ntpv5")] 0xF504 => Self::ReferenceIdResponse, + 0xFE00 => Self::Ed25519Request, + 0xFE01 => Self::Ed25519Response, _ => Self::Unknown { type_id }, } } @@ -66,6 +70,8 @@ impl ExtensionFieldTypeId { ExtensionFieldTypeId::ReferenceIdRequest => 0xF503, #[cfg(feature = "ntpv5")] ExtensionFieldTypeId::ReferenceIdResponse => 0xF504, + ExtensionFieldTypeId::Ed25519Request => 0xFE00, + ExtensionFieldTypeId::Ed25519Response => 0xFE01, ExtensionFieldTypeId::Unknown { type_id } => type_id, } } @@ -87,6 +93,9 @@ pub enum ExtensionField<'a> { ReferenceIdRequest(super::v5::extension_fields::ReferenceIdRequest), #[cfg(feature = "ntpv5")] ReferenceIdResponse(super::v5::extension_fields::ReferenceIdResponse<'a>), + Ed25519Request { + placeholder_size: u16, + }, Unknown { type_id: u16, data: Cow<'a, [u8]>, @@ -115,6 +124,10 @@ impl<'a> std::fmt::Debug for ExtensionField<'a> { Self::ReferenceIdRequest(r) => f.debug_tuple("ReferenceIdRequest").field(r).finish(), #[cfg(feature = "ntpv5")] Self::ReferenceIdResponse(r) => f.debug_tuple("ReferenceIdResponse").field(r).finish(), + Self::Ed25519Request { placeholder_size } => f + .debug_struct("Ed25519Request") + .field("placeholder_size", placeholder_size) + .finish(), Self::Unknown { type_id: typeid, data, @@ -158,6 +171,7 @@ impl<'a> ExtensionField<'a> { ReferenceIdRequest(req) => ReferenceIdRequest(req), #[cfg(feature = "ntpv5")] ReferenceIdResponse(res) => ReferenceIdResponse(res.into_owned()), + Ed25519Request { placeholder_size } => Ed25519Request { placeholder_size }, } } @@ -191,6 +205,9 @@ impl<'a> ExtensionField<'a> { ReferenceIdRequest(req) => req.serialize(w), #[cfg(feature = "ntpv5")] ReferenceIdResponse(res) => res.serialize(w), + Ed25519Request { placeholder_size } => { + Self::encode_ed25519_request(w, *placeholder_size, minimum_size, version) + } } } @@ -325,6 +342,27 @@ impl<'a> ExtensionField<'a> { Ok(()) } + fn encode_ed25519_request( + w: &mut W, + placeholder_size: u16, + minimum_size: u16, + version: ExtensionHeaderVersion, + ) -> std::io::Result<()> { + Self::encode_framing( + w, + ExtensionFieldTypeId::Ed25519Request, + placeholder_size as usize, + minimum_size, + version, + )?; + + Self::write_zeros(w, placeholder_size as usize)?; + + Self::encode_padding(w, placeholder_size as usize, minimum_size)?; + + Ok(()) + } + fn encode_unknown( w: &mut W, type_id: u16, @@ -347,10 +385,33 @@ impl<'a> ExtensionField<'a> { Ok(()) } + fn encode_ed25519( + w: &mut Cursor<&mut [u8]>, + cipher: &(impl Cipher + ?Sized), + version: ExtensionHeaderVersion, + ) -> std::io::Result<()> { + let header_start = w.position(); + + Self::encode_framing( + w, + ExtensionFieldTypeId::Ed25519Response, + 168, + 4, + version, + )?; + + let (packet_so_far, cur_extension_field) = w.get_mut().split_at_mut(header_start as usize); + cipher.encrypt(&mut cur_extension_field[4..172], 0, packet_so_far)?; + + Self::encode_padding(w, 168, 4)?; + + Ok(()) + } + fn encode_encrypted( w: &mut Cursor<&mut [u8]>, fields_to_encrypt: &[ExtensionField], - cipher: &dyn Cipher, + cipher: &(impl Cipher + ?Sized), version: ExtensionHeaderVersion, ) -> std::io::Result<()> { let padding = [0; 4]; @@ -597,15 +658,10 @@ impl<'a> ExtensionFieldData<'a> { pub(super) fn serialize( &self, w: &mut Cursor<&mut [u8]>, - cipher: &(impl CipherProvider + ?Sized), + cipher: &(impl Cipher + ?Sized), version: ExtensionHeaderVersion, ) -> std::io::Result<()> { if !self.authenticated.is_empty() || !self.encrypted.is_empty() { - let cipher = match cipher.get(CipherType::Nts, &self.authenticated) { - Some(cipher) => cipher, - None => return Err(std::io::Error::new(std::io::ErrorKind::Other, "no cipher")), - }; - // the authenticated extension fields are always followed by the encrypted extension // field. We don't (currently) encode a MAC, so the minimum size per RFC 7822 is 16 octecs let minimum_size = 16; @@ -613,11 +669,20 @@ impl<'a> ExtensionFieldData<'a> { for field in &self.authenticated { field.serialize(w, minimum_size, version)?; } - - // RFC 8915, section 5.5: contrary to the RFC 7822 requirement that fields have a minimum length of 16 or 28 octets, - // encrypted extension fields MAY be arbitrarily short (but still MUST be a multiple of 4 octets in length) - // hence we don't provide a minimum size here - ExtensionField::encode_encrypted(w, &self.encrypted, cipher.as_ref(), version)?; + match cipher.etype() { + CipherType::None => { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "no cipher")) + } + CipherType::Nts => { + ExtensionField::encode_encrypted(w, &self.encrypted, cipher, version)?; + } + CipherType::Ed25519 => { + if self.encrypted.len() != 0 { + return Err(std::io::Error::new(std::io::ErrorKind::Other, "encrypted extensions not support for Ed25519 signing")); + } + ExtensionField::encode_ed25519(w, cipher, version)?; + } + } } // per RFC 7822, section 7.5.1.4. @@ -642,6 +707,7 @@ impl<'a> ExtensionFieldData<'a> { header_size: usize, cipher: &impl CipherProvider, version: ExtensionHeaderVersion, + transmit_timestamp: NtpTimestamp, ) -> Result, ParsingError>> { use ExtensionField::InvalidNtsEncryptedField; @@ -664,6 +730,40 @@ impl<'a> ExtensionFieldData<'a> { let (offset, field) = field.map_err(|e| e.generalize())?; size = offset + field.wire_length(version); match field.type_id { + ExtensionFieldTypeId::Ed25519Response => { + let cipher = match cipher.get(CipherType::Ed25519, &efdata.untrusted) { + Some(cipher) => cipher, + None => { + // Ignore ed25519 signatures if we aren't configured to verify them + continue; + } + }; + + let Some(nonce) = field.message_bytes.get(0..104) else { + is_valid_nts = false; + continue; + }; + let Some(ciphertext) = field.message_bytes.get(104..168) else { + is_valid_nts = false; + continue; + }; + + if cipher + .as_ref() + .decrypt( + nonce, + ciphertext, + &data[..header_size + offset], + transmit_timestamp, + ) + .is_err() + { + is_valid_nts = false; + continue; + } + + efdata.authenticated.append(&mut efdata.untrusted); + } ExtensionFieldTypeId::NtsEncryptedField => { let encrypted = RawEncryptedField::from_message_bytes(field.message_bytes) .map_err(|e| e.generalize())?; @@ -681,6 +781,7 @@ impl<'a> ExtensionFieldData<'a> { cipher.as_ref(), &data[..header_size + offset], version, + transmit_timestamp, ) { Ok(encrypted_fields) => encrypted_fields, Err(e) => { @@ -770,8 +871,9 @@ impl<'a> RawEncryptedField<'a> { cipher: &dyn Cipher, aad: &[u8], version: ExtensionHeaderVersion, + transmit_timestamp: NtpTimestamp, ) -> Result>, ParsingError>> { - let plaintext = match cipher.decrypt(self.nonce, self.ciphertext, aad) { + let plaintext = match cipher.decrypt(self.nonce, self.ciphertext, aad, transmit_timestamp) { Ok(plain) => plain, Err(_) => { return Err(ParsingError::DecryptError( @@ -1248,7 +1350,12 @@ mod tests { ) => { let raw = RawEncryptedField::from_message_bytes(message_bytes).unwrap(); let decrypted_fields = raw - .decrypt(&cipher, &[], ExtensionHeaderVersion::V4) + .decrypt( + &cipher, + &[], + ExtensionHeaderVersion::V4, + NtpTimestamp::default(), + ) .unwrap(); assert_eq!(decrypted_fields, fields_to_encrypt); } @@ -1367,8 +1474,14 @@ mod tests { let cipher = crate::packet::crypto::NoCipher; - let result = ExtensionFieldData::deserialize(slice, 0, &cipher, ExtensionHeaderVersion::V4) - .unwrap_err(); + let result = ExtensionFieldData::deserialize( + slice, + 0, + &cipher, + ExtensionHeaderVersion::V4, + NtpTimestamp::default(), + ) + .unwrap_err(); let ParsingError::DecryptError(InvalidNtsExtensionField { efdata, @@ -1411,8 +1524,14 @@ mod tests { let c2s = [0; 32]; let cipher = AesSivCmac256::new(c2s.into()); - let result = ExtensionFieldData::deserialize(slice, 0, &cipher, ExtensionHeaderVersion::V4) - .unwrap_err(); + let result = ExtensionFieldData::deserialize( + slice, + 0, + &cipher, + ExtensionHeaderVersion::V4, + NtpTimestamp::default(), + ) + .unwrap_err(); let ParsingError::DecryptError(InvalidNtsExtensionField { efdata, @@ -1447,14 +1566,20 @@ mod tests { let mut w = [0u8; 256]; let mut cursor = Cursor::new(w.as_mut_slice()); - data.serialize(&mut cursor, &keyset, ExtensionHeaderVersion::V4) + data.serialize(&mut cursor, decoded_server_cookie.c2s.as_ref(), ExtensionHeaderVersion::V4) .unwrap(); let n = cursor.position() as usize; let slice = &w.as_slice()[..n]; - let result = - ExtensionFieldData::deserialize(slice, 0, &keyset, ExtensionHeaderVersion::V4).unwrap(); + let result = ExtensionFieldData::deserialize( + slice, + 0, + &keyset, + ExtensionHeaderVersion::V4, + NtpTimestamp::default(), + ) + .unwrap(); let DeserializedExtensionField { efdata, diff --git a/ntp-proto/src/packet/mod.rs b/ntp-proto/src/packet/mod.rs index 9271ac875..97c94c447 100644 --- a/ntp-proto/src/packet/mod.rs +++ b/ntp-proto/src/packet/mod.rs @@ -23,7 +23,7 @@ pub mod v5; pub use crypto::{ AesSivCmac256, AesSivCmac512, Cipher, CipherHolder, CipherProvider, CipherType, DecryptError, - EncryptResult, NoCipher, + Ed25519Private, Ed25519Public, EncryptResult, NoCipher, }; pub use error::PacketParsingError; pub use extension_fields::{ExtensionField, ExtensionHeaderVersion}; @@ -346,6 +346,7 @@ impl<'a> NtpPacket<'a> { header_size, cipher, ExtensionHeaderVersion::V4, + header.transmit_timestamp, ) { Ok(decoded) => { let packet = construct_packet(decoded.remaining_bytes, decoded.efdata) @@ -446,7 +447,7 @@ impl<'a> NtpPacket<'a> { pub fn serialize( &self, w: &mut Cursor<&mut [u8]>, - cipher: &(impl CipherProvider + ?Sized), + cipher: &(impl Cipher + ?Sized), #[cfg_attr(not(feature = "ntpv5"), allow(unused_variables))] desired_size: Option, ) -> std::io::Result<()> { #[cfg(feature = "ntpv5")] @@ -620,6 +621,19 @@ impl<'a> NtpPacket<'a> { ) } + pub fn extend_with_ed25519_request(&mut self, req_id: &mut RequestIdentifier) { + if req_id.uid.is_none() { + let identifier: [u8; 32] = rand::thread_rng().gen(); + self.efdata + .untrusted + .push(ExtensionField::UniqueIdentifier(identifier.to_vec().into())); + req_id.uid = Some(identifier); + } + self.efdata.untrusted.push(ExtensionField::Ed25519Request { + placeholder_size: 168, + }); + } + #[cfg_attr(not(feature = "ntpv5"), allow(unused_mut))] pub fn timestamp_response( system: &SystemSnapshot, diff --git a/ntp-proto/src/peer.rs b/ntp-proto/src/peer.rs index fcdec565e..8a86db004 100644 --- a/ntp-proto/src/peer.rs +++ b/ntp-proto/src/peer.rs @@ -7,9 +7,11 @@ use crate::{ config::SourceDefaultsConfig, cookiestash::CookieStash, identifiers::ReferenceId, - packet::{Cipher, NtpAssociationMode, NtpLeapIndicator, NtpPacket, RequestIdentifier}, + packet::{ + Cipher, Ed25519Public, NtpAssociationMode, NtpLeapIndicator, NtpPacket, RequestIdentifier, + }, system::SystemSnapshot, - time_types::{NtpDuration, NtpInstant, NtpTimestamp, PollInterval}, + time_types::{NtpDuration, NtpInstant, NtpTimestamp, PollInterval}, NoCipher, }; use serde::{Deserialize, Serialize}; use std::{io::Cursor, net::SocketAddr}; @@ -57,6 +59,8 @@ impl std::fmt::Debug for PeerNtsData { pub struct Peer { nts: Option>, + ed25519_pk: Option, + // Poll interval dictated by unreachability backoff backoff_interval: PollInterval, // Poll interval used when sending last poll mesage. @@ -372,6 +376,7 @@ impl Peer { ) -> Self { Self { nts: None, + ed25519_pk: None, last_poll_interval: peer_defaults_config.poll_interval_limits.min, backoff_interval: peer_defaults_config.poll_interval_limits.min, @@ -417,6 +422,27 @@ impl Peer { } } + #[instrument] + pub fn new_ed25519( + our_addr: SocketAddr, + source_addr: SocketAddr, + local_clock_time: NtpInstant, + peer_defaults_config: SourceDefaultsConfig, + protocol_version: ProtocolVersion, + ed25519_pk: Ed25519Public, + ) -> Self { + Self { + ed25519_pk: Some(ed25519_pk), + ..Self::new( + our_addr, + source_addr, + local_clock_time, + peer_defaults_config, + protocol_version, + ) + } + } + pub fn update_config(&mut self, peer_defaults_config: SourceDefaultsConfig) { self.peer_defaults_config = peer_defaults_config; } @@ -444,7 +470,7 @@ impl Peer { self.tries = self.tries.saturating_add(1); let poll_interval = self.current_poll_interval(system); - let (mut packet, identifier) = match &mut self.nts { + let (mut packet, mut identifier) = match &mut self.nts { Some(nts) => { let cookie = nts.cookies.get().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::Other, NtsError::OutOfCookies) @@ -477,6 +503,10 @@ impl Peer { ProtocolVersion::V5 => NtpPacket::poll_message_v5(poll_interval), }, }; + + if self.ed25519_pk.is_some() { + packet.extend_with_ed25519_request(&mut identifier); + } self.current_request_identifier = Some((identifier, NtpInstant::now() + POLL_WINDOW)); // Ensure we don't spam the remote with polls if it is not reachable @@ -490,11 +520,19 @@ impl Peer { // Write packet to buffer let mut cursor = Cursor::new(buf); - packet.serialize( - &mut cursor, - &self.nts.as_ref().map(|nts| nts.c2s.as_ref()), - None, - )?; + if let Some(nts) = self.nts.as_ref() { + packet.serialize( + &mut cursor, + nts.c2s.as_ref(), + None, + )?; + } else { + packet.serialize( + &mut cursor, + &NoCipher, + None, + )?; + } let used = cursor.position(); let result = &cursor.into_inner()[..used as usize]; @@ -553,7 +591,10 @@ impl Peer { } } - if !message.valid_server_response(request_identifier, self.nts.is_some()) { + if !message.valid_server_response( + request_identifier, + self.nts.is_some() || self.ed25519_pk.is_some(), + ) { // Packets should be a response to a previous request from us, // if not just ignore. Note that this might also happen when // we reset between sending the request and receiving the response. @@ -701,6 +742,7 @@ impl Peer { Peer { nts: None, + ed25519_pk: None, last_poll_interval: PollInterval::default(), backoff_interval: PollInterval::default(),