Skip to main content

hatchery_enroll/
lib.rs

1//! Enrollment wire and key handling shared by `hatchery-c2` and `hatchery-node`.
2//!
3//! The node generates an Ed25519 identity key and a WireGuard (X25519) key on its
4//! own host and keeps them in its own state directory (files mode 0600). Only the
5//! public halves leave the host. Agents run by the node never get the state dir.
6//!
7//! * [`EnrollmentRequest`] is the signed request sent with a one-time code.
8//! * [`EnrollmentResponse`] carries only public data (tunnel address, C2 public key).
9//! * Signed call-home: after the usual call-home announce, C2 sends a [`CallHomeChallenge`];
10//!   the node answers with an ephemeral key and a signature ([`NodeIdentity::answer_challenge`]).
11//!   Both sides derive the same per-connection session token from an X25519 exchange, so the
12//!   existing mutual challenge-response of the node wire runs unchanged and no standing
13//!   shared secret exists.
14
15use 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/// Status of an enrolled node. Every node starts `Neutral`.
32#[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";
45/// Magic that starts the C2 challenge on a call-home socket (after the announce).
46pub const CHALLENGE_MAGIC: &[u8; 4] = b"HSC1";
47/// Challenge frame: magic + 32-byte challenge + 32-byte C2 ephemeral public key.
48pub const CHALLENGE_LEN: usize = 4 + 32 + 32;
49/// Answer frame: 32-byte node ephemeral public key + 64-byte signature.
50pub 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/// Where a node runs; decides which tunnels and isolation apply.
65#[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/// What a node sends. `signature` covers every other field.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct EnrollmentRequest {
88    pub node_id: String,
89    pub kind: NodeKind,
90    /// WireGuard public key, base64, 32 bytes.
91    pub wg_public_key: String,
92    /// Ed25519 identity public key, base64, 32 bytes.
93    pub identity_public_key: String,
94    pub timestamp_ms: u64,
95    pub nonce: String,
96    /// One-time code the operator handed out.
97    pub code: Option<String>,
98    /// Ed25519 signature, base64, 64 bytes.
99    pub signature: String,
100}
101
102impl EnrollmentRequest {
103    /// Canonical bytes covered by the signature.
104    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/// Public answer to a successful enrollment. No secret of any kind.
123#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
124pub struct EnrollmentResponse {
125    pub node_id: String,
126    pub status: String,
127    /// Address the node uses inside the node tunnel (without prefix length).
128    pub tunnel_ip: Option<String>,
129    /// C2 end of the node tunnel.
130    pub c2_tunnel_ip: Option<String>,
131    pub c2_wg_public_key: Option<String>,
132    /// `host:port` of the C2 WireGuard listener.
133    pub c2_wg_endpoint: Option<String>,
134    /// `ip:port` of the call-home listener inside the tunnel.
135    pub c2_call_home: Option<String>,
136    /// A2 nested crypto: C2 outer-layer public key (base64). Node seals with both upstream keys.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub upstream_c2_public_key: Option<String>,
139    /// A2 nested crypto: HQ recipient public key relayed by C2 (base64). No HQ name/address.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub upstream_recipient_public_key: Option<String>,
142    /// Messenger product base URL the node should use for session accounts (no secret).
143    /// Set on C2 (`HATCHERY_M4A_PRODUCT_URL`); empty means the node has no messenger yet.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub m4a_product_url: Option<String>,
146}
147
148/// Bytes a node signs to authenticate to C2 over `material`.
149pub 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
164/// Verify an Ed25519 signature (all values base64).
165pub 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
207/// Keys the node holds. Persisted as two 0600 files in the node's own state directory.
208pub 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    /// Deterministic construction for tests.
222    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    /// Paths of the key files inside `dir`.
238    pub fn identity_path(dir: &Path) -> PathBuf {
239        dir.join("identity.key")
240    }
241
242    /// The WireGuard private key file, in the base64 format `wg` and the node's
243    /// `GATE4AGENT_WG_PRIVATE_KEY` path expect.
244    pub fn wg_path(dir: &Path) -> PathBuf {
245        dir.join("wg.key")
246    }
247
248    /// Load the keys from `dir`, or generate and store them (directory 0700, files 0600).
249    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    /// Load only the signing identity (for a node whose tunnel is set up by a privileged helper
269    /// and which therefore never needs, or reads, the WireGuard private key). The returned
270    /// identity carries a throwaway WireGuard secret that is never written anywhere.
271    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    /// Sign arbitrary bytes with the node identity (callers add their own domain prefix).
278    pub fn sign_bytes(&self, message: &[u8]) -> String {
279        B64.encode(self.identity.sign(message).to_bytes())
280    }
281
282    /// Build and sign an enrollment request.
283    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    /// Answer a call-home challenge. Returns the answer frame and the session token.
305    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
334/// C2 side of one call-home challenge.
335pub struct CallHomeChallenge {
336    challenge: [u8; 32],
337    eph: StaticSecret,
338}
339
340/// What C2 learns from a valid answer: the bytes the registry must verify and the token.
341pub struct VerifiedMaterial {
342    /// Pass to `verify_node_signature` as the challenge.
343    pub material: Vec<u8>,
344    /// Base64 signature from the node.
345    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    /// Combine the node's answer with this challenge. The caller verifies the signature
366    /// against the registered identity key before trusting `session_token`.
367    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
393/// Write a file readable by the owner only.
394pub 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}