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}
143
144pub fn auth_signing_bytes(node_id: &str, material: &[u8]) -> Vec<u8> {
146 let mut out = DOMAIN_AUTH.to_vec();
147 out.extend_from_slice(node_id.as_bytes());
148 out.push(b'\n');
149 out.extend_from_slice(material);
150 out
151}
152
153pub fn decode_key32(value: &str) -> Result<[u8; 32], EnrollError> {
154 B64.decode(value)
155 .map_err(|_| EnrollError::MalformedKey)?
156 .try_into()
157 .map_err(|_| EnrollError::MalformedKey)
158}
159
160pub fn verify_signature(
162 public_b64: &str,
163 message: &[u8],
164 signature_b64: &str,
165) -> Result<(), EnrollError> {
166 let key = VerifyingKey::from_bytes(&decode_key32(public_b64)?)
167 .map_err(|_| EnrollError::MalformedKey)?;
168 let sig: [u8; 64] = B64
169 .decode(signature_b64)
170 .map_err(|_| EnrollError::BadSignature)?
171 .try_into()
172 .map_err(|_| EnrollError::BadSignature)?;
173 key.verify(message, &Signature::from_bytes(&sig))
174 .map_err(|_| EnrollError::BadSignature)
175}
176
177fn random32() -> Result<[u8; 32], EnrollError> {
178 let mut bytes = [0_u8; 32];
179 getrandom::getrandom(&mut bytes).map_err(|_| EnrollError::Random)?;
180 Ok(bytes)
181}
182
183fn material(challenge: &[u8; 32], c2_eph: &[u8; 32], node_eph: &[u8; 32]) -> Vec<u8> {
184 let mut m = Vec::with_capacity(96);
185 m.extend_from_slice(challenge);
186 m.extend_from_slice(c2_eph);
187 m.extend_from_slice(node_eph);
188 m
189}
190
191fn session_token(shared: &[u8; 32], material: &[u8]) -> String {
192 let mut hasher = Sha256::new();
193 hasher.update(DOMAIN_TOKEN);
194 hasher.update(shared);
195 hasher.update(material);
196 hasher
197 .finalize()
198 .iter()
199 .map(|b| format!("{b:02x}"))
200 .collect()
201}
202
203pub struct NodeIdentity {
205 identity: SigningKey,
206 wg: StaticSecret,
207}
208
209impl NodeIdentity {
210 pub fn generate() -> Result<Self, EnrollError> {
211 Ok(Self {
212 identity: SigningKey::from_bytes(&random32()?),
213 wg: StaticSecret::from(random32()?),
214 })
215 }
216
217 pub fn from_seeds(identity_seed: [u8; 32], wg_seed: [u8; 32]) -> Self {
219 Self {
220 identity: SigningKey::from_bytes(&identity_seed),
221 wg: StaticSecret::from(wg_seed),
222 }
223 }
224
225 pub fn identity_public_key(&self) -> String {
226 B64.encode(self.identity.verifying_key().to_bytes())
227 }
228
229 pub fn wg_public_key(&self) -> String {
230 B64.encode(PublicKey::from(&self.wg).to_bytes())
231 }
232
233 pub fn identity_path(dir: &Path) -> PathBuf {
235 dir.join("identity.key")
236 }
237
238 pub fn wg_path(dir: &Path) -> PathBuf {
241 dir.join("wg.key")
242 }
243
244 pub fn load_or_create(dir: &Path) -> Result<Self, EnrollError> {
246 let (ip, wp) = (Self::identity_path(dir), Self::wg_path(dir));
247 if ip.exists() && wp.exists() {
248 let read = |p: &Path| -> Result<[u8; 32], EnrollError> {
249 let text =
250 std::fs::read_to_string(p).map_err(|e| EnrollError::Storage(e.to_string()))?;
251 decode_key32(text.trim())
252 };
253 return Ok(Self::from_seeds(read(&ip)?, read(&wp)?));
254 }
255 let me = Self::generate()?;
256 create_private_dir(dir).map_err(|e| EnrollError::Storage(e.to_string()))?;
257 write_private(&ip, B64.encode(me.identity.to_bytes()).as_bytes())
258 .map_err(|e| EnrollError::Storage(e.to_string()))?;
259 write_private(&wp, B64.encode(me.wg.to_bytes()).as_bytes())
260 .map_err(|e| EnrollError::Storage(e.to_string()))?;
261 Ok(me)
262 }
263
264 pub fn load_signing_only(dir: &Path) -> Result<Self, EnrollError> {
268 let text = std::fs::read_to_string(Self::identity_path(dir))
269 .map_err(|e| EnrollError::Storage(e.to_string()))?;
270 Ok(Self::from_seeds(decode_key32(text.trim())?, random32()?))
271 }
272
273 pub fn sign_bytes(&self, message: &[u8]) -> String {
275 B64.encode(self.identity.sign(message).to_bytes())
276 }
277
278 pub fn sign_request(
280 &self,
281 node_id: &str,
282 kind: NodeKind,
283 code: Option<&str>,
284 now_ms: u64,
285 ) -> Result<EnrollmentRequest, EnrollError> {
286 let mut request = EnrollmentRequest {
287 node_id: node_id.to_owned(),
288 kind,
289 wg_public_key: self.wg_public_key(),
290 identity_public_key: self.identity_public_key(),
291 timestamp_ms: now_ms,
292 nonce: random32()?.iter().map(|b| format!("{b:02x}")).collect(),
293 code: code.map(str::to_owned),
294 signature: String::new(),
295 };
296 request.signature = B64.encode(self.identity.sign(&request.signing_bytes()).to_bytes());
297 Ok(request)
298 }
299
300 pub fn answer_challenge(
302 &self,
303 node_id: &str,
304 challenge_frame: &[u8; CHALLENGE_LEN],
305 ) -> Result<([u8; ANSWER_LEN], String), EnrollError> {
306 if &challenge_frame[..4] != CHALLENGE_MAGIC {
307 return Err(EnrollError::MalformedKey);
308 }
309 let challenge: [u8; 32] = challenge_frame[4..36]
310 .try_into()
311 .map_err(|_| EnrollError::MalformedKey)?;
312 let c2_eph: [u8; 32] = challenge_frame[36..68]
313 .try_into()
314 .map_err(|_| EnrollError::MalformedKey)?;
315 let eph = StaticSecret::from(random32()?);
316 let node_eph = PublicKey::from(&eph).to_bytes();
317 let m = material(&challenge, &c2_eph, &node_eph);
318 let signature = self
319 .identity
320 .sign(&auth_signing_bytes(node_id, &m))
321 .to_bytes();
322 let shared = eph.diffie_hellman(&PublicKey::from(c2_eph)).to_bytes();
323 let mut answer = [0_u8; ANSWER_LEN];
324 answer[..32].copy_from_slice(&node_eph);
325 answer[32..].copy_from_slice(&signature);
326 Ok((answer, session_token(&shared, &m)))
327 }
328}
329
330pub struct CallHomeChallenge {
332 challenge: [u8; 32],
333 eph: StaticSecret,
334}
335
336pub struct VerifiedMaterial {
338 pub material: Vec<u8>,
340 pub signature_b64: String,
342 pub session_token: String,
343}
344
345impl CallHomeChallenge {
346 pub fn new() -> Result<Self, EnrollError> {
347 Ok(Self {
348 challenge: random32()?,
349 eph: StaticSecret::from(random32()?),
350 })
351 }
352
353 pub fn frame(&self) -> [u8; CHALLENGE_LEN] {
354 let mut frame = [0_u8; CHALLENGE_LEN];
355 frame[..4].copy_from_slice(CHALLENGE_MAGIC);
356 frame[4..36].copy_from_slice(&self.challenge);
357 frame[36..].copy_from_slice(&PublicKey::from(&self.eph).to_bytes());
358 frame
359 }
360
361 pub fn accept_answer(&self, answer: &[u8; ANSWER_LEN]) -> VerifiedMaterial {
364 let node_eph: [u8; 32] = answer[..32].try_into().expect("slice length");
365 let c2_eph = PublicKey::from(&self.eph).to_bytes();
366 let m = material(&self.challenge, &c2_eph, &node_eph);
367 let shared = self
368 .eph
369 .diffie_hellman(&PublicKey::from(node_eph))
370 .to_bytes();
371 VerifiedMaterial {
372 material: m.clone(),
373 signature_b64: B64.encode(&answer[32..]),
374 session_token: session_token(&shared, &m),
375 }
376 }
377}
378
379fn create_private_dir(dir: &Path) -> io::Result<()> {
380 std::fs::create_dir_all(dir)?;
381 #[cfg(unix)]
382 {
383 use std::os::unix::fs::PermissionsExt;
384 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
385 }
386 Ok(())
387}
388
389pub fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
391 use std::io::Write;
392 let mut options = std::fs::OpenOptions::new();
393 options.write(true).create(true).truncate(true);
394 #[cfg(unix)]
395 {
396 use std::os::unix::fs::OpenOptionsExt;
397 options.mode(0o600);
398 }
399 let mut file = options.open(path)?;
400 file.write_all(bytes)?;
401 file.sync_all()
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn challenge_answer_agree_on_token_and_signature_verifies() {
410 let node = NodeIdentity::from_seeds([1; 32], [2; 32]);
411 let c2 = CallHomeChallenge::new().unwrap();
412 let (answer, node_token) = node.answer_challenge("n1", &c2.frame()).unwrap();
413 let v = c2.accept_answer(&answer);
414 assert_eq!(v.session_token, node_token);
415 verify_signature(
416 &node.identity_public_key(),
417 &auth_signing_bytes("n1", &v.material),
418 &v.signature_b64,
419 )
420 .unwrap();
421 assert!(verify_signature(
422 &node.identity_public_key(),
423 &auth_signing_bytes("n2", &v.material),
424 &v.signature_b64
425 )
426 .is_err());
427 }
428
429 #[test]
430 fn each_connection_gets_a_fresh_token() {
431 let node = NodeIdentity::from_seeds([1; 32], [2; 32]);
432 let a = CallHomeChallenge::new().unwrap();
433 let b = CallHomeChallenge::new().unwrap();
434 let (_, ta) = node.answer_challenge("n", &a.frame()).unwrap();
435 let (_, tb) = node.answer_challenge("n", &b.frame()).unwrap();
436 assert_ne!(ta, tb);
437 assert_eq!(ta.len(), 64);
438 }
439
440 #[test]
441 fn keys_persist_with_private_modes_and_public_halves_are_stable() {
442 let dir = std::env::temp_dir().join(format!("hatchery-enroll-keys-{}", std::process::id()));
443 let _ = std::fs::remove_dir_all(&dir);
444 let a = NodeIdentity::load_or_create(&dir).unwrap();
445 let b = NodeIdentity::load_or_create(&dir).unwrap();
446 assert_eq!(a.identity_public_key(), b.identity_public_key());
447 assert_eq!(a.wg_public_key(), b.wg_public_key());
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::PermissionsExt;
451 for p in [
452 NodeIdentity::identity_path(&dir),
453 NodeIdentity::wg_path(&dir),
454 ] {
455 assert_eq!(
456 std::fs::metadata(p).unwrap().permissions().mode() & 0o777,
457 0o600
458 );
459 }
460 assert_eq!(
461 std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
462 0o700
463 );
464 }
465 std::fs::remove_dir_all(&dir).unwrap();
466 }
467
468 #[test]
469 fn signed_request_verifies_and_tamper_fails() {
470 let n = NodeIdentity::from_seeds([3; 32], [4; 32]);
471 let mut r = n.sign_request("x", NodeKind::Qemu, Some("c"), 5).unwrap();
472 verify_signature(&r.identity_public_key, &r.signing_bytes(), &r.signature).unwrap();
473 r.node_id = "y".into();
474 assert!(
475 verify_signature(&r.identity_public_key, &r.signing_bytes(), &r.signature).is_err()
476 );
477 }
478}