nixfleet_cli/commands/
mint_token.rs

1//! Operator-side bootstrap-token minter. Signs a `TokenClaims` block with the
2//! org root key, derives the host fingerprint from either fleet.resolved or
3//! a flag override.
4
5use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use base64::Engine;
9use chrono::{Duration as ChronoDuration, Utc};
10// Alias avoids clashing with `struct Args` below.
11use 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    /// Must match the fleet.nix entry + CSR CN at enroll time.
20    #[arg(long)]
21    hostname: String,
22
23    /// base64 SHA-256 of the CSR's pubkey; binds the token to the key.
24    /// Mutually exclusive with `--fleet-resolved`. Flag-driven path is for
25    /// dev/test; declarative fleets use `--fleet-resolved`.
26    #[arg(long, conflicts_with = "fleet_resolved")]
27    csr_pubkey_fingerprint: Option<String>,
28
29    /// Path to signed `releases/fleet.resolved.json`. Derives the fingerprint
30    /// from `hosts.<hostname>.pubkey` so the token is scoped to what the
31    /// operator declared, no manual SHA-256 dance.
32    #[arg(long)]
33    fleet_resolved: Option<PathBuf>,
34
35    /// Org root ed25519 private key: PKCS#8 PEM, 32 raw bytes, or hex.
36    #[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    // FOOTGUN: detect PEM before whitespace strip - strip would collapse
111    // BEGIN/body/END lines.
112    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        // PKCS#8 ed25519 OCTET STRING tail: 0x04 0x20 + 32 bytes.
124        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}