Skip to main content

mur_common/
binary_attestation.rs

1//! Binary attestation: verify that a spawned `mur-agent-runtime` carries a
2//! valid signature from MUR's Developer ID team (launch-chain follow-on,
3//! spec 2026-08-12). Gated to release builds by the build marker.
4
5use std::fmt;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9/// True when this binary was built with `MUR_EMBED_RELEASE_MARKER=1`
10/// (the release pipeline). Dev builds never verify.
11pub const IS_EMBEDDED_RELEASE: bool = {
12    // `==`/`match` on `&str` is not const-stable on current rustc, so compare
13    // bytes (build.rs emits exactly "1" or "0").
14    let bytes = env!("MUR_EMBEDDED_RELEASE").as_bytes();
15    bytes.len() == 1 && bytes[0] == b'1'
16};
17
18/// MUR's Apple Developer Team ID (empty in dev builds; build.rs panics if the
19/// marker is set without it).
20pub const APPLE_TEAM_ID: &str = env!("MUR_APPLE_TEAM_ID");
21
22/// The designated requirement used in production: valid signature chaining to
23/// Apple plus a leaf certificate owned by MUR's team.
24pub(crate) fn production_requirement() -> String {
25    // Leading "=" marks the arg as literal requirement text to codesign (a
26    // plain arg would be read as a file path); "designated =>" is accepted
27    // only when *reading* a designated requirement, not as verification text.
28    format!("=anchor apple generic and certificate leaf[subject.OU] = \"{APPLE_TEAM_ID}\"")
29}
30
31/// Verify `path` is a legitimate runtime binary. No-op unless this is a
32/// macOS release build. Fail-closed: any verification error is returned.
33pub fn verify_runtime_signature(path: &Path) -> Result<(), AttestError> {
34    if !IS_EMBEDDED_RELEASE || !cfg!(target_os = "macos") {
35        return Ok(());
36    }
37    // Canonicalize so a symlink or /var → /private/var redirect is verified
38    // on the real file.
39    let real = path.canonicalize().map_err(|e| AttestError::Io {
40        path: path.to_path_buf(),
41        source: e,
42    })?;
43    verify_with_requirement(&real, &production_requirement())
44}
45
46/// Testable core: run `codesign --verify --strict -R <requirement>` on `path`.
47#[doc(hidden)]
48pub fn verify_with_requirement(path: &Path, requirement: &str) -> Result<(), AttestError> {
49    let out = Command::new("codesign")
50        .args(["--verify", "--strict", "-R", requirement])
51        .arg(path)
52        .output()
53        .map_err(|e| AttestError::Io {
54            path: path.to_path_buf(),
55            source: e,
56        })?;
57    if out.status.success() {
58        Ok(())
59    } else {
60        Err(AttestError::VerificationFailed {
61            path: path.to_path_buf(),
62            stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
63        })
64    }
65}
66
67#[derive(Debug)]
68pub enum AttestError {
69    /// The binary failed the designated requirement.
70    VerificationFailed { path: PathBuf, stderr: String },
71    /// Could not read/canonicalize the path or run codesign.
72    Io {
73        path: PathBuf,
74        source: std::io::Error,
75    },
76}
77
78impl fmt::Display for AttestError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::VerificationFailed { path, stderr } => write!(
82                f,
83                "runtime binary at {} failed signature verification: {stderr}",
84                path.display()
85            ),
86            Self::Io { path, source } => {
87                write!(
88                    f,
89                    "cannot verify runtime binary at {}: {source}",
90                    path.display()
91                )
92            }
93        }
94    }
95}
96
97impl std::error::Error for AttestError {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        match self {
100            Self::Io { source, .. } => Some(source),
101            Self::VerificationFailed { .. } => None,
102        }
103    }
104}
105
106/// `codesign` stderr marker for a binary that carries no signature at all.
107const STDERR_UNSIGNED: &str = "not signed at all";
108/// `codesign` stderr marker for a valid signature from the wrong signer
109/// (ad-hoc dev builds, a local re-sign identity, another team).
110const STDERR_REQUIREMENT_UNMET: &str = "failed to satisfy specified code requirement";
111
112/// Why attestation failed, as far as the error lets us tell. Drives the
113/// remediation hint so a dev setup is not reported like a security event.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum AttestFailureKind {
116    /// Intact binary, but not signed by MUR's Developer ID team — in
117    /// practice a locally built runtime next to a release CLI.
118    NotMurSigned,
119    /// The signature no longer matches the bytes: modified after signing.
120    Tampered,
121    /// Could not read the binary or run `codesign`.
122    Unverifiable,
123}
124
125impl AttestError {
126    pub fn kind(&self) -> AttestFailureKind {
127        match self {
128            Self::Io { .. } => AttestFailureKind::Unverifiable,
129            Self::VerificationFailed { stderr, .. } => {
130                // codesign checks integrity before the requirement, so a
131                // requirement miss means the bytes themselves are intact.
132                if stderr.contains(STDERR_UNSIGNED) || stderr.contains(STDERR_REQUIREMENT_UNMET) {
133                    AttestFailureKind::NotMurSigned
134                } else {
135                    AttestFailureKind::Tampered
136                }
137            }
138        }
139    }
140
141    /// The remediation sentence for this failure. Shared by every mount site
142    /// (CLI and Hub) so the guidance cannot drift between them.
143    pub fn fix_hint(&self) -> &'static str {
144        match self.kind() {
145            AttestFailureKind::NotMurSigned => {
146                "the runtime is not signed by MUR's Developer ID, which usually \
147                 means a locally built (dev) mur-agent-runtime is installed next \
148                 to a release mur. Fix: reinstall MUR to restore the release \
149                 runtime, or use a dev build of mur together with the dev \
150                 runtime. If you did not build it yourself, treat the binary as \
151                 swapped and reinstall MUR."
152            }
153            AttestFailureKind::Tampered => {
154                "the runtime binary was modified after it was signed \
155                 (launch-chain protection covers writes, attestation covers \
156                 swaps). Fix: reinstall MUR."
157            }
158            AttestFailureKind::Unverifiable => {
159                "the runtime binary could not be checked. Fix: confirm the \
160                 file exists and is readable, or reinstall MUR."
161            }
162        }
163    }
164
165    /// Full user-facing message for a refused runtime mount.
166    pub fn mount_failure_message(&self) -> String {
167        format!("{self} — {}", self.fix_hint())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::path::PathBuf;
175
176    // Compile-time gates: this binary is built without the release marker in
177    // CI, so IS_EMBEDDED_RELEASE is false here — the skip behavior is the
178    // negative control for every behavioral test below.
179    // ── Remediation hint (#1716) ──────────────────────────────────────────
180    // stderr strings are verbatim `codesign --verify --strict -R` output.
181    fn failed(stderr: &str) -> AttestError {
182        AttestError::VerificationFailed {
183            path: PathBuf::from("/x/mur-agent-runtime"),
184            stderr: stderr.to_string(),
185        }
186    }
187
188    #[test]
189    fn adhoc_dev_runtime_hint_names_dev_build_not_restart() {
190        let e = failed("test-requirement: code failed to satisfy specified code requirement(s)");
191        assert_eq!(e.kind(), AttestFailureKind::NotMurSigned);
192        let msg = e.mount_failure_message();
193        assert!(msg.contains("not signed by MUR's Developer ID"), "{msg}");
194        assert!(msg.contains("dev build of mur"), "{msg}");
195        assert!(msg.contains("reinstall MUR"), "{msg}");
196        assert!(!msg.contains("--restart-agents"), "{msg}");
197    }
198
199    #[test]
200    fn unsigned_runtime_is_not_mur_signed() {
201        let e = failed("/x/mur-agent-runtime: code object is not signed at all");
202        assert_eq!(e.kind(), AttestFailureKind::NotMurSigned);
203    }
204
205    #[test]
206    fn modified_after_signing_is_tampered() {
207        let e = failed("/x/mur-agent-runtime: main executable failed strict validation");
208        assert_eq!(e.kind(), AttestFailureKind::Tampered);
209        let msg = e.mount_failure_message();
210        assert!(msg.contains("modified after it was signed"), "{msg}");
211        assert!(!msg.contains("--restart-agents"), "{msg}");
212    }
213
214    #[test]
215    fn io_failure_is_unverifiable() {
216        let e = AttestError::Io {
217            path: PathBuf::from("/x/mur-agent-runtime"),
218            source: std::io::Error::from(std::io::ErrorKind::NotFound),
219        };
220        assert_eq!(e.kind(), AttestFailureKind::Unverifiable);
221        assert!(
222            e.mount_failure_message()
223                .starts_with("cannot verify runtime binary at /x/mur-agent-runtime")
224        );
225    }
226
227    #[test]
228    #[allow(clippy::assertions_on_constants)] // runtime negative control on a compile-time const
229    fn dev_build_never_verifies() {
230        assert!(!IS_EMBEDDED_RELEASE);
231        // verify_runtime_signature on a garbage path must still be Ok in dev:
232        assert!(verify_runtime_signature(Path::new("/nonexistent/nope")).is_ok());
233    }
234
235    #[test]
236    fn production_requirement_binds_anchor_and_team() {
237        let req = production_requirement();
238        assert!(req.contains("anchor apple generic"), "req: {req}");
239        assert!(req.contains("subject.OU"), "req: {req}");
240        assert!(req.starts_with("=anchor apple generic and"), "req: {req}");
241    }
242
243    // ── Behavioral matrix (macOS + test identity) ────────────────────────
244    // These run only when MUR_TEST_SIGNING_OU is set (CI macOS job runs
245    // scripts/test-signing-identity.sh first). Negative controls included.
246    #[cfg(unix)]
247    fn test_dir(name: &str) -> PathBuf {
248        let d = std::env::temp_dir().join(format!("mur-attest-{}-{}", name, std::process::id()));
249        let _ = std::fs::remove_dir_all(&d);
250        std::fs::create_dir_all(&d).unwrap();
251        d
252    }
253
254    #[cfg(unix)]
255    fn test_ou() -> Option<String> {
256        std::env::var("MUR_TEST_SIGNING_OU").ok()
257    }
258
259    #[test]
260    #[cfg(unix)] // PermissionsExt::from_mode is unix-only (Windows CI compiles this module)
261    fn unsigned_file_fails_test_requirement() {
262        let Some(ou) = test_ou() else {
263            eprintln!("skipping: MUR_TEST_SIGNING_OU not set");
264            return;
265        };
266        let dir = test_dir("unsigned");
267        let f = dir.join("runtime");
268        std::fs::write(&f, b"#!/bin/sh\nexit 0\n").unwrap();
269        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
270        let req = format!("=certificate leaf[subject.OU] = \"{ou}\"");
271        let err = verify_with_requirement(&f, &req).expect_err("unsigned must fail");
272        assert!(matches!(err, AttestError::VerificationFailed { .. }));
273        let _ = std::fs::remove_dir_all(&dir);
274    }
275
276    #[test]
277    #[cfg(unix)] // PermissionsExt::from_mode is unix-only (Windows CI compiles this module)
278    fn adhoc_signed_fails_test_requirement() {
279        let Some(ou) = test_ou() else {
280            eprintln!("skipping");
281            return;
282        };
283        let dir = test_dir("adhoc");
284        let f = dir.join("runtime");
285        std::fs::write(&f, b"#!/bin/sh\nexit 0\n").unwrap();
286        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
287        let out = std::process::Command::new("codesign")
288            .args(["--force", "-s", "-"])
289            .arg(&f)
290            .output()
291            .unwrap();
292        assert!(
293            out.status.success(),
294            "ad-hoc sign failed: {}",
295            String::from_utf8_lossy(&out.stderr)
296        );
297        let req = format!("=certificate leaf[subject.OU] = \"{ou}\"");
298        let err = verify_with_requirement(&f, &req).expect_err("ad-hoc (no OU) must fail");
299        assert!(matches!(err, AttestError::VerificationFailed { .. }));
300        let _ = std::fs::remove_dir_all(&dir);
301    }
302
303    #[test]
304    #[cfg(unix)] // PermissionsExt::from_mode is unix-only (Windows CI compiles this module)
305    fn wrong_ou_fails_test_requirement() {
306        let Some(ou) = test_ou() else {
307            eprintln!("skipping");
308            return;
309        };
310        let dir = test_dir("wrongou");
311        let f = dir.join("runtime");
312        std::fs::write(&f, b"#!/bin/sh\nexit 0\n").unwrap();
313        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
314        let out = std::process::Command::new("codesign")
315            .args(["--force", "-s", &format!("Mur Test ({ou})")])
316            .arg(&f)
317            .output()
318            .unwrap();
319        assert!(
320            out.status.success(),
321            "sign failed: {}",
322            String::from_utf8_lossy(&out.stderr)
323        );
324        let wrong = "=certificate leaf[subject.OU] = \"WRONGTEAM000\"".to_string();
325        let err = verify_with_requirement(&f, &wrong).expect_err("wrong OU must fail");
326        assert!(matches!(err, AttestError::VerificationFailed { .. }));
327        // Positive control: the same signed binary passes with the right OU.
328        let right = format!("=certificate leaf[subject.OU] = \"{ou}\"");
329        verify_with_requirement(&f, &right).expect("matching OU must pass");
330        let _ = std::fs::remove_dir_all(&dir);
331    }
332
333    #[cfg(unix)]
334    use std::os::unix::fs::PermissionsExt;
335}