nixfleet_reconciler/
evidence.rs

1//! Probe-output signature verification. Pubkey is OpenSSH `ssh-ed25519 ...`
2//! sourced from `hosts.<hostname>.pubkey`.
3
4use base64::Engine;
5use ed25519_dalek::{Signature, Verifier, VerifyingKey};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum SignatureStatus {
11    Verified,
12    Unsigned,
13    NoPubkey,
14    /// Signature verification refused - active tampering.
15    Mismatch,
16    /// Decoding or pubkey parse failed - active tampering.
17    Malformed,
18    /// Non-ed25519 pubkey. Soft skip.
19    WrongAlgorithm,
20}
21
22impl SignatureStatus {
23    /// Counts everything except active-tampering signals; mTLS is the primary
24    /// trust root, this signature is defense-in-depth.
25    pub fn counts_for_gate(self) -> bool {
26        !matches!(self, SignatureStatus::Mismatch | SignatureStatus::Malformed)
27    }
28}
29
30/// Verify a base64 ed25519 signature over already-canonical bytes.
31pub fn verify_canonical_payload(
32    canonical: &[u8],
33    pubkey_openssh: Option<&str>,
34    signature: Option<&str>,
35) -> SignatureStatus {
36    let Some(sig_b64) = signature else {
37        return SignatureStatus::Unsigned;
38    };
39    let Some(pubkey_str) = pubkey_openssh else {
40        return SignatureStatus::NoPubkey;
41    };
42
43    let pubkey = match parse_ssh_ed25519_pubkey(pubkey_str) {
44        Ok(Some(k)) => k,
45        Ok(None) => return SignatureStatus::WrongAlgorithm,
46        Err(_) => return SignatureStatus::Malformed,
47    };
48
49    let sig_bytes = match base64::engine::general_purpose::STANDARD.decode(sig_b64) {
50        Ok(b) => b,
51        Err(_) => return SignatureStatus::Malformed,
52    };
53    let sig_arr: [u8; 64] = match sig_bytes.as_slice().try_into() {
54        Ok(a) => a,
55        Err(_) => return SignatureStatus::Malformed,
56    };
57    let sig = Signature::from_bytes(&sig_arr);
58
59    match pubkey.verify(canonical, &sig) {
60        Ok(()) => SignatureStatus::Verified,
61        Err(_) => SignatureStatus::Mismatch,
62    }
63}
64
65/// JCS-canonicalize `payload`, then verify.
66pub fn verify_event<T: Serialize>(
67    signature: Option<&str>,
68    pubkey_openssh: Option<&str>,
69    payload: &T,
70) -> SignatureStatus {
71    let canonical = match serde_jcs::to_vec(payload) {
72        Ok(v) => v,
73        Err(_) => return SignatureStatus::Malformed,
74    };
75    verify_canonical_payload(&canonical, pubkey_openssh, signature)
76}
77
78/// `Ok(Some)` ed25519, `Ok(None)` non-ed25519, `Err` parse failure.
79fn parse_ssh_ed25519_pubkey(line: &str) -> anyhow::Result<Option<VerifyingKey>> {
80    use anyhow::Context;
81    let public = ssh_key::PublicKey::from_openssh(line.trim()).context("parse OpenSSH pubkey")?;
82    match public.key_data() {
83        ssh_key::public::KeyData::Ed25519(ed) => {
84            let bytes: [u8; 32] = ed.0;
85            let vk =
86                VerifyingKey::from_bytes(&bytes).context("ed25519 verifying key from 32 bytes")?;
87            Ok(Some(vk))
88        }
89        _ => Ok(None),
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use nixfleet_proto::evidence_signing::ActivationFailedSignedPayload;
97
98    /// Deterministic distinct keypairs from a seed byte.
99    fn keypair_from(byte: u8) -> (ed25519_dalek::SigningKey, String) {
100        let seed = [byte; 32];
101        let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
102        let pubkey_bytes = sk.verifying_key().to_bytes();
103        let ssh_pk = ssh_key::PublicKey::new(
104            ssh_key::public::KeyData::Ed25519(ssh_key::public::Ed25519PublicKey(pubkey_bytes)),
105            "test-host",
106        );
107        (sk, ssh_pk.to_openssh().expect("to_openssh"))
108    }
109
110    fn sample_payload() -> ActivationFailedSignedPayload<'static> {
111        ActivationFailedSignedPayload {
112            hostname: "host-05",
113            rollout: Some("edge-slow@abc"),
114            phase: "switch-to-configuration",
115            exit_code: Some(2),
116            stderr_tail_sha256: "deadbeef".to_string(),
117        }
118    }
119
120    #[test]
121    fn unsigned_when_signature_missing() {
122        assert_eq!(
123            verify_event(None, Some("ssh-ed25519 AAAAxxxx"), &sample_payload()),
124            SignatureStatus::Unsigned
125        );
126    }
127
128    #[test]
129    fn no_pubkey_when_pubkey_missing() {
130        assert_eq!(
131            verify_event(Some("AAAA"), None, &sample_payload()),
132            SignatureStatus::NoPubkey
133        );
134    }
135
136    #[test]
137    fn round_trip_succeeds() {
138        use ed25519_dalek::Signer;
139        let (sk, pubkey_str) = keypair_from(1);
140        let payload = sample_payload();
141        let sig = sk.sign(&serde_jcs::to_vec(&payload).unwrap());
142        let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
143        assert_eq!(
144            verify_event(Some(&sig_b64), Some(&pubkey_str), &payload),
145            SignatureStatus::Verified
146        );
147    }
148
149    #[test]
150    fn mismatch_on_tampered_payload() {
151        use ed25519_dalek::Signer;
152        let (sk, pubkey_str) = keypair_from(1);
153        let payload = sample_payload();
154        let sig = sk.sign(&serde_jcs::to_vec(&payload).unwrap());
155        let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
156        let mut tampered = sample_payload();
157        tampered.phase = "build-derivation";
158        assert_eq!(
159            verify_event(Some(&sig_b64), Some(&pubkey_str), &tampered),
160            SignatureStatus::Mismatch
161        );
162    }
163
164    #[test]
165    fn mismatch_on_wrong_pubkey() {
166        use ed25519_dalek::Signer;
167        let (sk_signer, _) = keypair_from(1);
168        let (_, pubkey_str_other) = keypair_from(2);
169        let payload = sample_payload();
170        let sig = sk_signer.sign(&serde_jcs::to_vec(&payload).unwrap());
171        let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
172        assert_eq!(
173            verify_event(Some(&sig_b64), Some(&pubkey_str_other), &payload),
174            SignatureStatus::Mismatch
175        );
176    }
177
178    #[test]
179    fn malformed_on_garbage_signature() {
180        let payload = sample_payload();
181        let (_, pubkey_str) = keypair_from(3);
182        assert_eq!(
183            verify_event(Some("!!!not-base64!!!"), Some(&pubkey_str), &payload),
184            SignatureStatus::Malformed
185        );
186        let short = base64::engine::general_purpose::STANDARD.encode([0u8; 32]);
187        assert_eq!(
188            verify_event(Some(&short), Some(&pubkey_str), &payload),
189            SignatureStatus::Malformed
190        );
191    }
192
193    #[test]
194    fn malformed_on_garbage_pubkey() {
195        let payload = sample_payload();
196        let sig = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
197        assert_eq!(
198            verify_event(Some(&sig), Some("ssh-ed25519 garbage"), &payload),
199            SignatureStatus::Malformed
200        );
201    }
202
203    #[test]
204    fn signature_status_gate_counting() {
205        assert!(SignatureStatus::Verified.counts_for_gate());
206        assert!(SignatureStatus::Unsigned.counts_for_gate());
207        assert!(SignatureStatus::NoPubkey.counts_for_gate());
208        assert!(SignatureStatus::WrongAlgorithm.counts_for_gate());
209        assert!(!SignatureStatus::Mismatch.counts_for_gate());
210        assert!(!SignatureStatus::Malformed.counts_for_gate());
211    }
212
213    #[test]
214    fn bytes_level_round_trip() {
215        use ed25519_dalek::Signer;
216        let (sk, pubkey_str) = keypair_from(1);
217        let canonical = serde_jcs::to_vec(&sample_payload()).unwrap();
218        let sig = sk.sign(&canonical);
219        let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
220        assert_eq!(
221            verify_canonical_payload(&canonical, Some(&pubkey_str), Some(&sig_b64)),
222            SignatureStatus::Verified
223        );
224    }
225
226    fn round_trip<T: Serialize>(payload: &T) {
227        use ed25519_dalek::Signer;
228        let (sk, pubkey_str) = keypair_from(7);
229        let canonical = serde_jcs::to_vec(payload).unwrap();
230        let sig = sk.sign(&canonical);
231        let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
232        assert_eq!(
233            verify_event(Some(&sig_b64), Some(&pubkey_str), payload),
234            SignatureStatus::Verified
235        );
236    }
237
238    #[test]
239    fn activation_failed_round_trip() {
240        use nixfleet_proto::evidence_signing::ActivationFailedSignedPayload;
241        round_trip(&ActivationFailedSignedPayload {
242            hostname: "host-05",
243            rollout: Some("stable@abc"),
244            phase: "switch-to-configuration",
245            exit_code: Some(1),
246            stderr_tail_sha256: "deadbeef".to_string(),
247        });
248    }
249
250    #[test]
251    fn realise_failed_round_trip() {
252        use nixfleet_proto::evidence_signing::RealiseFailedSignedPayload;
253        round_trip(&RealiseFailedSignedPayload {
254            hostname: "host-05",
255            rollout: Some("stable@abc"),
256            closure_hash: "0000000000000000000000000000000000000000-test",
257            reason: "substituter 503",
258        });
259    }
260
261    #[test]
262    fn verify_mismatch_round_trip() {
263        use nixfleet_proto::evidence_signing::VerifyMismatchSignedPayload;
264        round_trip(&VerifyMismatchSignedPayload {
265            hostname: "host-05",
266            rollout: Some("stable@abc"),
267            expected: "0000000000000000000000000000000000000000-expected",
268            actual: "1111111111111111111111111111111111111111-actual",
269        });
270    }
271
272    #[test]
273    fn rollback_triggered_round_trip() {
274        use nixfleet_proto::evidence_signing::RollbackTriggeredSignedPayload;
275        round_trip(&RollbackTriggeredSignedPayload {
276            hostname: "host-05",
277            rollout: Some("stable@abc"),
278            reason: "cp-410: rollout cancelled",
279        });
280    }
281
282    #[test]
283    fn closure_signature_mismatch_round_trip() {
284        use nixfleet_proto::evidence_signing::ClosureSignatureMismatchSignedPayload;
285        round_trip(&ClosureSignatureMismatchSignedPayload {
286            hostname: "host-05",
287            rollout: Some("stable@abc"),
288            closure_hash: "0000000000000000000000000000000000000000-test",
289            stderr_tail_sha256: "cafebabe".to_string(),
290        });
291    }
292
293    #[test]
294    fn manifest_missing_round_trip() {
295        use nixfleet_proto::evidence_signing::ManifestMissingSignedPayload;
296        round_trip(&ManifestMissingSignedPayload {
297            hostname: "host-05",
298            rollout: Some("a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1"),
299            rollout_id: "a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1",
300            reason: "GET /v1/rollouts/<id> returned 404",
301        });
302    }
303
304    #[test]
305    fn manifest_verify_failed_round_trip() {
306        use nixfleet_proto::evidence_signing::ManifestVerifyFailedSignedPayload;
307        round_trip(&ManifestVerifyFailedSignedPayload {
308            hostname: "host-05",
309            rollout: Some("a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1"),
310            rollout_id: "a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1",
311            reason: "signature does not verify against ciReleaseKey",
312        });
313    }
314
315    #[test]
316    fn manifest_mismatch_round_trip() {
317        use nixfleet_proto::evidence_signing::ManifestMismatchSignedPayload;
318        round_trip(&ManifestMismatchSignedPayload {
319            hostname: "host-05",
320            rollout: Some("a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1"),
321            rollout_id: "a3f7c2b1d4e8f6a9c0b5d2e7f1a4c8b3d6e9f2a5c7b4d1e8f0a3b6c9d2e5f8a1",
322            reason: "(hostname, wave_index) not in manifest.host_set",
323        });
324    }
325
326    #[test]
327    fn stale_target_round_trip() {
328        use nixfleet_proto::evidence_signing::StaleTargetSignedPayload;
329        round_trip(&StaleTargetSignedPayload {
330            hostname: "host-05",
331            rollout: Some("stable@abc"),
332            closure_hash: "0000000000000000000000000000000000000000-test",
333            channel_ref: "stable@abc",
334            signed_at: chrono::DateTime::from_timestamp(1_000_000, 0).unwrap(),
335            freshness_window_secs: 86400,
336            age_secs: 3600,
337        });
338    }
339}