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}
137
138/// Bytes a node signs to authenticate to C2 over `material`.
139pub 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
154/// Verify an Ed25519 signature (all values base64).
155pub 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
197/// Keys the node holds. Persisted as two 0600 files in the node's own state directory.
198pub 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    /// Deterministic construction for tests.
212    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    /// Paths of the key files inside `dir`.
228    pub fn identity_path(dir: &Path) -> PathBuf {
229        dir.join("identity.key")
230    }
231
232    /// The WireGuard private key file, in the base64 format `wg` and the node's
233    /// `GATE4AGENT_WG_PRIVATE_KEY` path expect.
234    pub fn wg_path(dir: &Path) -> PathBuf {
235        dir.join("wg.key")
236    }
237
238    /// Load the keys from `dir`, or generate and store them (directory 0700, files 0600).
239    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    /// Load only the signing identity (for a node whose tunnel is set up by a privileged helper
259    /// and which therefore never needs, or reads, the WireGuard private key). The returned
260    /// identity carries a throwaway WireGuard secret that is never written anywhere.
261    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    /// Sign arbitrary bytes with the node identity (callers add their own domain prefix).
268    pub fn sign_bytes(&self, message: &[u8]) -> String {
269        B64.encode(self.identity.sign(message).to_bytes())
270    }
271
272    /// Build and sign an enrollment request.
273    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    /// Answer a call-home challenge. Returns the answer frame and the session token.
295    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
324/// C2 side of one call-home challenge.
325pub struct CallHomeChallenge {
326    challenge: [u8; 32],
327    eph: StaticSecret,
328}
329
330/// What C2 learns from a valid answer: the bytes the registry must verify and the token.
331pub struct VerifiedMaterial {
332    /// Pass to `verify_node_signature` as the challenge.
333    pub material: Vec<u8>,
334    /// Base64 signature from the node.
335    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    /// Combine the node's answer with this challenge. The caller verifies the signature
356    /// against the registered identity key before trusting `session_token`.
357    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
383/// Write a file readable by the owner only.
384pub 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}