1use 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 Mismatch,
16 Malformed,
18 WrongAlgorithm,
20}
21
22impl SignatureStatus {
23 pub fn counts_for_gate(self) -> bool {
26 !matches!(self, SignatureStatus::Mismatch | SignatureStatus::Malformed)
27 }
28}
29
30pub 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
65pub 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
78fn 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 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}