nixfleet_agent/
evidence_signer.rs

1//! Sign JCS-canonical event payloads with the SSH host key. The auditor trust
2//! root rotates independently from mTLS, so a leaked agent cert doesn't
3//! compromise the third-party chain.
4
5use 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    /// `Ok(None)` when absent; `Err` on parse errors, wrong algorithm, or non-NotFound IO.
27    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        // FOOTGUN: OpenSSH stores 64 bytes (seed + pubkey); dalek wants only the 32-byte seed.
46        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    /// base64-standard 64-byte ed25519 sig.
62    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
70/// Hex SHA-256 of JCS-canonical bytes; binds evidence_snippet to its envelope.
71pub 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
79/// Returns `None` for both "not configured" and "configured but failed";
80/// the runtime-failure path emits an `error!` so auditors can distinguish them.
81pub 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        // Roll the seed by hand: SigningKey::generate is feature-gated.
102        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}