nixfleet_reconciler/
verify.rs

1//! Sidecar fetch + verify + freshness-gate.
2
3use 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
13/// Signed sidecar under `ciReleaseKey`. Drives the
14/// canonicalize -> verify -> freshness-gate pipeline.
15pub 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
58/// Type-level witness that bytes passed signature + freshness + schema +
59/// `reject_before` gates against trust roots. No public constructor; the
60/// only way to obtain one is via a `verify_*` function in this module.
61/// `#[derive(Deserialize)]` is intentionally omitted so the witness cannot
62/// be fabricated from a serialized form.
63///
64/// A function taking `&Verified<T>` is statically guaranteed to have
65/// received a payload that was verified against the trust config; future
66/// "skip verify" shortcuts cannot compile without crossing the same gate.
67pub 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/// **TEST ONLY.** Bypasses signature verification. Available only when
105/// the lib is compiled with `#[cfg(test)]` (internal tests) or with the
106/// `test-helpers` feature flag (downstream crates' tests, enabled via
107/// `[dev-dependencies]` so production builds cannot reach it).
108/// Used by gate / applier tests that need a `SignedManifestSet` but
109/// don't exercise the signature path itself.
110#[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
117/// Concrete alias for the top-level fleet manifest.
118pub type VerifiedFleet = Verified<FleetResolved>;
119
120/// Concrete alias for the per-rollout signed manifest.
121pub 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
176/// Verify any signed sidecar. `trusted_keys` tried in declaration order,
177/// first match wins; unsupported algorithms skipped silently for forward-
178/// compat. `reject_before` is strict `<` (equality accepted).
179pub 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    // Post-deserialization normalization: synthesize the implicit
208    // match-all zero-soak wave for `all-at-once` policies declared
209    // without explicit waves. Signed bytes are not modified
210    // (signature covered the wire form with empty waves); the
211    // in-memory form every consumer sees is canonical. Inner field is
212    // module-private — direct mutation is the chosen access path so
213    // the witness type doesn't grow a public mutator. See
214    // `nixfleet_proto::normalize_rollout_policies` for the design
215    // rationale.
216    nixfleet_proto::normalize_rollout_policies(&mut verified.inner);
217    Ok(verified)
218}
219
220/// LOADBEARING: uses `verify_strict` (not `verify`) - rejects malleable
221/// signatures for root-of-trust keys.
222fn 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
257/// FOOTGUN: TPM2_Sign emits ~50% high-s ECDSA signatures; we MUST normalise
258/// to low-s before verifying or every other signature fails as BadSignature.
259fn 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    // Wrap X||Y as SEC1 uncompressed: 0x04 || X || Y.
286    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
326/// Verify a signed bootstrap-nonces allowlist. Same trust class +
327/// freshness semantics as revocations.
328pub 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
346/// Verify a signed rollout manifest. Callers MUST additionally
347/// discriminate the parsed manifest's canonical RolloutId per RFC-0008
348/// §6.3 against the advertised identifier (`RolloutId::new(&m.channel,
349/// &m.channel_ref).as_str()`) before consuming any field.
350pub 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
368/// SHA-256 hex of JCS-canonical bytes of any serialisable value. Producer
369/// path only. FOOTGUN: verifiers MUST use [`canonical_hash_from_bytes`] -
370/// re-serializing a parsed struct drops fields the consumer's proto doesn't
371/// know about, breaking content-addressing across additive schema changes.
372pub 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
379/// SHA-256 hex of JCS-canonical bytes, from raw input. No parse step, so
380/// fields the caller's proto doesn't know about are preserved in the canonical
381/// bytes - verify side computes the same hash as the producer regardless of
382/// additive proto drift.
383pub 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
448/// Schema gate + `reject_before` + bidirectional freshness check (past +
449/// future, both with `CLOCK_SKEW_SLACK_SECS` slack). `reject_before` runs
450/// first so alerts can distinguish compromise from staleness.
451fn 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;