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