1use std::collections::{BTreeMap, BTreeSet};
17use std::io;
18use std::path::{Path, PathBuf};
19
20use hatchery_enroll::{auth_signing_bytes, decode_key32, verify_signature, EnrollmentRequest};
21pub use hatchery_enroll::NodeKind;
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25pub const MAX_CLOCK_SKEW_MS: u64 = 5 * 60 * 1000;
27
28pub use hatchery_enroll::NodeStatus;
29
30#[derive(Debug, thiserror::Error, Eq, PartialEq)]
31pub enum EnrollmentError {
32 #[error("node id must be 1-64 characters of a-z, 0-9, '-' or '_'")]
33 InvalidNodeId,
34 #[error("public key is malformed")]
35 MalformedKey,
36 #[error("signature is invalid")]
37 BadSignature,
38 #[error("request timestamp is outside the allowed clock skew")]
39 Stale,
40 #[error("request nonce was already used")]
41 Replay,
42 #[error("an enrollment code is required")]
43 CodeRequired,
44 #[error("enrollment code is unknown, used or expired")]
45 CodeRejected,
46 #[error("node id is enrolled with different keys")]
47 KeyMismatch,
48 #[error("node is revoked")]
49 Revoked,
50 #[error("node is not enrolled")]
51 Unknown,
52 #[error("registry storage failed: {0}")]
53 Storage(String),
54}
55
56#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
58pub struct AuditEntry {
59 pub at_ms: u64,
60 pub actor: String,
61 pub action: String,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65pub struct NodeRecord {
66 pub node_id: String,
67 pub kind: NodeKind,
68 pub wg_public_key: String,
69 pub identity_public_key: String,
70 pub status: NodeStatus,
71 #[serde(default)]
73 pub tunnel_ip: Option<String>,
74 pub enrolled_at_ms: u64,
75 pub status_changed_at_ms: u64,
76}
77
78#[derive(Default, Serialize, Deserialize)]
79struct State {
80 nodes: BTreeMap<String, NodeRecord>,
81 codes: BTreeMap<String, u64>,
83 seen_nonces: BTreeSet<String>,
84 audit: Vec<AuditEntry>,
85}
86
87#[derive(Clone, Copy, Debug)]
89pub struct TunnelPool {
90 pub c2_ip: std::net::Ipv4Addr,
91 pub prefix: u8,
92}
93
94pub struct EnrollmentRegistry {
96 path: Option<PathBuf>,
97 require_code: bool,
98 pool: Option<TunnelPool>,
99 state: State,
100}
101
102impl EnrollmentRegistry {
103 pub fn in_memory(require_code: bool) -> Self {
105 Self { path: None, require_code, pool: None, state: State::default() }
106 }
107
108 pub fn open(path: &Path, require_code: bool) -> Result<Self, EnrollmentError> {
110 let state = match std::fs::read(path) {
111 Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| EnrollmentError::Storage(e.to_string()))?,
112 Err(e) if e.kind() == io::ErrorKind::NotFound => State::default(),
113 Err(e) => return Err(EnrollmentError::Storage(e.to_string())),
114 };
115 Ok(Self { path: Some(path.to_owned()), require_code, pool: None, state })
116 }
117
118 pub fn with_pool(mut self, pool: TunnelPool) -> Self {
120 self.pool = Some(pool);
121 self
122 }
123
124 pub fn require_code(&self) -> bool {
125 self.require_code
126 }
127
128 fn next_tunnel_ip(&self) -> Result<Option<String>, EnrollmentError> {
129 let Some(pool) = self.pool else { return Ok(None) };
130 let mask = if pool.prefix == 0 { 0 } else { u32::MAX << (32 - u32::from(pool.prefix)) };
131 let network = u32::from(pool.c2_ip) & mask;
132 let broadcast = network | !mask;
133 let used: BTreeSet<String> = self.state.nodes.values().filter_map(|n| n.tunnel_ip.clone()).collect();
134 for host in (network + 1)..broadcast {
135 let ip = std::net::Ipv4Addr::from(host);
136 if ip != pool.c2_ip && !used.contains(&ip.to_string()) {
137 return Ok(Some(ip.to_string()));
138 }
139 }
140 Err(EnrollmentError::Storage("tunnel address pool is exhausted".to_owned()))
141 }
142
143 pub fn burn_code(&mut self, code: &str, now_ms: u64) -> bool {
145 let burned = self.state.codes.remove(&hash_hex(code.as_bytes())).is_some();
146 if burned {
147 self.audit(now_ms, "c2", "burn_code:plaintext".to_owned());
148 let _ = self.save();
149 }
150 burned
151 }
152
153 pub fn issue_code(&mut self, now_ms: u64, ttl_ms: u64, actor: &str) -> Result<String, EnrollmentError> {
156 let mut raw = [0_u8; 16];
157 getrandom::getrandom(&mut raw).map_err(|e| EnrollmentError::Storage(e.to_string()))?;
158 let code: String = raw.iter().map(|b| format!("{b:02x}")).collect();
159 self.state.codes.insert(hash_hex(code.as_bytes()), now_ms.saturating_add(ttl_ms));
160 self.audit(now_ms, actor, "issue_code".to_owned());
161 self.save()?;
162 Ok(code)
163 }
164
165 pub fn submit(&mut self, request: &EnrollmentRequest, now_ms: u64) -> Result<NodeRecord, EnrollmentError> {
167 validate_node_id(&request.node_id)?;
168 let wg = decode_key32(&request.wg_public_key).map_err(|_| EnrollmentError::MalformedKey)?;
169 let identity = decode_key32(&request.identity_public_key).map_err(|_| EnrollmentError::MalformedKey)?;
170 verify_signature(&request.identity_public_key, &request.signing_bytes(), &request.signature).map_err(map_verify)?;
171 if now_ms.abs_diff(request.timestamp_ms) > MAX_CLOCK_SKEW_MS {
172 return Err(EnrollmentError::Stale);
173 }
174 let _ = (wg, identity);
175 if let Some(existing) = self.state.nodes.get(&request.node_id) {
176 if existing.wg_public_key != request.wg_public_key || existing.identity_public_key != request.identity_public_key {
177 return Err(EnrollmentError::KeyMismatch);
178 }
179 if existing.status == NodeStatus::Revoked {
180 return Err(EnrollmentError::Revoked);
181 }
182 return Ok(existing.clone());
183 }
184 let nonce_key = format!("{}:{}", request.node_id, request.nonce);
185 if self.state.seen_nonces.contains(&nonce_key) {
186 return Err(EnrollmentError::Replay);
187 }
188 if self.require_code {
189 let code = request.code.as_deref().ok_or(EnrollmentError::CodeRequired)?;
190 let hash = hash_hex(code.as_bytes());
191 match self.state.codes.get(&hash) {
192 Some(expiry) if *expiry >= now_ms => {
193 self.state.codes.remove(&hash);
194 }
195 _ => return Err(EnrollmentError::CodeRejected),
196 }
197 }
198 self.state.seen_nonces.insert(nonce_key);
199 let record = NodeRecord {
200 node_id: request.node_id.clone(),
201 kind: request.kind,
202 wg_public_key: request.wg_public_key.clone(),
203 identity_public_key: request.identity_public_key.clone(),
204 status: NodeStatus::Neutral,
205 tunnel_ip: self.next_tunnel_ip()?,
206 enrolled_at_ms: now_ms,
207 status_changed_at_ms: now_ms,
208 };
209 self.state.nodes.insert(record.node_id.clone(), record.clone());
210 self.audit(now_ms, &request.node_id, "enroll:neutral".to_owned());
211 self.save()?;
212 Ok(record)
213 }
214
215 pub fn set_status(&mut self, node_id: &str, status: NodeStatus, actor: &str, now_ms: u64) -> Result<NodeRecord, EnrollmentError> {
217 let record = self.state.nodes.get_mut(node_id).ok_or(EnrollmentError::Unknown)?;
218 if record.status == NodeStatus::Revoked && status != NodeStatus::Revoked {
219 return Err(EnrollmentError::Revoked);
220 }
221 record.status = status;
222 record.status_changed_at_ms = now_ms;
223 let snapshot = record.clone();
224 self.audit(now_ms, actor, format!("status:{node_id}:{status:?}").to_lowercase());
225 self.save()?;
226 Ok(snapshot)
227 }
228
229 pub fn get(&self, node_id: &str) -> Option<&NodeRecord> {
230 self.state.nodes.get(node_id)
231 }
232
233 pub fn list(&self) -> Vec<NodeRecord> {
234 self.state.nodes.values().cloned().collect()
235 }
236
237 pub fn audit_log(&self) -> &[AuditEntry] {
238 &self.state.audit
239 }
240
241 pub fn verify_node_signature(&self, node_id: &str, challenge: &[u8], signature_b64: &str) -> Result<NodeStatus, EnrollmentError> {
244 let record = self.state.nodes.get(node_id).ok_or(EnrollmentError::Unknown)?;
245 if record.status == NodeStatus::Revoked {
246 return Err(EnrollmentError::Revoked);
247 }
248 verify_signature(&record.identity_public_key, &auth_signing_bytes(node_id, challenge), signature_b64).map_err(map_verify)?;
249 Ok(record.status)
250 }
251
252 pub fn verify_node_message(&self, node_id: &str, message: &[u8], signature_b64: &str) -> Result<NodeStatus, EnrollmentError> {
255 let record = self.state.nodes.get(node_id).ok_or(EnrollmentError::Unknown)?;
256 if record.status == NodeStatus::Revoked {
257 return Err(EnrollmentError::Revoked);
258 }
259 verify_signature(&record.identity_public_key, message, signature_b64).map_err(map_verify)?;
260 Ok(record.status)
261 }
262
263 fn audit(&mut self, at_ms: u64, actor: &str, action: String) {
264 self.state.audit.push(AuditEntry { at_ms, actor: actor.to_owned(), action });
265 }
266
267 fn save(&self) -> Result<(), EnrollmentError> {
268 let Some(path) = &self.path else { return Ok(()) };
269 let bytes = serde_json::to_vec_pretty(&self.state).map_err(|e| EnrollmentError::Storage(e.to_string()))?;
270 let tmp = path.with_extension("tmp");
271 hatchery_enroll::write_private(&tmp, &bytes).map_err(|e| EnrollmentError::Storage(e.to_string()))?;
272 std::fs::rename(&tmp, path).map_err(|e| EnrollmentError::Storage(e.to_string()))
273 }
274}
275
276fn validate_node_id(id: &str) -> Result<(), EnrollmentError> {
277 let ok = !id.is_empty()
278 && id.len() <= 64
279 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_');
280 if ok { Ok(()) } else { Err(EnrollmentError::InvalidNodeId) }
281}
282
283fn map_verify(error: hatchery_enroll::EnrollError) -> EnrollmentError {
284 match error {
285 hatchery_enroll::EnrollError::MalformedKey => EnrollmentError::MalformedKey,
286 _ => EnrollmentError::BadSignature,
287 }
288}
289
290fn hash_hex(bytes: &[u8]) -> String {
291 Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use hatchery_enroll::NodeIdentity;
298
299 const NOW: u64 = 1_800_000_000_000;
300
301 fn node(seed: u8) -> NodeIdentity {
302 NodeIdentity::from_seeds([seed; 32], [seed.wrapping_add(1); 32])
303 }
304
305 fn request(id: &str, seed: u8, code: Option<&str>, ts: u64) -> EnrollmentRequest {
306 node(seed).sign_request(id, NodeKind::Qemu, code, ts).unwrap()
307 }
308
309 #[test]
310 fn enrolls_as_neutral_and_only_the_operator_changes_status() {
311 let mut reg = EnrollmentRegistry::in_memory(false);
312 let rec = reg.submit(&request("home1", 7, None, NOW), NOW).unwrap();
313 assert_eq!(rec.status, NodeStatus::Neutral);
314 let again = reg.submit(&request("home1", 7, None, NOW), NOW).unwrap();
315 assert_eq!(again.status, NodeStatus::Neutral, "re-submit never upgrades");
316 let rec = reg.set_status("home1", NodeStatus::Member, "operator", NOW + 1).unwrap();
317 assert_eq!(rec.status, NodeStatus::Member);
318 assert!(reg.audit_log().iter().any(|a| a.action == "enroll:neutral"));
319 assert!(reg.audit_log().iter().any(|a| a.action == "status:home1:member" && a.actor == "operator"));
320 }
321
322 #[test]
323 fn rejects_bad_signature_tamper_stale_and_malformed() {
324 let mut reg = EnrollmentRegistry::in_memory(false);
325 let mut r = request("a", 1, None, NOW);
326 r.node_id = "b".into();
327 assert_eq!(reg.submit(&r, NOW), Err(EnrollmentError::BadSignature));
328 assert_eq!(reg.submit(&request("a", 1, None, NOW - MAX_CLOCK_SKEW_MS - 1), NOW), Err(EnrollmentError::Stale));
329 assert_eq!(reg.submit(&request("Bad Id", 1, None, NOW), NOW), Err(EnrollmentError::InvalidNodeId));
330 let mut r = request("a", 1, None, NOW);
331 r.wg_public_key = "short".into();
332 assert_eq!(reg.submit(&r, NOW), Err(EnrollmentError::MalformedKey));
333 assert!(reg.list().is_empty());
334 }
335
336 #[test]
337 fn a_node_id_cannot_be_taken_over_with_other_keys() {
338 let mut reg = EnrollmentRegistry::in_memory(false);
339 reg.submit(&request("n", 1, None, NOW), NOW).unwrap();
340 assert_eq!(reg.submit(&request("n", 2, None, NOW), NOW), Err(EnrollmentError::KeyMismatch));
341 }
342
343 #[test]
344 fn one_time_code_is_required_single_use_and_expires() {
345 let mut reg = EnrollmentRegistry::in_memory(true);
346 assert_eq!(reg.submit(&request("n", 1, None, NOW), NOW), Err(EnrollmentError::CodeRequired));
347 let code = reg.issue_code(NOW, 1000, "operator").unwrap();
348 assert_eq!(reg.submit(&request("n", 1, Some("wrong"), NOW), NOW), Err(EnrollmentError::CodeRejected));
349 reg.submit(&request("n", 1, Some(&code), NOW), NOW).unwrap();
350 assert_eq!(reg.submit(&request("m", 2, Some(&code), NOW), NOW), Err(EnrollmentError::CodeRejected), "single use");
351 let late = reg.issue_code(NOW, 10, "operator").unwrap();
352 assert_eq!(reg.submit(&request("k", 3, Some(&late), NOW + 11), NOW + 11), Err(EnrollmentError::CodeRejected));
353 }
354
355 #[test]
356 fn replayed_request_after_revoke_is_refused_and_revocation_is_final() {
357 let mut reg = EnrollmentRegistry::in_memory(false);
358 let req = request("n", 5, None, NOW);
359 reg.submit(&req, NOW).unwrap();
360 let challenge = b"challenge-1";
361 let sig = {
362 let id = node(5);
363 let c2 = hatchery_enroll::CallHomeChallenge::new().unwrap();
364 let (answer, _) = id.answer_challenge("n", &c2.frame()).unwrap();
365 let v = c2.accept_answer(&answer);
366 assert_eq!(reg.verify_node_signature("n", &v.material, &v.signature_b64), Ok(NodeStatus::Neutral));
367 "AAAA".to_owned()
368 };
369 assert_eq!(reg.verify_node_signature("n", challenge, &sig), Err(EnrollmentError::BadSignature));
370 assert_eq!(reg.verify_node_signature("x", challenge, &sig), Err(EnrollmentError::Unknown));
371 reg.set_status("n", NodeStatus::Revoked, "operator", NOW).unwrap();
372 assert_eq!(reg.set_status("n", NodeStatus::Member, "operator", NOW), Err(EnrollmentError::Revoked));
373 assert_eq!(reg.submit(&req, NOW), Err(EnrollmentError::Revoked));
374 }
375
376 #[test]
377 fn tunnel_addresses_are_allocated_uniquely_and_skip_the_c2_address() {
378 let pool = TunnelPool { c2_ip: "10.79.0.1".parse().unwrap(), prefix: 30 };
379 let mut reg = EnrollmentRegistry::in_memory(false).with_pool(pool);
380 assert_eq!(reg.submit(&request("a", 1, None, NOW), NOW).unwrap().tunnel_ip.as_deref(), Some("10.79.0.2"));
381 assert_eq!(reg.submit(&request("b", 2, None, NOW), NOW), Err(EnrollmentError::Storage("tunnel address pool is exhausted".into())));
382 }
383
384 #[test]
385 fn persists_atomically_with_private_mode_and_reloads() {
386 let dir = std::env::temp_dir().join(format!("hatchery-enroll-reg-{}", std::process::id()));
387 let _ = std::fs::remove_dir_all(&dir);
388 std::fs::create_dir_all(&dir).unwrap();
389 let path = dir.join("registry.json");
390 {
391 let mut reg = EnrollmentRegistry::open(&path, true).unwrap();
392 let code = reg.issue_code(NOW, 10_000, "operator").unwrap();
393 reg.submit(&request("n", 9, Some(&code), NOW), NOW).unwrap();
394 reg.set_status("n", NodeStatus::Trusted, "operator", NOW).unwrap();
395 let text = std::fs::read_to_string(&path).unwrap();
396 assert!(!text.contains(&code), "the plain code is never stored");
397 }
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::PermissionsExt;
401 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
402 }
403 let reg = EnrollmentRegistry::open(&path, true).unwrap();
404 assert_eq!(reg.get("n").unwrap().status, NodeStatus::Trusted);
405 std::fs::remove_dir_all(&dir).unwrap();
406 }
407}