mur_common/
binary_attestation.rs1use std::fmt;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9pub const IS_EMBEDDED_RELEASE: bool = {
12 let bytes = env!("MUR_EMBEDDED_RELEASE").as_bytes();
15 bytes.len() == 1 && bytes[0] == b'1'
16};
17
18pub const APPLE_TEAM_ID: &str = env!("MUR_APPLE_TEAM_ID");
21
22pub(crate) fn production_requirement() -> String {
25 format!("=anchor apple generic and certificate leaf[subject.OU] = \"{APPLE_TEAM_ID}\"")
29}
30
31pub fn verify_runtime_signature(path: &Path) -> Result<(), AttestError> {
34 if !IS_EMBEDDED_RELEASE || !cfg!(target_os = "macos") {
35 return Ok(());
36 }
37 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#[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 VerificationFailed { path: PathBuf, stderr: String },
71 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
106const STDERR_UNSIGNED: &str = "not signed at all";
108const STDERR_REQUIREMENT_UNMET: &str = "failed to satisfy specified code requirement";
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum AttestFailureKind {
116 NotMurSigned,
119 Tampered,
121 Unverifiable,
123}
124
125impl AttestError {
126 pub fn kind(&self) -> AttestFailureKind {
127 match self {
128 Self::Io { .. } => AttestFailureKind::Unverifiable,
129 Self::VerificationFailed { stderr, .. } => {
130 if stderr.contains(STDERR_UNSIGNED) || stderr.contains(STDERR_REQUIREMENT_UNMET) {
133 AttestFailureKind::NotMurSigned
134 } else {
135 AttestFailureKind::Tampered
136 }
137 }
138 }
139 }
140
141 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 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 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)] fn dev_build_never_verifies() {
230 assert!(!IS_EMBEDDED_RELEASE);
231 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 #[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)] 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)] 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)] 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 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}