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}
143
144/// Bytes a node signs to authenticate to C2 over `material`.
145pub 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
160/// Verify an Ed25519 signature (all values base64).
161pub 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
203/// Keys the node holds. Persisted as two 0600 files in the node's own state directory.
204pub 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    /// Deterministic construction for tests.
218    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    /// Paths of the key files inside `dir`.
234    pub fn identity_path(dir: &Path) -> PathBuf {
235        dir.join("identity.key")
236    }
237
238    /// The WireGuard private key file, in the base64 format `wg` and the node's
239    /// `GATE4AGENT_WG_PRIVATE_KEY` path expect.
240    pub fn wg_path(dir: &Path) -> PathBuf {
241        dir.join("wg.key")
242    }
243
244    /// Load the keys from `dir`, or generate and store them (directory 0700, files 0600).
245    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    /// Load only the signing identity (for a node whose tunnel is set up by a privileged helper
265    /// and which therefore never needs, or reads, the WireGuard private key). The returned
266    /// identity carries a throwaway WireGuard secret that is never written anywhere.
267    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    /// Sign arbitrary bytes with the node identity (callers add their own domain prefix).
274    pub fn sign_bytes(&self, message: &[u8]) -> String {
275        B64.encode(self.identity.sign(message).to_bytes())
276    }
277
278    /// Build and sign an enrollment request.
279    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    /// Answer a call-home challenge. Returns the answer frame and the session token.
301    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
330/// C2 side of one call-home challenge.
331pub struct CallHomeChallenge {
332    challenge: [u8; 32],
333    eph: StaticSecret,
334}
335
336/// What C2 learns from a valid answer: the bytes the registry must verify and the token.
337pub struct VerifiedMaterial {
338    /// Pass to `verify_node_signature` as the challenge.
339    pub material: Vec<u8>,
340    /// Base64 signature from the node.
341    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    /// Combine the node's answer with this challenge. The caller verifies the signature
362    /// against the registered identity key before trusting `session_token`.
363    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
389/// Write a file readable by the owner only.
390pub 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}