Skip to main content

hatchery_c2/
enrollment.rs

1//! Node enrollment registry.
2//!
3//! A node generates two key pairs on its own host: a WireGuard key pair (only the
4//! public half is ever sent) and an Ed25519 identity key pair (only the public
5//! half is sent). It submits a signed [`EnrollmentRequest`]. The registry checks
6//! the signature (proof of possession), freshness, replay and an optional
7//! one-time code, and records the node with the neutral status
8//! [`NodeStatus::Neutral`]. Only an operator call changes the status.
9//! Afterwards the node authenticates by signing a challenge
10//! ([`EnrollmentRegistry::verify_node_signature`]); no shared secret exists, so
11//! no secret ever has to be shown to an agent.
12//!
13//! The registry persists to a JSON file (written atomically, mode 0600 on unix).
14//! It knows nothing about what a status allows: that is policy (phase P3).
15
16use 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
25/// Largest accepted clock difference between node and C2 for a request.
26pub 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/// One audit line per state change. Never contains keys other than public ones.
57#[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    /// Address assigned inside the node tunnel (without prefix length).
72    #[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    /// sha256 (hex) of unused codes -> expiry ms.
82    codes: BTreeMap<String, u64>,
83    seen_nonces: BTreeSet<String>,
84    audit: Vec<AuditEntry>,
85}
86
87/// Addresses handed to enrolled nodes: the host range of `c2_ip/prefix` minus `c2_ip`.
88#[derive(Clone, Copy, Debug)]
89pub struct TunnelPool {
90    pub c2_ip: std::net::Ipv4Addr,
91    pub prefix: u8,
92}
93
94/// The registry. Single-writer: wrap it in a mutex when sharing.
95pub struct EnrollmentRegistry {
96    path: Option<PathBuf>,
97    require_code: bool,
98    pool: Option<TunnelPool>,
99    state: State,
100}
101
102impl EnrollmentRegistry {
103    /// In-memory registry (tests, ephemeral C2).
104    pub fn in_memory(require_code: bool) -> Self {
105        Self { path: None, require_code, pool: None, state: State::default() }
106    }
107
108    /// Load or create a persistent registry at `path`.
109    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    /// Hand out tunnel addresses from `pool` to newly enrolled nodes.
119    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    /// Invalidates `code` if it is still unused (it was seen in the clear). True when one was.
144    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    /// Operator: create a one-time enrollment code valid for `ttl_ms`. The code is
154    /// returned once; only its hash is stored.
155    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    /// Node request. Idempotent for an identical key set (returns the record).
166    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    /// Operator: change a node's status. A revoked node cannot be re-activated.
216    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    /// Verify a node's signature over a C2 challenge; returns the node's status.
242    /// Revoked and unknown nodes never authenticate.
243    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    /// Verify a node's signature over a message that carries its own domain prefix (for
253    /// requests other than call-home). Revoked and unknown nodes never verify.
254    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}