diff --git a/Cargo.lock b/Cargo.lock index e2d7e26..04102f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,8 +307,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -467,6 +469,69 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "serde", + "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 = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "debug-ignore" version = "1.0.5" @@ -491,6 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -614,6 +680,31 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.13.0" @@ -676,6 +767,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "flate2" version = "1.0.35" @@ -1254,6 +1351,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1283,6 +1386,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1822,6 +1926,7 @@ name = "patagia-agent" version = "0.2.0" dependencies = [ "anyhow", + "chrono", "clap", "futures", "instrumentation", @@ -1839,12 +1944,21 @@ name = "patagia-controller" version = "0.2.0" dependencies = [ "anyhow", + "chrono", "clap", "dropshot", + "ed25519-dalek", + "futures", + "hex", + "hkdf", "http", "instrumentation", + "rand", "schemars", "serde", + "serde_json", + "serde_with", + "sha2", "slog", "slog-async", "sqlx", @@ -1853,6 +1967,7 @@ dependencies = [ "tracing", "tracing-slog", "uuid", + "x25519-dalek", ] [[package]] @@ -2305,6 +2420,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.43" @@ -2415,6 +2539,7 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" dependencies = [ + "bytes", "chrono", "dyn-clone", "schemars_derive", @@ -2519,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", @@ -2572,6 +2697,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -4018,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" @@ -4099,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 d61baf6..ea7bb55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ name = "patagia-run" [workspace.dependencies] anyhow = "1.0.95" +chrono = "0.4.39" clap = { version = "4.5.26", features = [ "derive", "deprecated", @@ -32,14 +33,22 @@ clap = { version = "4.5.26", features = [ "string", ] } 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" +rand = "0.8.5" reqwest = { version = "0.12.12", features = ["json", "stream", "rustls-tls"] } -schemars = "0.8.21" +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"] } @@ -48,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/agent/Cargo.toml b/agent/Cargo.toml index 2305f9f..8cc49b3 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -6,6 +6,7 @@ version.workspace = true [dependencies] anyhow.workspace = true +chrono.workspace = true clap.workspace = true futures.workspace = true instrumentation = { path = "../instrumentation" } diff --git a/api.json b/api.json index 4ea6803..d7c3f25 100644 --- a/api.json +++ b/api.json @@ -5,6 +5,43 @@ "version": "1.0.0" }, "paths": { + "/nodes/register": { + "get": { + "tags": [ + "node" + ], + "summary": "Get registration info", + "operationId": "get_registration_info", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationInfo" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/users": { "get": { "tags": [ @@ -141,6 +178,31 @@ "request_id" ] }, + "RegistrationInfo": { + "type": "object", + "properties": { + "node_id": { + "type": "string", + "format": "uuid" + }, + "nonce": { + "type": "string" + }, + "server_dh_pub": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "node_id", + "nonce", + "server_dh_pub", + "timestamp" + ] + }, "User": { "description": "User", "type": "object", @@ -210,6 +272,9 @@ } }, "tags": [ + { + "name": "node" + }, { "name": "user" } diff --git a/controller/Cargo.toml b/controller/Cargo.toml index 7157501..f51b59a 100644 --- a/controller/Cargo.toml +++ b/controller/Cargo.toml @@ -7,12 +7,21 @@ version.workspace = true [dependencies] anyhow.workspace = true +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 = [ @@ -23,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/api.rs b/controller/src/api.rs index 5be86ce..2063611 100644 --- a/controller/src/api.rs +++ b/controller/src/api.rs @@ -4,6 +4,7 @@ use dropshot::ApiDescription; use std::sync::Arc; use crate::context::ControllerContext; +use crate::node; use crate::user; use crate::version; @@ -11,6 +12,7 @@ type ControllerApiDescription = ApiDescription>; pub fn api() -> Result { let mut api = ControllerApiDescription::new(); + node::register_api(&mut api)?; user::register_api(&mut api)?; api.register(version::version)?; Ok(api) 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, + 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/lib.rs b/controller/src/lib.rs index 2d12df1..d408e5c 100644 --- a/controller/src/lib.rs +++ b/controller/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; pub mod context; +mod node; mod user; mod version; diff --git a/controller/src/node/api.rs b/controller/src/node/api.rs new file mode 100644 index 0000000..63517a0 --- /dev/null +++ b/controller/src/node/api.rs @@ -0,0 +1,43 @@ +use dropshot::{endpoint, HttpError, HttpResponseOk, Query, RequestContext}; +use dropshot::{ApiDescription, ApiDescriptionRegisterError}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use trace_request::trace_request; +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")] + #[schemars(with = "String")] + key: [u8; 32], +} + +pub fn register_api( + api: &mut ApiDescription>, +) -> Result<(), ApiDescriptionRegisterError> { + api.register(get_registration_info) +} + +/// Get registration info +#[endpoint { + method = GET, + path = "/nodes/register", + tags = [ "node" ], +}] +#[trace_request] +async fn get_registration_info( + rqctx: RequestContext>, + params: Query, +) -> Result, HttpError> { + 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, + + #[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>>, +} + +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::::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 { + 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 new file mode 100644 index 0000000..c67ce0c --- /dev/null +++ b/controller/src/node/mod.rs @@ -0,0 +1,5 @@ +mod api; +mod controller; + +pub use self::api::register_api; +pub(crate) use self::controller::NodeController; diff --git a/flake.nix b/flake.nix index ac97f6e..8ad0312 100644 --- a/flake.nix +++ b/flake.nix @@ -146,6 +146,7 @@ rust-dev-toolchain sqls sqlx-cli + tpm2-tools watchexec ] ++ commonArgs.buildInputs;