diff --git a/Cargo.lock b/Cargo.lock index 86ec307..04102f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -481,6 +481,7 @@ dependencies = [ "digest", "fiat-crypto", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1947,13 +1948,17 @@ dependencies = [ "clap", "dropshot", "ed25519-dalek", + "futures", "hex", + "hkdf", "http", "instrumentation", "rand", "schemars", "serde", + "serde_json", "serde_with", + "sha2", "slog", "slog-async", "sqlx", @@ -1962,6 +1967,7 @@ dependencies = [ "tracing", "tracing-slog", "uuid", + "x25519-dalek", ] [[package]] @@ -2638,9 +2644,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ "itoa", "memchr", @@ -4167,6 +4173,18 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "xtask" version = "0.2.0" @@ -4248,6 +4266,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerovec" diff --git a/Cargo.toml b/Cargo.toml index fdf3949..ea7bb55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ dropshot = "0.15.1" ed25519-dalek = { version = "2.1.1", features = ["pem", "rand_core"] } futures = "0.3" hex = "0.4.3" +hkdf = "0.12.4" http = "1.2.0" once_cell = "1.20.2" progenitor = "0.9" @@ -44,8 +45,10 @@ reqwest = { version = "0.12.12", features = ["json", "stream", "rustls-tls"] } schemars = { version = "0.8.21", features = ["bytes", "chrono", "derive"] } semver = "1.0.24" serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.138" serde_with = { version = "3.12.0", features = ["base64", "hex", "macros", "std"] } # serde_with_macros = { version = "3.12.0", features = ["schemars_0_8"] } +sha2 = "0.10.8" slog = "2.7.0" slog-async = "2.8.0" tokio = { version = "1.43.0", features = ["full"] } @@ -54,3 +57,4 @@ tracing-core = "0.1.33" tracing-chrome = "0.7.2" tracing-slog = { git = "https://github.com/oxidecomputer/tracing-slog", default-features = false } uuid = { version = "1", features = [ "serde", "v4" ] } +x25519-dalek = { version = "2.0.1", features = ["serde", "static_secrets"] } diff --git a/api.json b/api.json index ae26182..d7c3f25 100644 --- a/api.json +++ b/api.json @@ -12,6 +12,16 @@ ], "summary": "Get registration info", "operationId": "get_registration_info", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "successful operation", @@ -171,26 +181,26 @@ "RegistrationInfo": { "type": "object", "properties": { - "challenge": { - "type": "string" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, "node_id": { "type": "string", "format": "uuid" }, - "public_key": { + "nonce": { "type": "string" + }, + "server_dh_pub": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } }, "required": [ - "challenge", - "expiration", "node_id", - "public_key" + "nonce", + "server_dh_pub", + "timestamp" ] }, "User": { diff --git a/controller/Cargo.toml b/controller/Cargo.toml index 6b2f178..f51b59a 100644 --- a/controller/Cargo.toml +++ b/controller/Cargo.toml @@ -11,13 +11,17 @@ chrono.workspace = true clap.workspace = true dropshot.workspace = true ed25519-dalek.workspace = true +futures.workspace = true hex.workspace = true +hkdf.workspace = true http.workspace = true instrumentation = { path = "../instrumentation" } rand.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true serde_with.workspace = true +sha2.workspace = true slog-async.workspace = true slog.workspace = true sqlx = { version = "0.8.3", default-features = false, features = [ @@ -28,6 +32,7 @@ trace-request = { path = "../trace-request" } tracing-slog.workspace = true tracing.workspace = true uuid.workspace = true +x25519-dalek.workspace = true [package.metadata.cargo-machete] ignored = ["http"] diff --git a/controller/src/context.rs b/controller/src/context.rs index b99d559..3d4d4e1 100644 --- a/controller/src/context.rs +++ b/controller/src/context.rs @@ -1,11 +1,20 @@ use sqlx::postgres::PgPool; +use crate::node::NodeController; + pub struct ControllerContext { pub pg_pool: PgPool, + // pub node_controller: Mutex<NodeController>, + pub node_controller: NodeController, } impl ControllerContext { pub fn new(pg_pool: PgPool) -> ControllerContext { - ControllerContext { pg_pool } + // let node_controller = Mutex::new(NodeController::new(pg_pool.to_owned())); + let node_controller = NodeController::new(pg_pool.to_owned()); + ControllerContext { + pg_pool, + node_controller, + } } } diff --git a/controller/src/node/api.rs b/controller/src/node/api.rs index cf51771..63517a0 100644 --- a/controller/src/node/api.rs +++ b/controller/src/node/api.rs @@ -1,45 +1,28 @@ -use chrono::DateTime; -use dropshot::{endpoint, HttpError, HttpResponseOk, RequestContext}; +use dropshot::{endpoint, HttpError, HttpResponseOk, Query, RequestContext}; use dropshot::{ApiDescription, ApiDescriptionRegisterError}; -use rand::RngCore; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_with::base64::Base64; -use serde_with::base64::Crypt; use serde_with::serde_as; use trace_request::trace_request; -use uuid::Uuid; +use x25519_dalek::PublicKey; use std::sync::Arc; use crate::context::ControllerContext; +use crate::node::controller::RegistrationInfo; + +#[serde_as] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +pub struct RegistrationParams { + #[serde_as(as = "serde_with::base64::Base64<serde_with::base64::UrlSafe>")] + #[schemars(with = "String")] + key: [u8; 32], +} pub fn register_api( api: &mut ApiDescription<Arc<ControllerContext>>, ) -> Result<(), ApiDescriptionRegisterError> { api.register(get_registration_info) - // api.register(register_node) -} - -#[serde_as] -#[derive(Deserialize, JsonSchema, Serialize)] -struct RegistrationInfo { - node_id: Uuid, - expiration: DateTime<chrono::Utc>, - - // #[serde_as(as = "Base64<serde_with::base64::BinHex, serde_with::formats::Unpadded>")] - // #[serde_as(as = "Base64<serde_with::base64::UrlSafe, serde_with::formats::Unpadded>")] - #[serde_as(as = "serde_with::base64::Base64")] - // #[serde_as(as = "serde_with::hex::Hex<serde_with::formats::Uppercase>")] - #[schemars(with = "String")] - challenge: [u8; 60], - - // #[serde_as(as = "serde_with::base64::Base64")] - #[serde_as(as = "serde_with::hex::Hex")] - // #[serde_as(as = "Base64<serde_with::base64::UrlSafe, serde_with::formats::Unpadded>")] - // #[serde_as(as = "serde_with::hex::Hex<serde_with::formats::Uppercase>")] - #[schemars(with = "String")] - public_key: [u8; 32], } /// Get registration info @@ -51,20 +34,10 @@ struct RegistrationInfo { #[trace_request] async fn get_registration_info( rqctx: RequestContext<Arc<ControllerContext>>, + params: Query<RegistrationParams>, ) -> Result<HttpResponseOk<RegistrationInfo>, HttpError> { - tracing::debug!("Registration info"); - - let rng = &mut rand::rngs::OsRng; - let mut challenge = [0u8; 60]; - rng.fill_bytes(&mut challenge); - let key = ed25519_dalek::SigningKey::generate(rng); - - let info = RegistrationInfo { - node_id: Uuid::new_v4(), - expiration: chrono::Utc::now(), - challenge, - public_key: key.verifying_key().to_bytes(), - }; - + let key = PublicKey::from(params.into_inner().key); + tracing::debug!("Registration info for public key: {:?}", key); + let info = rqctx.context().node_controller.new_registration(&key); Ok(HttpResponseOk(info)) } diff --git a/controller/src/node/controller.rs b/controller/src/node/controller.rs new file mode 100644 index 0000000..1698dc7 --- /dev/null +++ b/controller/src/node/controller.rs @@ -0,0 +1,179 @@ +use chrono::DateTime; +use ed25519_dalek::ed25519::signature::SignerMut; +use hkdf::Hkdf; +use rand::Rng; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use sha2::Sha256; +use sqlx::postgres::PgPool; +use uuid::Uuid; +use x25519_dalek::{PublicKey, StaticSecret}; + +use std::sync::{Arc, RwLock}; + +#[serde_as] +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct RegistrationInfo { + node_id: Uuid, + challenge: Challenge, + + #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] + challenge_signature: [u8; 64], +} + +#[serde_as] +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct Challenge { + timestamp: DateTime<chrono::Utc>, + + #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] + nonce: [u8; 32], + + #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] + server_dh_pub: [u8; 32], + + params: ArgonParams, +} + +#[serde_as] +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +struct ArgonParams { + iterations: u32, + memory_kb: u32, + threads: u32, +} + +pub struct NodeController { + _pg_pool: PgPool, + signing_key: RwLock<Option<Arc<SecretKey>>>, +} + +type SecretKey = [u8; 32]; +type Nonce256 = [u8; 32]; + +trait SecretKeyExt { + fn derive_dh_secret(&self, nonce: &Nonce256) -> StaticSecret; +} + +impl SecretKeyExt for SecretKey { + fn derive_dh_secret(&self, nonce: &Nonce256) -> StaticSecret { + let hk = Hkdf::<Sha256>::new(Some(nonce), self); + let mut out = [0u8; 32]; + hk.expand(b"x25519-secret", &mut out).unwrap(); + StaticSecret::from(out) + } +} + +impl RegistrationInfo { + pub fn from(server_secret: &SecretKey, client_dh_public_key: &PublicKey) -> RegistrationInfo { + let mut rng = rand::thread_rng(); + let nonce = rng.gen(); + + let server_dh_secret = server_secret.derive_dh_secret(&nonce); + let _shared_secret = server_dh_secret.diffie_hellman(client_dh_public_key); + let server_dh_public_key = PublicKey::from(&server_dh_secret); + + let timestamp = chrono::Utc::now(); + + let params = ArgonParams { + iterations: 10, + memory_kb: 220 * 1024, // 220 MiB + threads: 4, + }; + + let challenge = Challenge { + timestamp, + nonce, + server_dh_pub: server_dh_public_key.to_bytes(), + params, + }; + + let challenge_json = serde_json::to_vec(&challenge).unwrap(); + + let mut sk = ed25519_dalek::SigningKey::from_bytes(server_secret); + let sig = sk.sign(challenge_json.as_slice()); + + let info = RegistrationInfo { + node_id: Uuid::new_v4(), + challenge, + challenge_signature: sig.to_bytes(), + }; + + tracing::debug!("Info: {:?}", info); + info + } +} + +impl NodeController { + pub fn new(pg_pool: PgPool) -> NodeController { + NodeController { + _pg_pool: pg_pool, + signing_key: RwLock::new(None), + } + } + + pub fn new_registration(&self, client_dh_public_key: &PublicKey) -> RegistrationInfo { + let server_secret = self.master_key(); + RegistrationInfo::from(&server_secret, client_dh_public_key) + } + + fn master_key(&self) -> Arc<SecretKey> { + self.signing_key + .write() + .unwrap() + .get_or_insert_with(|| { + tracing::debug!("Generating key"); + Arc::new(rand::rngs::OsRng.gen()) + }) + .clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_registration_info() { + let client_key: SecretKey = rand::rngs::OsRng.gen(); + let server_key = rand::rngs::OsRng.gen(); + let client_public_key = PublicKey::from(client_key); + + let info = RegistrationInfo::from(&server_key, &client_public_key); + let info2 = RegistrationInfo::from(&server_key, &client_public_key); + + // Randomness + assert_ne!(info.node_id, info2.node_id, "Node ID should be unique"); + assert_ne!( + info.challenge.nonce, info2.challenge.nonce, + "Nonce should be unique" + ); + assert_ne!( + info.challenge.server_dh_pub, info2.challenge.server_dh_pub, + "Server public key derived from nonce should be unique" + ); + + // Timestamp + let now = chrono::Utc::now(); + assert!( + info.challenge.timestamp <= now, + "Timestamp is in the future" + ); + assert!( + info.challenge.timestamp >= now - chrono::Duration::milliseconds(5), + "Timestamp is too old" + ); + + // Public key + let server_dh_secret = server_key.derive_dh_secret(&info.challenge.nonce); + let server_dh_public_key = PublicKey::from(&server_dh_secret).to_bytes(); + assert_eq!( + info.challenge.server_dh_pub, server_dh_public_key, + "Server public key should be derived from nonce" + ); + } +} diff --git a/controller/src/node/mod.rs b/controller/src/node/mod.rs index f35db4c..c67ce0c 100644 --- a/controller/src/node/mod.rs +++ b/controller/src/node/mod.rs @@ -1,3 +1,5 @@ mod api; +mod controller; pub use self::api::register_api; +pub(crate) use self::controller::NodeController;