1use std::io;
16use std::path::{Path, PathBuf};
17
18use base64::engine::general_purpose::STANDARD as B64;
19use base64::Engine;
20use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use x25519_dalek::{PublicKey, StaticSecret};
24
25pub mod isolation;
26pub mod call;
27pub mod policy;
28pub use isolation::{IsolationProfile, KeyStore, KillSwitch, KillSwitchError, TunnelMode};
29pub use policy::{Grants, Policy, PolicyError};
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum NodeStatus {
35 Neutral,
36 Member,
37 Trusted,
38 Suspended,
39 Revoked,
40}
41
42const DOMAIN_ENROLL: &[u8] = b"hatchery-enroll-v1\n";
43const DOMAIN_AUTH: &[u8] = b"hatchery-node-auth-v1\n";
44const DOMAIN_TOKEN: &[u8] = b"hatchery-session-token-v1\n";
45pub const CHALLENGE_MAGIC: &[u8; 4] = b"HSC1";
47pub const CHALLENGE_LEN: usize = 4 + 32 + 32;
49pub const ANSWER_LEN: usize = 32 + 64;
51
52#[derive(Debug, thiserror::Error, Eq, PartialEq)]
53pub enum EnrollError {
54 #[error("key material is malformed")]
55 MalformedKey,
56 #[error("signature is invalid")]
57 BadSignature,
58 #[error("randomness unavailable")]
59 Random,
60 #[error("state directory error: {0}")]
61 Storage(String),
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum NodeKind {
68 Local,
69 Docker,
70 Qemu,
71 Remote,
72}
73
74impl NodeKind {
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Local => "local",
78 Self::Docker => "docker",
79 Self::Qemu => "qemu",
80 Self::Remote => "remote",
81 }
82 }
83}
84
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct EnrollmentRequest {
88 pub node_id: String,
89 pub kind: NodeKind,
90 pub wg_public_key: String,
92 pub identity_public_key: String,
94 pub timestamp_ms: u64,
95 pub nonce: String,
96 pub code: Option<String>,
98 pub signature: String,
100}
101
102impl EnrollmentRequest {
103 pub fn signing_bytes(&self) -> Vec<u8> {
105 let mut out = DOMAIN_ENROLL.to_vec();
106 for part in [
107 self.node_id.as_str(),
108 self.kind.as_str(),
109 self.wg_public_key.as_str(),
110 self.identity_public_key.as_str(),
111 &self.timestamp_ms.to_string(),
112 self.nonce.as_str(),
113 self.code.as_deref().unwrap_or(""),
114 ] {
115 out.extend_from_slice(part.as_bytes());
116 out.push(b'\n');
117 }
118 out
119 }
120}
121
122#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
124pub struct EnrollmentResponse {
125 pub node_id: String,
126 pub status: String,
127 pub tunnel_ip: Option<String>,
129 pub c2_tunnel_ip: Option<String>,
131 pub c2_wg_public_key: Option<String>,
132 pub c2_wg_endpoint: Option<String>,
134 pub c2_call_home: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub upstream_c2_public_key: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub upstream_recipient_public_key: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub m4a_product_url: Option<String>,
146}
147
148pub fn auth_signing_bytes(node_id: &str, material: &[u8]) -> Vec<u8> {
150 let mut out = DOMAIN_AUTH.to_vec();
151 out.extend_from_slice(node_id.as_bytes());
152 out.push(b'\n');
153 out.extend_from_slice(material);
154 out
155}
156
157pub fn decode_key32(value: &str) -> Result<[u8; 32], EnrollError> {
158 B64.decode(value)
159 .map_err(|_| EnrollError::MalformedKey)?
160 .try_into()
161 .map_err(|_| EnrollError::MalformedKey)
162}
163
164pub fn verify_signature(
166 public_b64: &str,
167 message: &[u8],
168 signature_b64: &str,
169) -> Result<(), EnrollError> {
170 let key = VerifyingKey::from_bytes(&decode_key32(public_b64)?)
171 .map_err(|_| EnrollError::MalformedKey)?;
172 let sig: [u8; 64] = B64
173 .decode(signature_b64)
174 .map_err(|_| EnrollError::BadSignature)?
175 .try_into()
176 .map_err(|_| EnrollError::BadSignature)?;
177 key.verify(message, &Signature::from_bytes(&sig))
178 .map_err(|_| EnrollError::BadSignature)
179}
180
181fn random32() -> Result<[u8; 32], EnrollError> {
182 let mut bytes = [0_u8; 32];
183 getrandom::getrandom(&mut bytes).map_err(|_| EnrollError::Random)?;
184 Ok(bytes)
185}
186
187fn material(challenge: &[u8; 32], c2_eph: &[u8; 32], node_eph: &[u8; 32]) -> Vec<u8> {
188 let mut m = Vec::with_capacity(96);
189 m.extend_from_slice(challenge);
190 m.extend_from_slice(c2_eph);
191 m.extend_from_slice(node_eph);
192 m
193}
194
195fn session_token(shared: &[u8; 32], material: &[u8]) -> String {
196 let mut hasher = Sha256::new();
197 hasher.update(DOMAIN_TOKEN);
198 hasher.update(shared);
199 hasher.update(material);
200 hasher
201 .finalize()
202 .iter()
203 .map(|b| format!("{b:02x}"))
204 .collect()
205}
206
207pub struct NodeIdentity {
209 identity: SigningKey,
210 wg: StaticSecret,
211}
212
213impl NodeIdentity {
214 pub fn generate() -> Result<Self, EnrollError> {
215 Ok(Self {
216 identity: SigningKey::from_bytes(&random32()?),
217 wg: StaticSecret::from(random32()?),
218 })
219 }
220
221 pub fn from_seeds(identity_seed: [u8; 32], wg_seed: [u8; 32]) -> Self {
223 Self {
224 identity: SigningKey::from_bytes(&identity_seed),
225 wg: StaticSecret::from(wg_seed),
226 }
227 }
228
229 pub fn identity_public_key(&self) -> String {
230 B64.encode(self.identity.verifying_key().to_bytes())
231 }
232
233 pub fn wg_public_key(&self) -> String {
234 B64.encode(PublicKey::from(&self.wg).to_bytes())
235 }
236
237 pub fn identity_path(dir: &Path) -> PathBuf {
239 dir.join("identity.key")
240 }
241
242 pub fn wg_path(dir: &Path) -> PathBuf {
245 dir.join("wg.key")
246 }
247
248 pub fn load_or_create(dir: &Path) -> Result<Self, EnrollError> {
250 let (ip, wp) = (Self::identity_path(dir), Self::wg_path(dir));
251 if ip.exists() && wp.exists() {
252 let read = |p: &Path| -> Result<[u8; 32], EnrollError> {
253 let text =
254 std::fs::read_to_string(p).map_err(|e| EnrollError::Storage(e.to_string()))?;
255 decode_key32(text.trim())
256 };
257 return Ok(Self::from_seeds(read(&ip)?, read(&wp)?));
258 }
259 let me = Self::generate()?;
260 create_private_dir(dir).map_err(|e| EnrollError::Storage(e.to_string()))?;
261 write_private(&ip, B64.encode(me.identity.to_bytes()).as_bytes())
262 .map_err(|e| EnrollError::Storage(e.to_string()))?;
263 write_private(&wp, B64.encode(me.wg.to_bytes()).as_bytes())
264 .map_err(|e| EnrollError::Storage(e.to_string()))?;
265 Ok(me)
266 }
267
268 pub fn load_signing_only(dir: &Path) -> Result<Self, EnrollError> {
272 let text = std::fs::read_to_string(Self::identity_path(dir))
273 .map_err(|e| EnrollError::Storage(e.to_string()))?;
274 Ok(Self::from_seeds(decode_key32(text.trim())?, random32()?))
275 }
276
277 pub fn sign_bytes(&self, message: &[u8]) -> String {
279 B64.encode(self.identity.sign(message).to_bytes())
280 }
281
282 pub fn sign_request(
284 &self,
285 node_id: &str,
286 kind: NodeKind,
287 code: Option<&str>,
288 now_ms: u64,
289 ) -> Result<EnrollmentRequest, EnrollError> {
290 let mut request = EnrollmentRequest {
291 node_id: node_id.to_owned(),
292 kind,
293 wg_public_key: self.wg_public_key(),
294 identity_public_key: self.identity_public_key(),
295 timestamp_ms: now_ms,
296 nonce: random32()?.iter().map(|b| format!("{b:02x}")).collect(),
297 code: code.map(str::to_owned),
298 signature: String::new(),
299 };
300 request.signature = B64.encode(self.identity.sign(&request.signing_bytes()).to_bytes());
301 Ok(request)
302 }
303
304 pub fn answer_challenge(
306 &self,
307 node_id: &str,
308 challenge_frame: &[u8; CHALLENGE_LEN],
309 ) -> Result<([u8; ANSWER_LEN], String), EnrollError> {
310 if &challenge_frame[..4] != CHALLENGE_MAGIC {
311 return Err(EnrollError::MalformedKey);
312 }
313 let challenge: [u8; 32] = challenge_frame[4..36]
314 .try_into()
315 .map_err(|_| EnrollError::MalformedKey)?;
316 let c2_eph: [u8; 32] = challenge_frame[36..68]
317 .try_into()
318 .map_err(|_| EnrollError::MalformedKey)?;
319 let eph = StaticSecret::from(random32()?);
320 let node_eph = PublicKey::from(&eph).to_bytes();
321 let m = material(&challenge, &c2_eph, &node_eph);
322 let signature = self
323 .identity
324 .sign(&auth_signing_bytes(node_id, &m))
325 .to_bytes();
326 let shared = eph.diffie_hellman(&PublicKey::from(c2_eph)).to_bytes();
327 let mut answer = [0_u8; ANSWER_LEN];
328 answer[..32].copy_from_slice(&node_eph);
329 answer[32..].copy_from_slice(&signature);
330 Ok((answer, session_token(&shared, &m)))
331 }
332}
333
334pub struct CallHomeChallenge {
336 challenge: [u8; 32],
337 eph: StaticSecret,
338}
339
340pub struct VerifiedMaterial {
342 pub material: Vec<u8>,
344 pub signature_b64: String,
346 pub session_token: String,
347}
348
349impl CallHomeChallenge {
350 pub fn new() -> Result<Self, EnrollError> {
351 Ok(Self {
352 challenge: random32()?,
353 eph: StaticSecret::from(random32()?),
354 })
355 }
356
357 pub fn frame(&self) -> [u8; CHALLENGE_LEN] {
358 let mut frame = [0_u8; CHALLENGE_LEN];
359 frame[..4].copy_from_slice(CHALLENGE_MAGIC);
360 frame[4..36].copy_from_slice(&self.challenge);
361 frame[36..].copy_from_slice(&PublicKey::from(&self.eph).to_bytes());
362 frame
363 }
364
365 pub fn accept_answer(&self, answer: &[u8; ANSWER_LEN]) -> VerifiedMaterial {
368 let node_eph: [u8; 32] = answer[..32].try_into().expect("slice length");
369 let c2_eph = PublicKey::from(&self.eph).to_bytes();
370 let m = material(&self.challenge, &c2_eph, &node_eph);
371 let shared = self
372 .eph
373 .diffie_hellman(&PublicKey::from(node_eph))
374 .to_bytes();
375 VerifiedMaterial {
376 material: m.clone(),
377 signature_b64: B64.encode(&answer[32..]),
378 session_token: session_token(&shared, &m),
379 }
380 }
381}
382
383fn create_private_dir(dir: &Path) -> io::Result<()> {
384 std::fs::create_dir_all(dir)?;
385 #[cfg(unix)]
386 {
387 use std::os::unix::fs::PermissionsExt;
388 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
389 }
390 Ok(())
391}
392
393pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
395 use std::io::Write;
396 let mut options = std::fs::OpenOptions::new();
397 options.write(true).create(true).truncate(true);
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::OpenOptionsExt;
401 options.mode(0o600);
402 }
403 let mut file = options.open(path)?;
404 file.write_all(bytes)?;
405 file.sync_all()
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn challenge_answer_agree_on_token_and_signature_verifies() {
414 let node = NodeIdentity::from_seeds([1; 32], [2; 32]);
415 let c2 = CallHomeChallenge::new().unwrap();
416 let (answer, node_token) = node.answer_challenge("n1", &c2.frame()).unwrap();
417 let v = c2.accept_answer(&answer);
418 assert_eq!(v.session_token, node_token);
419 verify_signature(
420 &node.identity_public_key(),
421 &auth_signing_bytes("n1", &v.material),
422 &v.signature_b64,
423 )
424 .unwrap();
425 assert!(verify_signature(
426 &node.identity_public_key(),
427 &auth_signing_bytes("n2", &v.material),
428 &v.signature_b64
429 )
430 .is_err());
431 }
432
433 #[test]
434 fn each_connection_gets_a_fresh_token() {
435 let node = NodeIdentity::from_seeds([1; 32], [2; 32]);
436 let a = CallHomeChallenge::new().unwrap();
437 let b = CallHomeChallenge::new().unwrap();
438 let (_, ta) = node.answer_challenge("n", &a.frame()).unwrap();
439 let (_, tb) = node.answer_challenge("n", &b.frame()).unwrap();
440 assert_ne!(ta, tb);
441 assert_eq!(ta.len(), 64);
442 }
443
444 #[test]
445 fn keys_persist_with_private_modes_and_public_halves_are_stable() {
446 let dir = std::env::temp_dir().join(format!("hatchery-enroll-keys-{}", std::process::id()));
447 let _ = std::fs::remove_dir_all(&dir);
448 let a = NodeIdentity::load_or_create(&dir).unwrap();
449 let b = NodeIdentity::load_or_create(&dir).unwrap();
450 assert_eq!(a.identity_public_key(), b.identity_public_key());
451 assert_eq!(a.wg_public_key(), b.wg_public_key());
452 #[cfg(unix)]
453 {
454 use std::os::unix::fs::PermissionsExt;
455 for p in [
456 NodeIdentity::identity_path(&dir),
457 NodeIdentity::wg_path(&dir),
458 ] {
459 assert_eq!(
460 std::fs::metadata(p).unwrap().permissions().mode() & 0o777,
461 0o600
462 );
463 }
464 assert_eq!(
465 std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
466 0o700
467 );
468 }
469 std::fs::remove_dir_all(&dir).unwrap();
470 }
471
472 #[test]
473 fn signed_request_verifies_and_tamper_fails() {
474 let n = NodeIdentity::from_seeds([3; 32], [4; 32]);
475 let mut r = n.sign_request("x", NodeKind::Qemu, Some("c"), 5).unwrap();
476 verify_signature(&r.identity_public_key, &r.signing_bytes(), &r.signature).unwrap();
477 r.node_id = "y".into();
478 assert!(
479 verify_signature(&r.identity_public_key, &r.signing_bytes(), &r.signature).is_err()
480 );
481 }
482}