diff --git a/Cargo.lock b/Cargo.lock index 8226b6a71b..8661a0b425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -358,6 +367,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.38" @@ -368,6 +388,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.13" @@ -1411,6 +1441,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -2340,6 +2380,7 @@ dependencies = [ "bytes", "cached", "cfg-if", + "chacha20", "clap", "crossbeam-utils", "dashmap", @@ -2421,6 +2462,38 @@ dependencies = [ "syn 2.0.72", ] +[[package]] +name = "quilkin-profiling" +version = "0.10.0-dev" +dependencies = [ + "arc-swap", + "async-stream", + "cached", + "enum-map", + "eyre", + "fixedstr", + "futures", + "once_cell", + "parking_lot", + "prometheus", + "prost", + "prost-types", + "quilkin-proto", + "rand", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "tracing-futures", + "tryhard", + "url", + "uuid", +] + [[package]] name = "quilkin-proto" version = "0.10.0-dev" diff --git a/Cargo.toml b/Cargo.toml index c3b0b35032..e3b8795a7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,7 @@ cfg-if = "1.0.0" libflate = "2.0.0" form_urlencoded = "1.2.1" gxhash = "3.4.1" +chacha20 = { version = "0.9.1", features = ["std"] } [dependencies.hyper-util] version = "0.1" diff --git a/crates/proto-gen/gen.rs b/crates/proto-gen/gen.rs index babcd059ef..2b2672421d 100644 --- a/crates/proto-gen/gen.rs +++ b/crates/proto-gen/gen.rs @@ -202,6 +202,7 @@ fn execute(which: &str) { "filters/capture/v1alpha1/capture", "filters/compress/v1alpha1/compress", "filters/concatenate/v1alpha1/concatenate", + "filters/decryptor/v1alpha1/decryptor", "filters/debug/v1alpha1/debug", "filters/drop/v1alpha1/drop", "filters/firewall/v1alpha1/firewall", diff --git a/crates/quilkin-proto/src/generated/quilkin/filters.rs b/crates/quilkin-proto/src/generated/quilkin/filters.rs index dda35599f9..dfe2d9b708 100644 --- a/crates/quilkin-proto/src/generated/quilkin/filters.rs +++ b/crates/quilkin-proto/src/generated/quilkin/filters.rs @@ -2,6 +2,7 @@ pub mod capture; pub mod compress; pub mod concatenate; pub mod debug; +pub mod decryptor; pub mod drop; pub mod firewall; pub mod load_balancer; diff --git a/crates/quilkin-proto/src/generated/quilkin/filters/decryptor.rs b/crates/quilkin-proto/src/generated/quilkin/filters/decryptor.rs new file mode 100644 index 0000000000..32a5a9d4fd --- /dev/null +++ b/crates/quilkin-proto/src/generated/quilkin/filters/decryptor.rs @@ -0,0 +1 @@ +pub mod v1alpha1; diff --git a/crates/quilkin-proto/src/generated/quilkin/filters/decryptor/v1alpha1.rs b/crates/quilkin-proto/src/generated/quilkin/filters/decryptor/v1alpha1.rs new file mode 100644 index 0000000000..161ce7206b --- /dev/null +++ b/crates/quilkin-proto/src/generated/quilkin/filters/decryptor/v1alpha1.rs @@ -0,0 +1,38 @@ +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Decryptor { + #[prost(bytes = "vec", tag = "1")] + pub key: ::prost::alloc::vec::Vec, + #[prost(enumeration = "decryptor::Mode", tag = "2")] + pub mode: i32, + #[prost(message, optional, tag = "3")] + pub data_key: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "4")] + pub nonce_key: ::core::option::Option<::prost::alloc::string::String>, +} +/// Nested message and enum types in `Decryptor`. +pub mod decryptor { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum Mode { + Destination = 0, + } + impl Mode { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Mode::Destination => "Destination", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "Destination" => Some(Self::Destination), + _ => None, + } + } + } +} diff --git a/docs/src/services/xds/proto/index.md b/docs/src/services/xds/proto/index.md index 31639fb849..735a391d59 100644 --- a/docs/src/services/xds/proto/index.md +++ b/docs/src/services/xds/proto/index.md @@ -37,6 +37,11 @@ - [Concatenate.Strategy](#quilkin-filters-concatenate-v1alpha1-Concatenate-Strategy) +- [quilkin/filters/decryptor/v1alpha1/decryptor.proto](#quilkin_filters_decryptor_v1alpha1_decryptor-proto) + - [Decryptor](#quilkin-filters-decryptor-v1alpha1-Decryptor) + + - [Decryptor.Mode](#quilkin-filters-decryptor-v1alpha1-Decryptor-Mode) + - [quilkin/filters/debug/v1alpha1/debug.proto](#quilkin_filters_debug_v1alpha1_debug-proto) - [Debug](#quilkin-filters-debug-v1alpha1-Debug) @@ -507,6 +512,51 @@ of xDS servers to connect to in the relay itself. + +

Top

+ +## quilkin/filters/decryptor/v1alpha1/decryptor.proto + + + + + +### Decryptor + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [bytes](#bytes) | | | +| mode | [Decryptor.Mode](#quilkin-filters-decryptor-v1alpha1-Decryptor-Mode) | | | +| data_key | [google.protobuf.StringValue](#google-protobuf-StringValue) | | | +| nonce_key | [google.protobuf.StringValue](#google-protobuf-StringValue) | | | + + + + + + + + + + +### Decryptor.Mode + + +| Name | Number | Description | +| ---- | ------ | ----------- | +| Destination | 0 | | + + + + + + + + + +

Top

diff --git a/proto/quilkin/filters/decryptor/v1alpha1/decryptor.proto b/proto/quilkin/filters/decryptor/v1alpha1/decryptor.proto new file mode 100644 index 0000000000..b8dae62093 --- /dev/null +++ b/proto/quilkin/filters/decryptor/v1alpha1/decryptor.proto @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto3"; + +package quilkin.filters.decryptor.v1alpha1; + +import "google/protobuf/wrappers.proto"; + +message Decryptor { + enum Mode { + Destination = 0; + } + + bytes key = 1; + Mode mode = 2; + google.protobuf.StringValue data_key = 3; + google.protobuf.StringValue nonce_key = 4; +} diff --git a/src/filters.rs b/src/filters.rs index c3971a5777..057ce8796c 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -28,6 +28,7 @@ pub mod capture; pub mod compress; pub mod concatenate; pub mod debug; +pub mod decryptor; pub mod drop; pub mod firewall; pub mod load_balancer; diff --git a/src/filters/decryptor.rs b/src/filters/decryptor.rs new file mode 100644 index 0000000000..fd57204931 --- /dev/null +++ b/src/filters/decryptor.rs @@ -0,0 +1,261 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use chacha20::cipher::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + filters::{capture::CAPTURED_BYTES, prelude::*}, + net::endpoint::metadata, +}; + +use quilkin_xds::generated::quilkin::filters::decryptor::v1alpha1 as proto; + +/// The default key under which the [`Decryptor`] filter reads the nonce. +/// - **Type** `Vec` +pub const NONCE_KEY: &str = "quilkin.dev/nonce"; + +/// Filter that only allows packets to be passed to Endpoints that have a matching +/// connection_id to the token stored in the Filter's dynamic metadata. +pub struct Decryptor { + config: Config, +} + +impl Decryptor { + fn decode_chacha20(&self, nonce: [u8; 12], data: &mut [u8]) { + let mut cipher = chacha20::ChaCha20::new(&self.config.key.into(), &nonce.into()); + cipher.apply_keystream(data); + } + + fn apply_mode(&self, data: &[u8], ctx: &mut ReadContext) -> Result<(), FilterError> { + match self.config.mode { + Mode::Destination => match data.len() { + 6 => { + let ip: [u8; 4] = data[..4].try_into().unwrap(); + let port = u16::from_be_bytes(<[u8; 2]>::try_from(&data[4..]).unwrap()); + + ctx.destinations = vec![(ip, port).into()]; + Ok(()) + } + 18 => todo!(), + _ => Err(FilterError::Custom( + "Invalid decoded data length, must be `6` or `8` bytes.", + )), + }, + } + } +} + +impl StaticFilter for Decryptor { + const NAME: &'static str = "quilkin.filters.token_router.v1alpha1.Decryptor"; + type Configuration = Config; + type BinaryConfiguration = proto::Decryptor; + + fn try_from_config(config: Option) -> Result { + Ok(Self { + config: Self::ensure_config_exists(config)?, + }) + } +} + +impl Filter for Decryptor { + fn read(&self, ctx: &mut ReadContext) -> Result<(), FilterError> { + match ( + ctx.metadata.get(&self.config.data_key), + ctx.metadata.get(&self.config.nonce_key), + ) { + (Some(metadata::Value::Bytes(data)), Some(metadata::Value::Bytes(nonce))) => { + let nonce = <[u8; 12]>::try_from(&**nonce) + .map_err(|_| FilterError::Custom("Expected 12 byte nonce"))?; + let mut data = Vec::from(&**data); + + self.decode_chacha20(nonce, &mut data); + self.apply_mode(&data, ctx) + } + (Some(metadata::Value::Bytes(_)), Some(_)) => { + Err(FilterError::Custom("expected `bytes` value in nonce key")) + } + (Some(_), Some(metadata::Value::Bytes(_))) => { + Err(FilterError::Custom("expected `bytes` value in data key")) + } + (Some(_), Some(_)) => Err(FilterError::Custom( + "expected `bytes` value in data and nonce key", + )), + (Some(_), None) => Err(FilterError::Custom("Nonce key is missing")), + (None, Some(_)) => Err(FilterError::Custom("Data key is missing")), + (None, None) => Err(FilterError::Custom("Nonce and data key is missing")), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, schemars::JsonSchema)] +pub struct Config { + #[serde(deserialize_with = "deserialize", serialize_with = "serialize")] + #[schemars(with = "String")] + pub key: [u8; 32], + /// the key to use when retrieving the data from the Filter's dynamic metadata + #[serde(rename = "metadataKey", default = "default_data_key")] + pub data_key: metadata::Key, + #[serde(rename = "metadataKey", default = "default_nonce_key")] + pub nonce_key: metadata::Key, + pub mode: Mode, +} + +/// Default value for [`Config::data_key`] +fn default_data_key() -> metadata::Key { + metadata::Key::from_static(CAPTURED_BYTES) +} + +/// Default value for [`Config::nonce_key`] +fn default_nonce_key() -> metadata::Key { + metadata::Key::from_static(NONCE_KEY) +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, schemars::JsonSchema)] +pub enum Mode { + /// Value is expected to be a IP:port pair to be used for setting the destination. + Destination, +} + +impl From for proto::decryptor::Mode { + fn from(mode: Mode) -> Self { + match mode { + Mode::Destination => Self::Destination, + } + } +} + +impl From for Mode { + fn from(mode: proto::decryptor::Mode) -> Self { + match mode { + proto::decryptor::Mode::Destination => Self::Destination, + } + } +} + +impl TryFrom for Mode { + type Error = ConvertProtoConfigError; + fn try_from(mode: i32) -> Result { + match mode { + 0 => Ok(Self::Destination), + _ => Err(ConvertProtoConfigError::missing_field("mode")), + } + } +} + +fn deserialize<'de, D>(de: D) -> Result<[u8; 32], D::Error> +where + D: serde::Deserializer<'de>, +{ + let string = String::deserialize(de)?; + + crate::codec::base64::decode(string) + .map_err(serde::de::Error::custom)? + .try_into() + .map_err(|_| serde::de::Error::custom("invalid key, expected 32 bytes")) +} + +fn serialize(value: &[u8; 32], ser: S) -> Result +where + S: serde::Serializer, +{ + crate::codec::base64::encode(value).serialize(ser) +} + +impl From for proto::Decryptor { + fn from(config: Config) -> Self { + Self { + key: config.key.into(), + mode: proto::decryptor::Mode::from(config.mode).into(), + data_key: Some(config.data_key.to_string()), + nonce_key: Some(config.nonce_key.to_string()), + } + } +} + +impl TryFrom for Config { + type Error = ConvertProtoConfigError; + + fn try_from(p: proto::Decryptor) -> Result { + Ok(Self { + key: p.key.try_into().map_err(|_| { + ConvertProtoConfigError::new( + "invalid key, expected 32 bytes", + Some("private_key".into()), + ) + })?, + mode: p.mode.try_into()?, + data_key: p + .data_key + .map(metadata::Key::new) + .unwrap_or_else(default_data_key), + nonce_key: p + .nonce_key + .map(metadata::Key::new) + .unwrap_or_else(default_nonce_key), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipv4() { + let pool = std::sync::Arc::new(crate::pool::BufferPool::new(1, 5)); + + let endpoints = crate::net::cluster::ClusterMap::default(); + let mut ctx = ReadContext::new( + endpoints.into(), + "0.0.0.0:0".parse().unwrap(), + pool.alloc_slice(b"hello"), + ); + + let key = [0x42u8; 32]; + let nonce = [0x22u8; 12]; + let ip: [u8; 4] = [127, 0, 0, 1]; + let port: [u8; 2] = 8080u16.to_be_bytes(); + + let mut data = Vec::new(); + data.extend(ip); + data.extend(port); + + let mut cipher = chacha20::ChaCha20::new(&key.into(), &nonce.into()); + cipher.apply_keystream(&mut data); + + ctx.metadata.insert( + NONCE_KEY.into(), + bytes::Bytes::from(Vec::from(nonce)).into(), + ); + ctx.metadata + .insert(CAPTURED_BYTES.into(), bytes::Bytes::from(data).into()); + + let config = Config { + data_key: CAPTURED_BYTES.into(), + nonce_key: NONCE_KEY.into(), + key, + mode: Mode::Destination, + }; + let filter = Decryptor::from_config(config.into()); + + filter.read(&mut ctx).unwrap(); + assert_eq!( + std::net::SocketAddr::from(([127u8, 0, 0, 1], 8080u16)), + ctx.destinations[0].to_socket_addr().unwrap() + ); + } +}