Skip to main content

hatchery_build/
wire.rs

1//! Wire types: the node's signed request, the host request C2 forwards, and the signed receipt.
2
3use base64::engine::general_purpose::STANDARD as B64;
4use base64::Engine;
5use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier, VerifyingKey};
6use serde::{Deserialize, Serialize};
7
8pub const REQUEST_DOMAIN: &[u8] = b"hatchery-build-request-v1\n";
9pub const RECEIPT_DOMAIN: &[u8] = b"hatchery-build-receipt-v1\n";
10/// How far a node's request timestamp may be from the C2 clock.
11pub const MAX_SKEW_MS: u64 = 60_000;
12
13pub fn valid_name(value: &str, max: usize) -> bool {
14    !value.is_empty()
15        && value.len() <= max
16        && value.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.'))
17}
18
19pub fn valid_source_ref(value: &str) -> bool {
20    !value.is_empty()
21        && value.len() <= 128
22        && !value.contains("..")
23        && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'/'))
24}
25
26/// What a node sends to C2 (signed with its enrolled identity key).
27#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
28pub struct SignedBuildRequest {
29    pub node_id: String,
30    pub recipe: String,
31    #[serde(default)]
32    pub source_ref: Option<String>,
33    pub ts_ms: u64,
34    pub nonce: String,
35    pub signature: String,
36}
37
38impl SignedBuildRequest {
39    pub fn signing_bytes(node_id: &str, recipe: &str, source_ref: Option<&str>, ts_ms: u64, nonce: &str) -> Vec<u8> {
40        let mut out = REQUEST_DOMAIN.to_vec();
41        for part in [node_id, recipe, source_ref.unwrap_or(""), &ts_ms.to_string(), nonce] {
42            out.extend_from_slice(part.as_bytes());
43            out.push(b'\n');
44        }
45        out
46    }
47
48    pub fn well_formed(&self) -> bool {
49        valid_name(&self.node_id, 64)
50            && valid_name(&self.recipe, 64)
51            && self.source_ref.as_deref().is_none_or(valid_source_ref)
52            && !self.nonce.is_empty()
53            && self.nonce.len() <= 64
54            && self.nonce.bytes().all(|b| b.is_ascii_alphanumeric())
55    }
56}
57
58/// What C2 forwards to the build host (after its own admission check).
59#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
60pub struct HostRequest {
61    pub node_id: String,
62    pub recipe: String,
63    #[serde(default)]
64    pub source_ref: Option<String>,
65}
66
67#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
68pub struct BuildReceipt {
69    pub node_id: String,
70    pub recipe: String,
71    pub source_ref: Option<String>,
72    pub artifact_sha256: String,
73    pub artifact_size: u64,
74    pub built_at_ms: u64,
75    /// Public half of the build host's signing key. The private half never leaves the host.
76    pub signer_public_key: String,
77    pub signature: String,
78}
79
80#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
81pub struct BuildResponse {
82    pub receipt: BuildReceipt,
83    pub artifact_b64: String,
84}
85
86#[derive(Debug, thiserror::Error, Eq, PartialEq)]
87pub enum ReceiptError {
88    #[error("receipt is signed by a different key than the trusted signer")]
89    UntrustedSigner,
90    #[error("receipt signature is invalid")]
91    BadSignature,
92    #[error("artifact does not match the receipt hash")]
93    ArtifactMismatch,
94}
95
96impl BuildReceipt {
97    pub fn signing_bytes(&self) -> Vec<u8> {
98        let mut out = RECEIPT_DOMAIN.to_vec();
99        for part in [
100            self.node_id.as_str(),
101            self.recipe.as_str(),
102            self.source_ref.as_deref().unwrap_or(""),
103            self.artifact_sha256.as_str(),
104            &self.artifact_size.to_string(),
105            &self.built_at_ms.to_string(),
106        ] {
107            out.extend_from_slice(part.as_bytes());
108            out.push(b'\n');
109        }
110        out
111    }
112
113    pub fn sign(mut self, key: &SigningKey) -> Self {
114        self.signer_public_key = B64.encode(key.verifying_key().to_bytes());
115        self.signature = B64.encode(key.sign(&self.signing_bytes()).to_bytes());
116        self
117    }
118
119    /// Verify against the operator-provided public key of the build host, and (when given)
120    /// that the delivered artifact has the signed hash.
121    pub fn verify(&self, trusted_public_key: &str, artifact: Option<&[u8]>) -> Result<(), ReceiptError> {
122        if self.signer_public_key != trusted_public_key {
123            return Err(ReceiptError::UntrustedSigner);
124        }
125        let key = hatchery_enroll::decode_key32(trusted_public_key).map_err(|_| ReceiptError::BadSignature)?;
126        let key = VerifyingKey::from_bytes(&key).map_err(|_| ReceiptError::BadSignature)?;
127        let sig = B64.decode(&self.signature).map_err(|_| ReceiptError::BadSignature)?;
128        let sig = Signature::from_slice(&sig).map_err(|_| ReceiptError::BadSignature)?;
129        key.verify(&self.signing_bytes(), &sig).map_err(|_| ReceiptError::BadSignature)?;
130        if let Some(bytes) = artifact {
131            use sha2::{Digest, Sha256};
132            let digest = hex(&Sha256::digest(bytes));
133            if digest != self.artifact_sha256 || bytes.len() as u64 != self.artifact_size {
134                return Err(ReceiptError::ArtifactMismatch);
135            }
136        }
137        Ok(())
138    }
139}
140
141pub fn hex(bytes: &[u8]) -> String {
142    bytes.iter().map(|b| format!("{b:02x}")).collect()
143}