1use 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";
10pub 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#[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#[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 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 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}