1use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use base64::Engine;
9use chrono::{Duration as ChronoDuration, Utc};
10use clap::Args as ClapArgs;
12use ed25519_dalek::{Signer, SigningKey};
13use nixfleet_proto::enroll_wire::{BootstrapToken, TokenClaims};
14use rand::RngCore;
15
16#[derive(ClapArgs, Debug)]
17#[command(about = "Mint a bootstrap token for first-boot fleet enrollment.")]
18pub struct Args {
19 #[arg(long)]
21 hostname: String,
22
23 #[arg(long, conflicts_with = "fleet_resolved")]
27 csr_pubkey_fingerprint: Option<String>,
28
29 #[arg(long)]
33 fleet_resolved: Option<PathBuf>,
34
35 #[arg(long)]
37 org_root_key: PathBuf,
38
39 #[arg(long, default_value_t = 24)]
40 validity_hours: u32,
41
42 #[arg(long, default_value_t = 1)]
43 version: u32,
44}
45
46pub fn run(args: Args) -> Result<()> {
47 let signing_key = read_signing_key(&args.org_root_key)?;
48
49 let fingerprint = match (&args.csr_pubkey_fingerprint, &args.fleet_resolved) {
50 (Some(fp), None) => fp.clone(),
51 (None, Some(fleet_path)) => fingerprint_from_fleet(fleet_path, &args.hostname)?,
52 (None, None) => anyhow::bail!(
53 "must pass --csr-pubkey-fingerprint OR --fleet-resolved (declarative path)",
54 ),
55 (Some(_), Some(_)) => unreachable!("clap's `conflicts_with` rejects this combo"),
56 };
57
58 let now = Utc::now();
59 let claims = TokenClaims {
60 hostname: args.hostname,
61 expected_pubkey_fingerprint: fingerprint,
62 issued_at: now,
63 expires_at: now + ChronoDuration::hours(args.validity_hours as i64),
64 nonce: random_nonce(),
65 };
66
67 let claims_json = serde_json::to_string(&claims).context("serialize claims")?;
68 let canonical =
69 nixfleet_canonicalize::canonicalize(&claims_json).context("canonicalize claims")?;
70 let signature = signing_key.sign(canonical.as_bytes());
71 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
72
73 let token = BootstrapToken {
74 version: args.version,
75 claims,
76 signature: sig_b64,
77 };
78
79 let out = serde_json::to_string_pretty(&token)?;
80 println!("{out}");
81 eprintln!("nonce: {}", token.claims.nonce);
82 eprintln!("expiresAt: {}", token.claims.expires_at.to_rfc3339());
83 eprintln!();
84 eprintln!("Add to fleet.nix `bootstrapNonces`, commit, and push:");
85 eprintln!();
86 eprintln!(" {{");
87 eprintln!(" nonce = \"{}\";", token.claims.nonce);
88 eprintln!(" hostname = \"{}\";", token.claims.hostname);
89 eprintln!(
90 " expiresAt = \"{}\";",
91 token.claims.expires_at.to_rfc3339()
92 );
93 eprintln!(
94 " mintedAt = \"{}\";",
95 token.claims.issued_at.to_rfc3339()
96 );
97 if let Ok(user) = std::env::var("USER") {
98 eprintln!(" mintedBy = \"{}\";", user);
99 }
100 eprintln!(" }}");
101 eprintln!();
102 eprintln!("Once CI signs the sidecar (~2 min), deploy the token bytes and");
103 eprintln!("restart the agent.");
104 Ok(())
105}
106
107fn read_signing_key(path: &PathBuf) -> Result<SigningKey> {
108 let bytes =
109 std::fs::read(path).with_context(|| format!("read org root key {}", path.display()))?;
110 if let Ok(orig) = std::str::from_utf8(&bytes)
113 && orig.trim_start().starts_with("-----BEGIN")
114 {
115 let body: String = orig
116 .lines()
117 .filter(|l| !l.starts_with("-----"))
118 .collect::<Vec<_>>()
119 .join("");
120 let der = base64::engine::general_purpose::STANDARD
121 .decode(&body)
122 .context("base64 decode PEM body")?;
123 if der.len() < 34 {
125 anyhow::bail!("PEM too short for PKCS#8 ed25519");
126 }
127 let arr: [u8; 32] = der[der.len() - 32..]
128 .try_into()
129 .map_err(|_| anyhow::anyhow!("PKCS#8 tail wrong size"))?;
130 return Ok(SigningKey::from_bytes(&arr));
131 }
132
133 let trimmed: Vec<u8> = bytes
134 .iter()
135 .copied()
136 .filter(|b| !b.is_ascii_whitespace())
137 .collect();
138 if trimmed.len() == 32 {
139 let arr: [u8; 32] = trimmed[..32].try_into().expect("len 32 checked above");
140 return Ok(SigningKey::from_bytes(&arr));
141 }
142 if let Ok(s) = std::str::from_utf8(&trimmed) {
143 let s = s.trim_start_matches("0x").trim();
144 if s.len() == 64 {
145 let raw = hex::decode(s).context("hex decode org root key")?;
146 let arr: [u8; 32] = raw[..32].try_into().expect("hex-64 decodes to 32 bytes");
147 return Ok(SigningKey::from_bytes(&arr));
148 }
149 }
150 anyhow::bail!("couldn't parse org root key - expected 32 raw bytes, hex, or PEM PKCS#8");
151}
152
153#[cfg(test)]
154#[allow(clippy::items_after_test_module)]
155mod tests {
156 use super::*;
157 use std::io::Write;
158
159 fn pkcs8_pem_for_seed(seed: &[u8; 32]) -> String {
160 let mut der = hex::decode("302e020100300506032b657004220420").unwrap();
161 der.extend_from_slice(seed);
162 let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
163 format!("-----BEGIN PRIVATE KEY-----\n{b64}\n-----END PRIVATE KEY-----\n")
164 }
165
166 #[test]
167 fn read_signing_key_accepts_pkcs8_pem() {
168 let seed = [0x42u8; 32];
169 let pem = pkcs8_pem_for_seed(&seed);
170 let mut tmp = tempfile::NamedTempFile::new().unwrap();
171 tmp.write_all(pem.as_bytes()).unwrap();
172 let key = read_signing_key(&tmp.path().to_path_buf()).expect("PEM should parse");
173 assert_eq!(key.to_bytes(), seed);
174 }
175
176 #[test]
177 fn read_signing_key_accepts_raw_32_bytes() {
178 let seed = [0x55u8; 32];
179 let mut tmp = tempfile::NamedTempFile::new().unwrap();
180 tmp.write_all(&seed).unwrap();
181 let key = read_signing_key(&tmp.path().to_path_buf()).unwrap();
182 assert_eq!(key.to_bytes(), seed);
183 }
184
185 #[test]
186 fn read_signing_key_accepts_hex() {
187 let seed = [0x77u8; 32];
188 let hex = hex::encode(seed);
189 let mut tmp = tempfile::NamedTempFile::new().unwrap();
190 tmp.write_all(hex.as_bytes()).unwrap();
191 let key = read_signing_key(&tmp.path().to_path_buf()).unwrap();
192 assert_eq!(key.to_bytes(), seed);
193 }
194}
195
196fn random_nonce() -> String {
197 let mut buf = [0u8; 16];
198 rand::rng().fill_bytes(&mut buf);
199 hex::encode(buf)
200}
201
202fn fingerprint_from_fleet(fleet_path: &PathBuf, hostname: &str) -> Result<String> {
203 let raw = std::fs::read_to_string(fleet_path)
204 .with_context(|| format!("read fleet.resolved.json {}", fleet_path.display()))?;
205 let fleet: nixfleet_proto::FleetResolved = serde_json::from_str(&raw)
206 .with_context(|| format!("parse fleet.resolved.json {}", fleet_path.display()))?;
207 let host = fleet.hosts.get(hostname).ok_or_else(|| {
208 anyhow::anyhow!("host {hostname} not declared in {}", fleet_path.display())
209 })?;
210 let openssh = host.pubkey.as_deref().ok_or_else(|| {
211 anyhow::anyhow!(
212 "host {hostname} has no `pubkey` declared in fleet.nix - set it before minting"
213 )
214 })?;
215 nixfleet_proto::host_key::fingerprint_openssh_pubkey(openssh)
216 .map_err(|err| anyhow::anyhow!("derive fingerprint from declared pubkey: {err}"))
217}
218
219#[cfg(test)]
220mod fleet_resolved_tests {
221 use super::*;
222 use std::io::Write;
223
224 #[test]
225 fn fingerprint_from_fleet_matches_proto_helper() {
226 let raw_pubkey = [0x42u8; 32];
227 let mut blob = Vec::new();
228 blob.extend_from_slice(&(b"ssh-ed25519".len() as u32).to_be_bytes());
229 blob.extend_from_slice(b"ssh-ed25519");
230 blob.extend_from_slice(&(raw_pubkey.len() as u32).to_be_bytes());
231 blob.extend_from_slice(&raw_pubkey);
232 let b64 = base64::engine::general_purpose::STANDARD.encode(&blob);
233 let openssh = format!("ssh-ed25519 {b64} test@host");
234
235 let fleet_json = serde_json::json!({
236 "schemaVersion": 1,
237 "hosts": {
238 "test-host": {
239 "system": "x86_64-linux",
240 "tags": [],
241 "channel": "stable",
242 "closureHash": null,
243 "pubkey": openssh,
244 }
245 },
246 "channels": {
247 "stable": {
248 "rolloutPolicy": "default",
249 "reconcileIntervalMinutes": 5,
250 "freshnessWindow": 60,
251 "signingIntervalMinutes": 30,
252 "compliance": { "frameworks": [], "mode": "disabled" },
253 }
254 },
255 "rolloutPolicies": {
256 "default": {
257 "strategy": "waves",
258 "waves": [],
259 "healthGate": {},
260 "onHealthFailure": "halt",
261 }
262 },
263 "waves": {},
264 "edges": [],
265 "disruptionBudgets": [],
266 "meta": {
267 "schemaVersion": 1,
268 "signedAt": null,
269 "ciCommit": null,
270 "signatureAlgorithm": null,
271 }
272 });
273 let mut tmp = tempfile::NamedTempFile::new().unwrap();
274 tmp.write_all(fleet_json.to_string().as_bytes()).unwrap();
275
276 let got = fingerprint_from_fleet(&tmp.path().to_path_buf(), "test-host").unwrap();
277 let expected = nixfleet_proto::host_key::fingerprint_openssh_pubkey(&openssh).unwrap();
278 assert_eq!(got, expected);
279 }
280
281 #[test]
282 fn fingerprint_from_fleet_errors_when_host_missing() {
283 let fleet_json = serde_json::json!({
284 "schemaVersion": 1,
285 "hosts": {},
286 "channels": {},
287 "rolloutPolicies": {},
288 "waves": {},
289 "edges": [],
290 "disruptionBudgets": [],
291 "meta": { "schemaVersion": 1, "signedAt": null, "ciCommit": null, "signatureAlgorithm": null }
292 });
293 let mut tmp = tempfile::NamedTempFile::new().unwrap();
294 tmp.write_all(fleet_json.to_string().as_bytes()).unwrap();
295
296 let err = fingerprint_from_fleet(&tmp.path().to_path_buf(), "test-host").unwrap_err();
297 assert!(format!("{err:#}").contains("not declared"), "msg = {err:#}");
298 }
299
300 #[test]
301 fn fingerprint_from_fleet_errors_when_pubkey_missing() {
302 let fleet_json = serde_json::json!({
303 "schemaVersion": 1,
304 "hosts": {
305 "test-host": {
306 "system": "x86_64-linux",
307 "tags": [],
308 "channel": "stable",
309 "closureHash": null,
310 "pubkey": null,
311 }
312 },
313 "channels": {
314 "stable": {
315 "rolloutPolicy": "default",
316 "reconcileIntervalMinutes": 5,
317 "freshnessWindow": 60,
318 "signingIntervalMinutes": 30,
319 "compliance": { "frameworks": [], "mode": "disabled" },
320 }
321 },
322 "rolloutPolicies": {
323 "default": {
324 "strategy": "waves",
325 "waves": [],
326 "healthGate": {},
327 "onHealthFailure": "halt",
328 }
329 },
330 "waves": {},
331 "edges": [],
332 "disruptionBudgets": [],
333 "meta": { "schemaVersion": 1, "signedAt": null, "ciCommit": null, "signatureAlgorithm": null }
334 });
335 let mut tmp = tempfile::NamedTempFile::new().unwrap();
336 tmp.write_all(fleet_json.to_string().as_bytes()).unwrap();
337
338 let err = fingerprint_from_fleet(&tmp.path().to_path_buf(), "test-host").unwrap_err();
339 assert!(format!("{err:#}").contains("no `pubkey`"), "msg = {err:#}");
340 }
341}