diff --git a/Cargo.lock b/Cargo.lock
index 6346099..642a241 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -305,8 +305,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
+ "js-sys",
  "num-traits",
  "serde",
+ "wasm-bindgen",
  "windows-link",
 ]
 
@@ -465,6 +467,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.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "debug-ignore"
 version = "1.0.5"
@@ -489,6 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
 dependencies = [
  "powerfmt",
+ "serde",
 ]
 
 [[package]]
@@ -612,6 +678,31 @@ version = "1.0.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
 
+[[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 0.6.4",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
 [[package]]
 name = "either"
 version = "1.15.0"
@@ -674,6 +765,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.1.1"
@@ -1268,6 +1365,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"
@@ -1297,6 +1400,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
 dependencies = [
  "autocfg",
  "hashbrown 0.12.3",
+ "serde",
 ]
 
 [[package]]
@@ -1851,6 +1955,7 @@ name = "patagia-agent"
 version = "0.2.0"
 dependencies = [
  "anyhow",
+ "chrono",
  "clap",
  "futures",
  "instrumentation",
@@ -1868,12 +1973,21 @@ name = "patagia-controller"
 version = "0.2.0"
 dependencies = [
  "anyhow",
+ "chrono",
  "clap",
  "dropshot",
+ "ed25519-dalek",
+ "futures",
+ "hex",
+ "hkdf",
  "http",
  "instrumentation",
+ "rand 0.8.5",
  "schemars",
  "serde",
+ "serde_json",
+ "serde_with",
+ "sha2",
  "slog",
  "slog-async",
  "sqlx",
@@ -1882,6 +1996,7 @@ dependencies = [
  "tracing",
  "tracing-slog",
  "uuid",
+ "x25519-dalek",
 ]
 
 [[package]]
@@ -2201,9 +2316,9 @@ dependencies = [
 
 [[package]]
 name = "redox_syscall"
-version = "0.5.11"
+version = "0.5.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
+checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
 dependencies = [
  "bitflags",
 ]
@@ -2371,6 +2486,15 @@ version = "2.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
 
+[[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 = "1.0.7"
@@ -2492,6 +2616,7 @@ version = "0.8.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
 dependencies = [
+ "bytes",
  "chrono",
  "dyn-clone",
  "schemars_derive",
@@ -2649,6 +2774,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.9.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"
@@ -4217,9 +4372,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
 
 [[package]]
 name = "winnow"
-version = "0.7.8"
+version = "0.7.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e27d6ad3dac991091e4d35de9ba2d2d00647c5d0fc26c5496dee55984ae111b"
+checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3"
 dependencies = [
  "memchr",
 ]
@@ -4245,6 +4400,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 0.6.4",
+ "serde",
+ "zeroize",
+]
+
 [[package]]
 name = "xtask"
 version = "0.2.0"
@@ -4325,6 +4492,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 4d870ed..f86ea05 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ name = "patagia-run"
 
 [workspace.dependencies]
 anyhow = "1.0.98"
+chrono = "0.4.39"
 clap = { version = "4.5.37", features = [
   "derive",
   "deprecated",
@@ -32,19 +33,27 @@ clap = { version = "4.5.37", features = [
   "string",
 ] }
 dropshot = "0.16.0"
+ed25519-dalek = { version = "2.1.1", features = ["pem", "rand_core"] }
 futures = "0.3"
+hex = "0.4.3"
+hkdf = "0.12.4"
 http = "1.3.1"
 once_cell = "1.21.3"
 progenitor = "0.9"
+rand = "0.8.5"
 reqwest = { version = "0.12.15", features = ["json", "stream", "rustls-tls"] }
-schemars = "0.8.22"
+schemars = { version = "0.8.22", features = ["bytes", "chrono", "derive"] }
 semver = "1.0.26"
+serde_json = "1.0.138"
 serde = { version = "1.0.219", features = ["derive"] }
+serde_with = { version = "3.12.0", features = ["base64", "hex", "macros", "std"] }
+sha2 = "0.10.8"
 slog = "2.7.0"
 slog-async = "2.8.0"
 tokio = { version = "1.44.2", features = ["full"] }
 tracing = "0.1.41"
-tracing-core = "0.1.33"
 tracing-chrome = "0.7.2"
+tracing-core = "0.1.33"
 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 efe9bf7..8560153 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 1ff69da..ecd9aa4 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.5", 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<Arc<ControllerContext>>;
 
 pub fn api() -> Result<ControllerApiDescription> {
     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<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/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<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)
+}
+
+/// Get registration info
+#[endpoint {
+    method = GET,
+    path = "/nodes/register",
+    tags = [ "node" ],
+}]
+#[trace_request]
+async fn get_registration_info(
+    rqctx: RequestContext<Arc<ControllerContext>>,
+    params: Query<RegistrationParams>,
+) -> Result<HttpResponseOk<RegistrationInfo>, 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<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
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 e2aa667..e4ca97c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -167,6 +167,7 @@
               rust-dev-toolchain
               sqls
               sqlx-cli
+              tpm2-tools
               watchexec
             ]
             ++ commonArgs.buildInputs;
diff --git a/node-registration/Cargo.toml b/node-registration/Cargo.toml
new file mode 100644
index 0000000..074de4e
--- /dev/null
+++ b/node-registration/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "node-registration"
+edition = "2023"
+license = "MPL-2.0"
+version.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+chrono.workspace = true
+clap.workspace = true
+futures.workspace = true
+instrumentation = { path = "../instrumentation" }
+progenitor.workspace = true
+reqwest.workspace = true
+schemars.workspace = true
+serde.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+uuid.workspace = true
+
+[package.metadata.cargo-machete]
+ignored = ["reqwest", "serde"]