Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions cmux-tui/crates/acpmux/src/daemon/exe_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,16 @@ pub enum Verdict {
}

/// The verdict for a daemon of `home` run from `exe`. `default_home` is the
/// person's `~/.acpmux`; a default home that itself sits in a temporary
/// person's `~/.acpmux`; `verified_clone` comes only from the signature verifier.
/// A default home that itself sits in a temporary
/// folder (a test's `HOME`) is not the person's.
pub fn verdict(
exe: &Path,
home: &Path,
default_home: &Path,
temp_dir: &Path,
allow: bool,
verified_clone: bool,
) -> Verdict {
let Some(why) = classify(exe, temp_dir) else { return Verdict::Stable };
if home != default_home || classify(home, temp_dir).is_some() {
Expand All @@ -140,7 +142,9 @@ pub fn verdict(
why.phrase()
);
match why {
Unstable::RunnerWork | Unstable::Temp if !allow && !bundled(exe) => {
Unstable::RunnerWork | Unstable::Temp
if !(allow || bundled(exe) || why == Unstable::Temp && verified_clone) =>
{
Verdict::Refuse(format!(
"{line}. Refused for {}: install acpmux (`acpmux host setup HOST` from another machine, \
or copy it to ~/.local/bin/acpmux) and run that, give this one its own ACPMUX_HOME, \
Expand Down Expand Up @@ -179,6 +183,7 @@ fn current(allow: bool) -> Verdict {
&resolved(&crate::config::default_home()),
&resolved(&std::env::temp_dir()),
allow || allowed_by_env(),
cmux_link::app_caller::verified_clone_executable(&exe).is_some(),
)
}

Expand Down Expand Up @@ -241,6 +246,7 @@ pub fn shutdown_refusal(
default_home: &Path,
temp_dir: &Path,
allow: bool,
verified_clone: bool,
) -> Option<String> {
if home != default_home || classify(home, temp_dir).is_some() {
return None;
Expand All @@ -250,7 +256,9 @@ pub fn shutdown_refusal(
Some("a test runner")
} else {
match classify(peer_exe, temp_dir) {
Some(why @ (Unstable::RunnerWork | Unstable::Temp)) if !allow && !bundled(peer_exe) => {
Some(why @ (Unstable::RunnerWork | Unstable::Temp))
if !(allow || bundled(peer_exe) || why == Unstable::Temp && verified_clone) =>
{
Some(why.phrase())
}
_ => None,
Expand All @@ -273,13 +281,15 @@ pub fn peer_shutdown_refusal(
peer: Option<&cmux_link::app_caller::PeerToken>,
allow: bool,
) -> Option<String> {
let exe = cmux_link::app_caller::executable_path(peer?)?;
let peer = peer?;
let exe = cmux_link::app_caller::executable_path(peer)?;
shutdown_refusal(
&resolved(&exe),
&resolved(&crate::config::home()),
&resolved(&crate::config::default_home()),
&resolved(&std::env::temp_dir()),
allow,
cmux_link::app_caller::verified_clone_peer(peer).is_some(),
)
}

Expand Down
27 changes: 26 additions & 1 deletion cmux-tui/crates/acpmux/src/daemon/exe_path_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn v(exe: &str, home: &str, allow: bool) -> Verdict {
Path::new("/Users/cmux/.acpmux"),
Path::new(TMP),
allow,
false,
)
}

Expand Down Expand Up @@ -41,6 +42,7 @@ fn temp_folders_are_refused_in_the_default_home() {
Path::new("/home/l/.acpmux"),
Path::new("/run/user/1000/tmp"),
false,
false,
);
assert!(matches!(linux_tmp, Verdict::Refuse(_)));
}
Expand Down Expand Up @@ -117,7 +119,7 @@ fn a_test_home_under_a_temp_folder_is_not_the_persons() {
// cargo tests on a runner: the binary under _work, HOME and the home in a temp folder.
let home = PathBuf::from(TMP).join("t1/.acpmux");
let exe = "/Users/cmux/actions-runner-glaeda-2/_work/cmux/cmux/cmux-tui/target/debug/acpmux";
let got = verdict(Path::new(exe), &home, &home, Path::new(TMP), false);
let got = verdict(Path::new(exe), &home, &home, Path::new(TMP), false, false);
assert_eq!(got, Verdict::Stable);
}

Expand All @@ -128,6 +130,7 @@ fn stop(peer: &str, home: &str, allow: bool) -> Option<String> {
Path::new("/Users/cmux/.acpmux"),
Path::new(TMP),
allow,
false,
)
}

Expand Down Expand Up @@ -179,3 +182,25 @@ fn installed_clients_and_apps_stop_the_persons_daemon() {
assert_eq!(stop(peer, "/Users/cmux/.acpmux", false), None, "{peer}");
}
}

#[test]
fn sign_clone_start_and_handoff_require_signature_proof() {
let exe = Path::new(
"/private/var/folders/a/b/T/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.9gP34C/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux",
);
let home = Path::new("/Users/cmux/.acpmux");
for verified in [false, true] {
let result = verdict(exe, home, home, Path::new(TMP), false, verified);
assert_eq!(matches!(result, Verdict::Warn(_)), verified);
assert_eq!(
shutdown_refusal(exe, home, home, Path::new(TMP), false, verified).is_none(),
verified
);
}
// A proof cannot override the runner cleanup protection.
let runner = Path::new(
"/Users/cmux/actions-runner-1/_work/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.a/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux",
);
assert!(matches!(verdict(runner, home, home, Path::new(TMP), false, true), Verdict::Refuse(_)));
assert!(shutdown_refusal(runner, home, home, Path::new(TMP), false, true).is_some());
}
33 changes: 33 additions & 0 deletions cmux-tui/crates/cmux-link/src/app_caller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,35 @@ pub fn same_team_as_this_build(path: &std::path::Path) -> Result<TeamMatch, Stri
}
}

/// Resolve a signed macOS update clone to its matching installed executable.
/// Both bundles and executable signatures must validate; a path alone grants
/// no trust. Unsigned builds and other platforms return `None`.
pub fn verified_clone_executable(path: &std::path::Path) -> Option<std::path::PathBuf> {
#[cfg(target_os = "macos")]
{
crate::caller::macos::verified_clone_executable(path)
}
#[cfg(not(target_os = "macos"))]
{
let _ = path;
None
}
}

/// As [`verified_clone_executable`], also checking the running socket peer by
/// audit token against the installed executable's designated requirement.
pub fn verified_clone_peer(token: &PeerToken) -> Option<std::path::PathBuf> {
#[cfg(target_os = "macos")]
{
crate::caller::macos::verified_clone_peer(&token.0)
}
#[cfg(not(target_os = "macos"))]
{
let _ = token;
None
}
}

/// `CFBundleIdentifier` characters allowed into the requirement text.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn plain_bundle_identifier(identifier: &str) -> bool {
Expand All @@ -196,6 +225,10 @@ pub(crate) fn containing_bundle(executable: &std::path::Path) -> Option<std::pat
&& executable.starts_with(candidate.join("Contents"))
})
.map(std::path::Path::to_path_buf)
.or_else(|| {
verified_clone_executable(executable)
.and_then(|installed| containing_bundle(&installed))
})
}

#[cfg(test)]
Expand Down
53 changes: 53 additions & 0 deletions cmux-tui/crates/cmux-link/src/caller_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ unsafe extern "C" {
) -> OSStatus;
fn SecCodeCheckValidity(code: CFTypeRef, flags: u32, requirement: CFTypeRef) -> OSStatus;
fn SecCodeCopyStaticCode(code: CFTypeRef, flags: u32, out: *mut CFTypeRef) -> OSStatus;
fn SecCodeCopyDesignatedRequirement(
code: CFTypeRef,
flags: u32,
out: *mut CFTypeRef,
) -> OSStatus;
fn SecCodeCopySigningInformation(
code: CFTypeRef,
flags: u32,
Expand Down Expand Up @@ -230,6 +235,10 @@ fn containing_app_identifier(team: &str) -> Result<String, String> {
.ok_or("this binary is not inside an app bundle")?;
let code =
static_code_signed_by(&bundle, team, BASIC_VALIDATE_ONLY, "containing app signature")?;
static_identifier(&code)
}

fn static_identifier(code: &Owned) -> Result<String, String> {
let mut information: CFTypeRef = ptr::null();
// SAFETY: `code.0` is live; the out-pointer is valid.
status(
Expand Down Expand Up @@ -305,6 +314,50 @@ pub(crate) fn path_has_own_team(path: &std::path::Path) -> Result<bool, String>
Ok(true)
}

/// Full validation, including a nested executable, before a clone path can
/// stand for an installed app. The own Team ID is the existing trust anchor.
fn clone_signature(path: &std::path::Path) -> Option<crate::sign_clone::Identity> {
let team = own_signing().ok()?.team.filter(|team| plain_team(team))?;
let code = static_code_signed_by(path, &team, 0, "update clone signature").ok()?;
Some(crate::sign_clone::Identity { team, identifier: static_identifier(&code).ok()? })
}

fn check_running_clone(code: &Owned, installed: &std::path::Path) -> Result<(), String> {
let team = own_signing()?.team.filter(|team| plain_team(team)).ok_or("no signing team")?;
let installed = static_code_signed_by(installed, &team, 0, "installed clone executable")?;
let mut requirement: CFTypeRef = ptr::null();
// SAFETY: the static code is live and the out-pointer is valid.
status(
unsafe { SecCodeCopyDesignatedRequirement(installed.0, 0, &raw mut requirement) },
"installed executable requirement",
)?;
let requirement = Owned::new(requirement, "installed executable requirement")?;
// SAFETY: both owned references are live. Dynamic code was obtained from
// this process or the connection's audit token, never from a bare PID.
status(unsafe { SecCodeCheckValidity(code.0, 0, requirement.0) }, "running clone signature")
}

pub(crate) fn verified_clone_executable(path: &std::path::Path) -> Option<std::path::PathBuf> {
let installed = crate::sign_clone::resolve(path, clone_signature)?;
if std::env::current_exe().ok().as_deref() == Some(path) {
check_running_clone(&own_code().ok()?, &installed).ok()?;
}
Some(installed)
}

pub(crate) fn verified_clone_peer(token: &[u32; 8]) -> Option<std::path::PathBuf> {
// UID is also checked by the socket's caller gate; preserve it here for
// callers using this public verifier without that gate.
// SAFETY: geteuid has no preconditions.
if token[1] != unsafe { libc::geteuid() } {
return None;
}
let path = executable_path(token)?;
let installed = crate::sign_clone::resolve(&path, clone_signature)?;
check_running_clone(&guest_code(token).ok()?, &installed).ok()?;
Some(installed)
}

unsafe extern "C" {
/// libproc: the path of the process `token` names, failing (`<= 0`) when
/// its pid no longer carries the token's pid version.
Expand Down
2 changes: 2 additions & 0 deletions cmux-tui/crates/cmux-link/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub mod overlay_addr;
pub mod owner_session;
pub mod pairing;
pub mod registration;
#[cfg(any(test, target_os = "macos"))]
mod sign_clone;
pub mod stamp;
pub mod token;

Expand Down
134 changes: 134 additions & 0 deletions cmux-tui/crates/cmux-link/src/sign_clone.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! A macOS update clone is an installed app only after its signatures agree.

use std::path::{Component, Path, PathBuf};

/// Facts returned only after the complete static signature validates.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Identity {
pub team: String,
pub identifier: String,
}

/// Keep the path parser independent of Security.framework for Linux tests.
/// The reader must validate Apple-anchored signatures, not just read metadata.
pub(crate) fn resolve(
executable: &Path,
mut signature: impl FnMut(&Path) -> Option<Identity>,
) -> Option<PathBuf> {
if !executable.is_absolute()
|| executable
.components()
.any(|part| matches!(part, Component::ParentDir) || part.as_os_str() == "_work")
{
return None;
}
let bundle = executable.ancestors().find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".app.bundle"))
&& executable.starts_with(path.join("Contents"))
})?;
let clone_dir = bundle.parent()?;
let nonce = clone_dir.file_name()?.to_str()?.strip_prefix("code_sign_clone.")?;
if nonce.is_empty() {
return None;
}
let expected_identifier =
clone_dir.parent()?.file_name()?.to_str()?.strip_suffix(".code_sign_clone")?;
if !(expected_identifier == "com.cmuxterm.app"
|| expected_identifier.starts_with("com.cmuxterm.app."))
|| !crate::app_caller::plain_bundle_identifier(expected_identifier)
{
return None;
}
let installed_name = bundle.file_name()?.to_str()?.strip_suffix(".bundle")?;
let installed_bundle = Path::new("/Applications").join(installed_name);
let installed_executable = installed_bundle.join(executable.strip_prefix(bundle).ok()?);
let installed = signature(&installed_bundle)?;
if installed.identifier != expected_identifier || signature(bundle)? != installed {
return None;
}
// A nested helper has its own signed identifier. Check both executables,
// not merely the enclosing bundle's main executable and resource seal.
let installed_binary = signature(&installed_executable)?;
if installed_binary.team != installed.team || signature(executable)? != installed_binary {
return None;
}
Some(installed_executable)
}

#[cfg(test)]
mod tests {
use super::*;

const CLONE: &str = "/private/var/folders/a/b/T/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.9gP34C/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux";
const INSTALLED: &str = "/Applications/cmux NIGHTLY.app/Contents/Resources/bin/acpmux";

fn signed(path: &Path) -> Option<Identity> {
Some(Identity {
team: "ABCDEFGHIJ".into(),
identifier: if path.file_name()? == "acpmux" {
"acpmux"
} else {
"com.cmuxterm.app.nightly"
}
.into(),
})
}

#[test]
fn sign_clone_matching_valid_signatures_resolve_to_installed_executable() {
assert_eq!(resolve(Path::new(CLONE), signed), Some(PathBuf::from(INSTALLED)));
}

#[test]
fn sign_clone_missing_or_mismatching_signature_at_every_level_is_refused() {
let paths = [
"/Applications/cmux NIGHTLY.app",
"/private/var/folders/a/b/T/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.9gP34C/cmux NIGHTLY.app.bundle",
INSTALLED,
CLONE,
];
for target in paths {
assert_eq!(
resolve(Path::new(CLONE), |path| if path == Path::new(target) {
None
} else {
signed(path)
}),
None,
"missing/invalid signature: {target}"
);
for change_team in [false, true] {
assert_eq!(
resolve(Path::new(CLONE), |path| {
let mut identity = signed(path)?;
if path == Path::new(target) {
if change_team {
identity.team = "OTHERTEAM1".into();
} else {
identity.identifier = "other.app".into();
}
}
Some(identity)
}),
None,
"mismatching signature: {target}"
);
}
}
}

#[test]
fn sign_clone_plain_temp_runner_and_unrelated_shapes_never_read_signatures() {
for path in [
"/tmp/acpmux",
"/tmp/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux",
"/tmp/code_sign_clone.fake/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux",
"/runner/actions-runner-1/_work/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.a/cmux NIGHTLY.app.bundle/Contents/Resources/bin/acpmux",
"/tmp/com.cmuxterm.app.nightly.code_sign_clone/code_sign_clone.a/cmux NIGHTLY.app.bundle/Contents/../acpmux",
] {
assert_eq!(resolve(Path::new(path), |_| panic!("untrusted shape: {path}")), None);
}
}
}
Loading
Loading