1use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5use chrono::{DateTime, Duration as ChronoDuration, Utc};
6use ed25519_dalek::{Signature, VerifyingKey};
7use nixfleet_proto::{BootstrapNonces, FleetResolved, Revocations, RolloutManifest, TrustedPubkey};
8use serde::de::DeserializeOwned;
9use sha2::{Digest, Sha256};
10use std::time::Duration;
11use thiserror::Error;
12
13pub trait SignedSidecar {
16 fn schema_version(&self) -> u32;
17 fn signed_at(&self) -> Option<DateTime<Utc>>;
18}
19
20impl SignedSidecar for FleetResolved {
21 fn schema_version(&self) -> u32 {
22 self.schema_version
23 }
24 fn signed_at(&self) -> Option<DateTime<Utc>> {
25 self.meta.signed_at
26 }
27}
28
29impl SignedSidecar for BootstrapNonces {
30 fn schema_version(&self) -> u32 {
31 self.schema_version
32 }
33 fn signed_at(&self) -> Option<DateTime<Utc>> {
34 self.meta.signed_at
35 }
36}
37
38impl SignedSidecar for Revocations {
39 fn schema_version(&self) -> u32 {
40 self.schema_version
41 }
42 fn signed_at(&self) -> Option<DateTime<Utc>> {
43 self.meta.signed_at
44 }
45}
46
47impl SignedSidecar for RolloutManifest {
48 fn schema_version(&self) -> u32 {
49 self.schema_version
50 }
51 fn signed_at(&self) -> Option<DateTime<Utc>> {
52 self.meta.signed_at
53 }
54}
55
56const ACCEPTED_SCHEMA_VERSION: u32 = 1;
57
58pub struct Verified<T> {
68 inner: T,
69 signed_at: DateTime<Utc>,
70}
71
72impl<T> Verified<T> {
73 pub fn inner(&self) -> &T {
74 &self.inner
75 }
76
77 pub fn into_inner(self) -> T {
78 self.inner
79 }
80
81 pub fn signed_at(&self) -> DateTime<Utc> {
82 self.signed_at
83 }
84}
85
86impl<T: std::fmt::Debug> std::fmt::Debug for Verified<T> {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("Verified")
89 .field("signed_at", &self.signed_at)
90 .field("inner", &self.inner)
91 .finish()
92 }
93}
94
95impl<T: Clone> Clone for Verified<T> {
96 fn clone(&self) -> Self {
97 Self {
98 inner: self.inner.clone(),
99 signed_at: self.signed_at,
100 }
101 }
102}
103
104#[cfg(any(test, feature = "test-helpers"))]
111impl<T> Verified<T> {
112 pub fn unverified_for_tests(inner: T, signed_at: DateTime<Utc>) -> Self {
113 Self { inner, signed_at }
114 }
115}
116
117pub type VerifiedFleet = Verified<FleetResolved>;
119
120pub type VerifiedRolloutManifest = Verified<RolloutManifest>;
122
123#[derive(Debug, Error)]
124pub enum VerifyError {
125 #[error("fleet.resolved parse failed: {0}")]
126 Parse(#[from] serde_json::Error),
127
128 #[error("signature does not verify against any declared trust root")]
129 BadSignature,
130
131 #[error("artifact is unsigned (meta.signedAt is null)")]
132 NotSigned,
133
134 #[error("stale artifact: signedAt={signed_at}, now={now}, window={window:?}")]
135 Stale {
136 signed_at: DateTime<Utc>,
137 now: DateTime<Utc>,
138 window: Duration,
139 },
140
141 #[error(
142 "future-dated artifact: signedAt={signed_at} is more than {slack_secs}s ahead of now={now} \
143 (clock skew tolerance is {slack_secs}s; pre-signing suggests CI key compromise - \
144 rotate via reject_before)"
145 )]
146 FutureDated {
147 signed_at: DateTime<Utc>,
148 now: DateTime<Utc>,
149 slack_secs: i64,
150 },
151
152 #[error(
153 "artifact signed at {signed_at} is older than reject_before {reject_before} (compromise switch)"
154 )]
155 RejectedBeforeTimestamp {
156 signed_at: DateTime<Utc>,
157 reject_before: DateTime<Utc>,
158 },
159
160 #[error("unsupported schemaVersion: {0} (accepted: 1)")]
161 SchemaVersionUnsupported(u32),
162
163 #[error("JCS re-canonicalization failed: {0}")]
164 Canonicalize(#[source] anyhow::Error),
165
166 #[error("unsupported signature algorithm: {algorithm} (supported: ed25519, ecdsa-p256)")]
167 UnsupportedAlgorithm { algorithm: String },
168
169 #[error("trusted pubkey material is malformed ({algorithm}): {reason}")]
170 BadPubkeyEncoding { algorithm: String, reason: String },
171
172 #[error("no trust roots configured for artifact verification")]
173 NoTrustRoots,
174}
175
176pub fn verify_signed_sidecar<T: SignedSidecar + DeserializeOwned>(
180 signed_bytes: &[u8],
181 signature: &[u8],
182 trusted_keys: &[TrustedPubkey],
183 now: DateTime<Utc>,
184 freshness_window: Duration,
185 reject_before: Option<DateTime<Utc>>,
186) -> Result<Verified<T>, VerifyError> {
187 let canonical = verify_signature_against_trust_roots(signed_bytes, signature, trusted_keys)?;
188 finish_sidecar_verification(&canonical, now, freshness_window, reject_before)
189}
190
191pub fn verify_artifact(
192 signed_bytes: &[u8],
193 signature: &[u8],
194 trusted_keys: &[TrustedPubkey],
195 now: DateTime<Utc>,
196 freshness_window: Duration,
197 reject_before: Option<DateTime<Utc>>,
198) -> Result<VerifiedFleet, VerifyError> {
199 let mut verified: VerifiedFleet = verify_signed_sidecar(
200 signed_bytes,
201 signature,
202 trusted_keys,
203 now,
204 freshness_window,
205 reject_before,
206 )?;
207 nixfleet_proto::normalize_rollout_policies(&mut verified.inner);
217 Ok(verified)
218}
219
220fn verify_ed25519(
223 canonical_bytes: &[u8],
224 signature: &[u8],
225 public_b64: &str,
226) -> Result<(), VerifyError> {
227 let public_bytes =
228 BASE64_STANDARD
229 .decode(public_b64)
230 .map_err(|e| VerifyError::BadPubkeyEncoding {
231 algorithm: "ed25519".into(),
232 reason: format!("base64 decode failed: {e}"),
233 })?;
234 let public_array: [u8; 32] =
235 public_bytes
236 .try_into()
237 .map_err(|v: Vec<u8>| VerifyError::BadPubkeyEncoding {
238 algorithm: "ed25519".into(),
239 reason: format!("expected 32 bytes, got {}", v.len()),
240 })?;
241 let verifying_key =
242 VerifyingKey::from_bytes(&public_array).map_err(|e| VerifyError::BadPubkeyEncoding {
243 algorithm: "ed25519".into(),
244 reason: e.to_string(),
245 })?;
246
247 let sig_array: [u8; 64] = signature
248 .try_into()
249 .map_err(|_| VerifyError::BadSignature)?;
250 let sig = Signature::from_bytes(&sig_array);
251
252 verifying_key
253 .verify_strict(canonical_bytes, &sig)
254 .map_err(|_| VerifyError::BadSignature)
255}
256
257fn verify_ecdsa_p256(
260 canonical_bytes: &[u8],
261 signature: &[u8],
262 public_b64: &str,
263) -> Result<(), VerifyError> {
264 use p256::EncodedPoint;
265 use p256::ecdsa::signature::Verifier;
266 use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey};
267
268 let public_bytes =
269 BASE64_STANDARD
270 .decode(public_b64)
271 .map_err(|e| VerifyError::BadPubkeyEncoding {
272 algorithm: "ecdsa-p256".into(),
273 reason: format!("base64 decode failed: {e}"),
274 })?;
275 if public_bytes.len() != 64 {
276 return Err(VerifyError::BadPubkeyEncoding {
277 algorithm: "ecdsa-p256".into(),
278 reason: format!(
279 "expected 64 bytes (X ‖ Y uncompressed), got {}",
280 public_bytes.len()
281 ),
282 });
283 }
284
285 let mut tagged = [0u8; 65];
287 tagged[0] = 0x04;
288 tagged[1..].copy_from_slice(&public_bytes);
289 let point = EncodedPoint::from_bytes(tagged).map_err(|e| VerifyError::BadPubkeyEncoding {
290 algorithm: "ecdsa-p256".into(),
291 reason: format!("SEC1 decode failed: {e}"),
292 })?;
293 let verifying_key = P256VerifyingKey::from_encoded_point(&point).map_err(|e| {
294 VerifyError::BadPubkeyEncoding {
295 algorithm: "ecdsa-p256".into(),
296 reason: format!("not on curve / invalid point: {e}"),
297 }
298 })?;
299
300 let sig = P256Signature::from_slice(signature).map_err(|_| VerifyError::BadSignature)?;
301 let sig = sig.normalize_s().unwrap_or(sig);
302
303 verifying_key
304 .verify(canonical_bytes, &sig)
305 .map_err(|_| VerifyError::BadSignature)
306}
307
308pub fn verify_revocations(
309 signed_bytes: &[u8],
310 signature: &[u8],
311 trusted_keys: &[TrustedPubkey],
312 now: DateTime<Utc>,
313 freshness_window: Duration,
314 reject_before: Option<DateTime<Utc>>,
315) -> Result<Verified<Revocations>, VerifyError> {
316 verify_signed_sidecar(
317 signed_bytes,
318 signature,
319 trusted_keys,
320 now,
321 freshness_window,
322 reject_before,
323 )
324}
325
326pub fn verify_bootstrap_nonces(
329 signed_bytes: &[u8],
330 signature: &[u8],
331 trusted_keys: &[TrustedPubkey],
332 now: DateTime<Utc>,
333 freshness_window: Duration,
334 reject_before: Option<DateTime<Utc>>,
335) -> Result<Verified<BootstrapNonces>, VerifyError> {
336 verify_signed_sidecar(
337 signed_bytes,
338 signature,
339 trusted_keys,
340 now,
341 freshness_window,
342 reject_before,
343 )
344}
345
346pub fn verify_rollout_manifest(
351 signed_bytes: &[u8],
352 signature: &[u8],
353 trusted_keys: &[TrustedPubkey],
354 now: DateTime<Utc>,
355 freshness_window: Duration,
356 reject_before: Option<DateTime<Utc>>,
357) -> Result<VerifiedRolloutManifest, VerifyError> {
358 verify_signed_sidecar(
359 signed_bytes,
360 signature,
361 trusted_keys,
362 now,
363 freshness_window,
364 reject_before,
365 )
366}
367
368pub fn compute_canonical_hash<T: serde::Serialize>(value: &T) -> Result<String, VerifyError> {
373 let raw = serde_json::to_string(value)?;
374 let canonical = nixfleet_canonicalize::canonicalize(&raw).map_err(VerifyError::Canonicalize)?;
375 let digest = Sha256::digest(canonical.as_bytes());
376 Ok(hex_lowercase(&digest))
377}
378
379pub fn canonical_hash_from_bytes(bytes: &[u8]) -> Result<String, VerifyError> {
384 let s = std::str::from_utf8(bytes).map_err(|err| {
385 VerifyError::Canonicalize(anyhow::anyhow!("input not valid UTF-8: {err}"))
386 })?;
387 let canonical = nixfleet_canonicalize::canonicalize(s).map_err(VerifyError::Canonicalize)?;
388 let digest = Sha256::digest(canonical.as_bytes());
389 Ok(hex_lowercase(&digest))
390}
391
392fn hex_lowercase(bytes: &[u8]) -> String {
393 const HEX: &[u8; 16] = b"0123456789abcdef";
394 let mut out = String::with_capacity(bytes.len() * 2);
395 for b in bytes {
396 out.push(HEX[(b >> 4) as usize] as char);
397 out.push(HEX[(b & 0x0f) as usize] as char);
398 }
399 out
400}
401
402fn verify_signature_against_trust_roots(
403 signed_bytes: &[u8],
404 signature: &[u8],
405 trusted_keys: &[TrustedPubkey],
406) -> Result<String, VerifyError> {
407 if trusted_keys.is_empty() {
408 return Err(VerifyError::NoTrustRoots);
409 }
410
411 let raw_str = std::str::from_utf8(signed_bytes).map_err(|e| {
412 VerifyError::Parse(serde_json::Error::io(std::io::Error::new(
413 std::io::ErrorKind::InvalidData,
414 e,
415 )))
416 })?;
417 let _value: serde_json::Value = serde_json::from_str(raw_str)?;
418 let canonical =
419 nixfleet_canonicalize::canonicalize(raw_str).map_err(VerifyError::Canonicalize)?;
420
421 let mut attempted_any_supported = false;
422 for key in trusted_keys {
423 match key.algorithm.as_str() {
424 "ed25519" => {
425 attempted_any_supported = true;
426 if verify_ed25519(canonical.as_bytes(), signature, &key.public).is_ok() {
427 return Ok(canonical);
428 }
429 }
430 "ecdsa-p256" => {
431 attempted_any_supported = true;
432 if verify_ecdsa_p256(canonical.as_bytes(), signature, &key.public).is_ok() {
433 return Ok(canonical);
434 }
435 }
436 _other => continue,
437 }
438 }
439
440 if !attempted_any_supported {
441 return Err(VerifyError::UnsupportedAlgorithm {
442 algorithm: trusted_keys[0].algorithm.clone(),
443 });
444 }
445 Err(VerifyError::BadSignature)
446}
447
448fn finish_sidecar_verification<T: SignedSidecar + DeserializeOwned>(
452 canonical: &str,
453 now: DateTime<Utc>,
454 freshness_window: Duration,
455 reject_before: Option<DateTime<Utc>>,
456) -> Result<Verified<T>, VerifyError> {
457 let payload: T = serde_json::from_str(canonical)?;
458 if payload.schema_version() != ACCEPTED_SCHEMA_VERSION {
459 return Err(VerifyError::SchemaVersionUnsupported(
460 payload.schema_version(),
461 ));
462 }
463
464 let signed_at = payload.signed_at().ok_or(VerifyError::NotSigned)?;
465
466 if let Some(rb) = reject_before
467 && signed_at < rb
468 {
469 return Err(VerifyError::RejectedBeforeTimestamp {
470 signed_at,
471 reject_before: rb,
472 });
473 }
474
475 let window = ChronoDuration::from_std(freshness_window)
476 .expect("freshness_window fits in i64 nanoseconds - multi-century windows are a bug");
477 let effective_window = window + ChronoDuration::seconds(CLOCK_SKEW_SLACK_SECS);
478 let elapsed = now - signed_at;
479 if elapsed > effective_window {
480 return Err(VerifyError::Stale {
481 signed_at,
482 now,
483 window: freshness_window,
484 });
485 }
486 if -elapsed > ChronoDuration::seconds(CLOCK_SKEW_SLACK_SECS) {
487 return Err(VerifyError::FutureDated {
488 signed_at,
489 now,
490 slack_secs: CLOCK_SKEW_SLACK_SECS,
491 });
492 }
493
494 Ok(Verified {
495 inner: payload,
496 signed_at,
497 })
498}
499
500pub const CLOCK_SKEW_SLACK_SECS: i64 = 60;