nixfleet_verify_artifact/
main.rs

1//! `nixfleet-verify-artifact` - offline verifier CLI. Exit codes: 0 verified,
2//! 1 verify error, 2 argument / I/O / parse error.
3
4use std::path::PathBuf;
5use std::process::ExitCode;
6use std::time::Duration;
7
8use chrono::{DateTime, Utc};
9use clap::{Parser, Subcommand};
10use nixfleet_proto::{RolloutId, TrustConfig};
11use nixfleet_reconciler::evidence::{SignatureStatus, verify_canonical_payload};
12use nixfleet_reconciler::{verify_artifact, verify_rollout_manifest};
13
14#[derive(Parser, Debug)]
15#[command(name = "nixfleet-verify-artifact", version)]
16struct Args {
17    #[command(subcommand)]
18    cmd: Cmd,
19}
20
21#[derive(Subcommand, Debug)]
22enum Cmd {
23    /// Verify a signed fleet.resolved artifact against a trust.json.
24    Artifact {
25        #[arg(long)]
26        artifact: PathBuf,
27        #[arg(long)]
28        signature: PathBuf,
29        #[arg(long)]
30        trust_file: PathBuf,
31        #[arg(long)]
32        now: DateTime<Utc>,
33        #[arg(long)]
34        freshness_window_secs: u64,
35    },
36    /// Verify a signed manifest; parsed identity matches `--rollout-id`.
37    RolloutManifest {
38        #[arg(long)]
39        manifest: PathBuf,
40        #[arg(long)]
41        signature: PathBuf,
42        #[arg(long)]
43        trust_file: PathBuf,
44        #[arg(long)]
45        now: DateTime<Utc>,
46        #[arg(long)]
47        freshness_window_secs: u64,
48        /// Canonical RFC-0008 ยง6.3 RolloutId `"channel@channel_ref"`.
49        /// Catches mix-and-match / rename attacks: a manifest signed
50        /// under one identity served at a different filename fails this
51        /// discriminator before the bytes reach a downstream consumer.
52        #[arg(long)]
53        rollout_id: String,
54    },
55    /// Verify a signed probe-output payload against a host's pubkey.
56    Probe {
57        /// JSON payload; will be JCS-canonicalized then verified.
58        #[arg(long)]
59        payload: PathBuf,
60        /// File containing the base64 ed25519 signature.
61        #[arg(long)]
62        signature: PathBuf,
63        /// File containing the host's OpenSSH-format `ssh-ed25519` pubkey.
64        #[arg(long)]
65        pubkey: PathBuf,
66    },
67}
68
69fn main() -> ExitCode {
70    match Args::parse().cmd {
71        Cmd::Artifact {
72            artifact,
73            signature,
74            trust_file,
75            now,
76            freshness_window_secs,
77        } => run_artifact(artifact, signature, trust_file, now, freshness_window_secs),
78        Cmd::RolloutManifest {
79            manifest,
80            signature,
81            trust_file,
82            now,
83            freshness_window_secs,
84            rollout_id,
85        } => run_rollout_manifest(
86            manifest,
87            signature,
88            trust_file,
89            now,
90            freshness_window_secs,
91            rollout_id,
92        ),
93        Cmd::Probe {
94            payload,
95            signature,
96            pubkey,
97        } => run_probe(payload, signature, pubkey),
98    }
99}
100
101fn run_rollout_manifest(
102    manifest_path: PathBuf,
103    signature_path: PathBuf,
104    trust_file: PathBuf,
105    now: DateTime<Utc>,
106    freshness_window_secs: u64,
107    expected_rollout_id: String,
108) -> ExitCode {
109    let manifest_bytes = match std::fs::read(&manifest_path) {
110        Ok(v) => v,
111        Err(err) => return arg_error(format!("read manifest {}: {err}", manifest_path.display())),
112    };
113    let signature_bytes = match std::fs::read(&signature_path) {
114        Ok(v) => v,
115        Err(err) => {
116            return arg_error(format!(
117                "read signature {}: {err}",
118                signature_path.display()
119            ));
120        }
121    };
122    let trust_raw = match std::fs::read_to_string(&trust_file) {
123        Ok(v) => v,
124        Err(err) => return arg_error(format!("read trust-file {}: {err}", trust_file.display())),
125    };
126    let trust: TrustConfig = match serde_json::from_str(&trust_raw) {
127        Ok(t) => t,
128        Err(err) => return arg_error(format!("parse trust-file {}: {err}", trust_file.display())),
129    };
130    if trust.schema_version != TrustConfig::CURRENT_SCHEMA_VERSION {
131        return arg_error(format!(
132            "trust-file schemaVersion {} unsupported (accepted: {})",
133            trust.schema_version,
134            TrustConfig::CURRENT_SCHEMA_VERSION
135        ));
136    }
137
138    let manifest = match verify_rollout_manifest(
139        &manifest_bytes,
140        &signature_bytes,
141        &trust.ci_release_key.active_keys_at(now),
142        now,
143        Duration::from_secs(freshness_window_secs),
144        trust.ci_release_key.reject_before,
145    ) {
146        Ok(v) => v.into_inner(),
147        Err(err) => {
148            eprintln!("{err}");
149            return ExitCode::from(1);
150        }
151    };
152
153    let parsed_id = RolloutId::new(&manifest.channel, &manifest.channel_ref);
154    if parsed_id.as_str() != expected_rollout_id {
155        eprintln!(
156            "rolloutId mismatch: expected {expected_rollout_id}, parsed {parsed}",
157            parsed = parsed_id.as_str(),
158        );
159        return ExitCode::from(1);
160    }
161
162    println!(
163        "schemaVersion={} channel={} hostSet={} fleetResolvedHash={} rolloutId={}",
164        manifest.schema_version,
165        manifest.channel,
166        manifest.host_set.len(),
167        manifest.fleet_resolved_hash,
168        parsed_id.as_str(),
169    );
170    ExitCode::SUCCESS
171}
172
173fn run_artifact(
174    artifact: PathBuf,
175    signature: PathBuf,
176    trust_file: PathBuf,
177    now: DateTime<Utc>,
178    freshness_window_secs: u64,
179) -> ExitCode {
180    let artifact_bytes = match std::fs::read(&artifact) {
181        Ok(v) => v,
182        Err(err) => return arg_error(format!("read artifact {}: {err}", artifact.display())),
183    };
184    let signature_bytes = match std::fs::read(&signature) {
185        Ok(v) => v,
186        Err(err) => return arg_error(format!("read signature {}: {err}", signature.display())),
187    };
188    let trust_raw = match std::fs::read_to_string(&trust_file) {
189        Ok(v) => v,
190        Err(err) => return arg_error(format!("read trust-file {}: {err}", trust_file.display())),
191    };
192    let trust: TrustConfig = match serde_json::from_str(&trust_raw) {
193        Ok(t) => t,
194        Err(err) => return arg_error(format!("parse trust-file {}: {err}", trust_file.display())),
195    };
196    if trust.schema_version != TrustConfig::CURRENT_SCHEMA_VERSION {
197        return arg_error(format!(
198            "trust-file schemaVersion {} unsupported (accepted: {})",
199            trust.schema_version,
200            TrustConfig::CURRENT_SCHEMA_VERSION
201        ));
202    }
203
204    match verify_artifact(
205        &artifact_bytes,
206        &signature_bytes,
207        &trust.ci_release_key.active_keys_at(now),
208        now,
209        Duration::from_secs(freshness_window_secs),
210        trust.ci_release_key.reject_before,
211    ) {
212        Ok(verified) => {
213            let fleet = verified.into_inner();
214            println!(
215                "schemaVersion={} hosts={}",
216                fleet.schema_version,
217                fleet.hosts.len()
218            );
219            ExitCode::SUCCESS
220        }
221        Err(err) => {
222            eprintln!("{err}");
223            ExitCode::from(1)
224        }
225    }
226}
227
228fn run_probe(payload: PathBuf, signature: PathBuf, pubkey: PathBuf) -> ExitCode {
229    let payload_raw = match std::fs::read_to_string(&payload) {
230        Ok(v) => v,
231        Err(err) => return arg_error(format!("read payload {}: {err}", payload.display())),
232    };
233    let payload_value: serde_json::Value = match serde_json::from_str(&payload_raw) {
234        Ok(v) => v,
235        Err(err) => return arg_error(format!("parse payload {}: {err}", payload.display())),
236    };
237    let canonical = match serde_jcs::to_vec(&payload_value) {
238        Ok(v) => v,
239        Err(err) => return arg_error(format!("canonicalize payload: {err}")),
240    };
241    let sig_b64 = match std::fs::read_to_string(&signature) {
242        Ok(v) => v.trim().to_string(),
243        Err(err) => return arg_error(format!("read signature {}: {err}", signature.display())),
244    };
245    let pubkey_str = match std::fs::read_to_string(&pubkey) {
246        Ok(v) => v.trim().to_string(),
247        Err(err) => return arg_error(format!("read pubkey {}: {err}", pubkey.display())),
248    };
249
250    let status = verify_canonical_payload(&canonical, Some(&pubkey_str), Some(&sig_b64));
251    println!(
252        "{}",
253        serde_json::to_string(&status).expect("SignatureStatus serialize")
254    );
255    match status {
256        SignatureStatus::Verified => ExitCode::SUCCESS,
257        _ => ExitCode::from(1),
258    }
259}
260
261fn arg_error(msg: String) -> ExitCode {
262    eprintln!("{msg}");
263    ExitCode::from(2)
264}