nixfleet_agent/
evidence_signer.rs1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use base64::Engine;
9use ed25519_dalek::{Signer, SigningKey};
10use serde::Serialize;
11
12pub use nixfleet_proto::evidence_signing::{
13 ActivationFailedSignedPayload, ClosureSignatureMismatchSignedPayload,
14 ManifestMismatchSignedPayload, ManifestMissingSignedPayload, ManifestVerifyFailedSignedPayload,
15 RealiseFailedSignedPayload, RollbackTriggeredSignedPayload, StaleTargetSignedPayload,
16 VerifyMismatchSignedPayload,
17};
18
19pub const DEFAULT_SSH_HOST_KEY_PATH: &str = "/etc/ssh/ssh_host_ed25519_key";
20
21pub struct EvidenceSigner {
22 signing_key: SigningKey,
23}
24
25impl EvidenceSigner {
26 pub fn load(path: &Path) -> Result<Option<Self>> {
28 let raw = match std::fs::read_to_string(path) {
29 Ok(s) => s,
30 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
31 tracing::warn!(
32 path = %path.display(),
33 "ssh host key not found - evidence signing disabled (no auditor chain)",
34 );
35 return Ok(None);
36 }
37 Err(err) => {
38 return Err(err).with_context(|| format!("read {}", path.display()));
39 }
40 };
41
42 let private = ssh_key::PrivateKey::from_openssh(&raw)
43 .with_context(|| format!("parse OpenSSH key at {}", path.display()))?;
44
45 let key_data = match private.key_data() {
47 ssh_key::private::KeypairData::Ed25519(kp) => kp.private.to_bytes(),
48 other => {
49 anyhow::bail!(
50 "ssh host key at {} is not ed25519 (algorithm: {:?})",
51 path.display(),
52 other.algorithm()
53 );
54 }
55 };
56 let signing_key = SigningKey::from_bytes(&key_data);
57
58 Ok(Some(Self { signing_key }))
59 }
60
61 pub fn sign<T: Serialize>(&self, payload: &T) -> Result<String> {
63 let canonical = serde_jcs::to_vec(payload)
64 .context("JCS canonicalisation of evidence payload failed")?;
65 let sig = self.signing_key.sign(&canonical);
66 Ok(base64::engine::general_purpose::STANDARD.encode(sig.to_bytes()))
67 }
68}
69
70pub fn sha256_jcs<T: Serialize>(payload: &T) -> Result<String> {
72 nixfleet_canonicalize::sha256_jcs_hex(payload)
73}
74
75pub fn default_ssh_host_key_path() -> PathBuf {
76 PathBuf::from(DEFAULT_SSH_HOST_KEY_PATH)
77}
78
79pub fn try_sign<T: Serialize>(signer: &EvidenceSigner, payload: &T) -> Option<String> {
82 match signer.sign(payload) {
83 Ok(sig) => Some(sig),
84 Err(err) => {
85 tracing::error!(
86 error = ?err,
87 "evidence_signer.sign failed; posting unsigned event \
88 (signing was configured, runtime failure)",
89 );
90 None
91 }
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use ed25519_dalek::Verifier;
99
100 fn write_test_key(dir: &Path) -> PathBuf {
101 use ed25519_dalek::SigningKey;
103 use rand::RngCore;
104 let mut seed = [0u8; 32];
105 rand::rng().fill_bytes(&mut seed);
106 let sk = SigningKey::from_bytes(&seed);
107 let kp = ssh_key::private::Ed25519Keypair {
108 public: ssh_key::public::Ed25519PublicKey(sk.verifying_key().to_bytes()),
109 private: ssh_key::private::Ed25519PrivateKey::from_bytes(&sk.to_bytes()),
110 };
111 let pk = ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ed25519(kp), "test-host")
112 .expect("ssh PrivateKey::new");
113 let pem = pk.to_openssh(ssh_key::LineEnding::LF).expect("to_openssh");
114 let path = dir.join("ssh_host_ed25519_key");
115 std::fs::write(&path, pem.as_bytes()).expect("write key");
116 path
117 }
118
119 #[test]
120 fn load_returns_none_when_missing() {
121 let result = EvidenceSigner::load(Path::new("/nonexistent/key"));
122 match result {
123 Ok(None) => {}
124 other => panic!("expected Ok(None), got {:?}", other.is_ok()),
125 }
126 }
127
128 #[test]
129 fn sign_produces_verifiable_signature() {
130 let dir = tempfile::tempdir().expect("tempdir");
131 let path = write_test_key(dir.path());
132 let signer = EvidenceSigner::load(&path)
133 .expect("load")
134 .expect("signer present");
135
136 let payload = ActivationFailedSignedPayload {
137 hostname: "host-05",
138 rollout: Some("edge-slow@abc"),
139 phase: "switch-to-configuration",
140 exit_code: Some(2),
141 stderr_tail_sha256: "deadbeef".to_string(),
142 };
143
144 let sig_b64 = signer.sign(&payload).expect("sign");
145 let sig_bytes = base64::engine::general_purpose::STANDARD
146 .decode(&sig_b64)
147 .expect("base64 decode");
148 let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().expect("64-byte sig");
149 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
150
151 let canonical = serde_jcs::to_vec(&payload).expect("canonicalise");
152 let vk = signer.signing_key.verifying_key();
153 vk.verify(&canonical, &sig).expect("verify");
154 }
155
156 #[test]
157 fn sign_changes_when_payload_changes() {
158 let dir = tempfile::tempdir().expect("tempdir");
159 let path = write_test_key(dir.path());
160 let signer = EvidenceSigner::load(&path)
161 .expect("load")
162 .expect("signer present");
163
164 let p1 = ActivationFailedSignedPayload {
165 hostname: "host-05",
166 rollout: Some("edge-slow@abc"),
167 phase: "switch-to-configuration",
168 exit_code: Some(2),
169 stderr_tail_sha256: "aaa".to_string(),
170 };
171 let mut p2 = p1.clone();
172 p2.phase = "build-derivation";
173
174 let s1 = signer.sign(&p1).expect("sign 1");
175 let s2 = signer.sign(&p2).expect("sign 2");
176 assert_ne!(s1, s2);
177 }
178
179 #[test]
180 fn sha256_jcs_is_stable() {
181 let v = serde_json::json!({"a": 1, "b": [2, 3]});
182 let h1 = sha256_jcs(&v).unwrap();
183 let h2 = sha256_jcs(&v).unwrap();
184 assert_eq!(h1, h2);
185 assert_eq!(h1.len(), 64);
186 }
187
188 #[test]
189 fn sha256_jcs_differs_on_field_change() {
190 let v1 = serde_json::json!({"a": 1});
191 let v2 = serde_json::json!({"a": 2});
192 assert_ne!(sha256_jcs(&v1).unwrap(), sha256_jcs(&v2).unwrap());
193 }
194}