Skip to main content

rightkit_daemon/
peer_signature.rs

1//! Server-side peer code-signature check for hosts that accept only signed peers
2//! (rightkit#1319, and the peer-identity half of rightkit#1501).
3//!
4//! [`Listener::accept`](crate::Listener::accept) proves the peer runs as the current user, and
5//! nothing more. [`verify_peer`] takes the [`PeerIdentity`] of an accepted connection and a
6//! [`PeerPolicy`] and answers whether the process behind it is code-signed by one of the
7//! identities the policy names, so a raw-`Listener` host (the shared speech service) and a
8//! `Handler` host (an owner-only route such as the licence-snapshot delivery) use the same RightKit
9//! code instead of carrying Security.framework or `WinVerifyTrust` FFI of their own.
10//!
11//! * **macOS** - `SecCodeCopyGuestWithAttributes` with `kSecGuestAttributeAudit` from the peer's
12//!   audit token (the kernel's record of the connecting process instance, never a bare pid that
13//!   could be reused), then `SecCodeCheckValidity` against
14//!   `anchor apple generic and certificate leaf[subject.OU] = "<team id>"`. This is dynamic
15//!   validation of the running code: unsigned, ad-hoc and other-team code cannot satisfy it.
16//! * **Windows** - `WinVerifyTrust` (no UI, cached URL retrieval only) on the peer's image path
17//!   as the kernel reports it for the process handle that is open during the check, then the
18//!   leaf signer's common name against the policy. The check covers the image file on disk at
19//!   verification time; Windows offers no handle-bound proof that the running pages equal that
20//!   file.
21//! * **Elsewhere** - no signing identity exists, so every peer is refused
22//!   ([`HostIdentityReason::Unverified`]).
23//!
24//! Fail closed: a policy that names no identity for this platform, a peer with no audit token or
25//! a pid that is not the process that connected, a process that exited, an unreadable image, an
26//! unsigned or untrusted binary and a signer that is not named are all `Err`. A pid is pinned by
27//! its start time recorded at accept (the same pin as
28//! [`CallContext::verify_peer_executable`](crate::CallContext::verify_peer_executable)).
29
30use crate::host_identity::{verify_peer_process, HostExpectation, HostIdentityError, HostProcess};
31use crate::peer::PeerIdentity;
32use std::path::{Path, PathBuf};
33
34/// A code-signing identity a peer may carry.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum SigningIdentity {
38    /// macOS: the Apple Developer team identifier (`kSecCodeInfoTeamIdentifier`, the leaf
39    /// certificate's `subject.OU`), exactly ten upper-case letters or digits. Anything else
40    /// never matches (the value is spliced into a requirement string, so it is refused rather
41    /// than escaped).
42    MacTeamId(String),
43    /// Windows: the common name of the Authenticode leaf signer certificate, compared exactly
44    /// and case-sensitively. An empty name never matches.
45    WindowsCommonName(String),
46}
47
48/// Which signed peers a host accepts. Build it with [`PeerPolicy::signed_by`] and
49/// [`or_signed_by`](Self::or_signed_by); only the identities that belong to the running
50/// platform are consulted, and a policy with none for it refuses every peer.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct PeerPolicy {
53    signers: Vec<SigningIdentity>,
54    executables: Vec<PathBuf>,
55}
56
57impl PeerPolicy {
58    /// Accept a peer signed by `identity`.
59    pub fn signed_by(identity: SigningIdentity) -> Self {
60        Self {
61            signers: vec![identity],
62            executables: Vec::new(),
63        }
64    }
65
66    /// Also accept a peer signed by `identity` (the macOS and the Windows identity of one
67    /// product, say).
68    #[must_use]
69    pub fn or_signed_by(mut self, identity: SigningIdentity) -> Self {
70        self.signers.push(identity);
71        self
72    }
73
74    /// Additionally require the peer's executable to be one of these paths (compared as
75    /// [`HostExpectation`] compares). Without any, the signature alone decides.
76    #[must_use]
77    pub fn only_executable(mut self, path: impl Into<PathBuf>) -> Self {
78        self.executables.push(path.into());
79        self
80    }
81
82    /// The identities this policy names.
83    pub fn signers(&self) -> &[SigningIdentity] {
84        &self.signers
85    }
86
87    /// This policy as a [`HostExpectation`] (the optional executable pins plus the signature
88    /// verifier), for an API that takes an expectation, such as
89    /// `LicenseGate::deliver_from_owner(cx, &policy.host_expectation(), frame)` in
90    /// `rightkit-agent-server` (rightkit#1501). Used with `CallContext::verify_peer_executable`
91    /// it gives the same answer as `CallContext::verify_peer_signed`.
92    pub fn host_expectation(&self) -> HostExpectation {
93        expectation(self)
94    }
95}
96
97/// The private-IPC method that carries the owner's licence snapshot (rightkit#1501). The host
98/// registers it on its private service and allows only the owner app; its handler authenticates
99/// the peer with [`CallContext::verify_peer_signed`](crate::CallContext::verify_peer_signed) or a
100/// [`PeerPolicy::host_expectation`] before reading the frame, so the loopback HTTP route
101/// `/internal/license-snapshot` (bearer token only) can be retired.
102pub const LICENSE_SNAPSHOT_METHOD: &str = "license.snapshot";
103
104/// Why a signature check did not admit a binary.
105#[derive(Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum SignatureError {
108    /// The check ran and the binary does not satisfy it: unsigned, ad-hoc, untrusted chain or a
109    /// signer that is not the one named. Carries the OS reason.
110    NotSigned(String),
111    /// The check could not run to an answer (no audit token, process gone, unreadable file).
112    Unverifiable(String),
113    /// This platform has no signing identity to check.
114    Unsupported(&'static str),
115}
116
117impl std::fmt::Display for SignatureError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            SignatureError::NotSigned(why) => {
121                write!(f, "the code signature is not acceptable ({why})")
122            }
123            SignatureError::Unverifiable(why) => {
124                write!(f, "the code signature could not be checked ({why})")
125            }
126            SignatureError::Unsupported(why) => {
127                write!(f, "code signatures are not checked here ({why})")
128            }
129        }
130    }
131}
132
133impl std::error::Error for SignatureError {}
134
135/// Is the process behind `peer` (the identity recorded when the connection was accepted) one the
136/// `policy` admits? Returns the identified process, the same [`HostProcess`] the executable check
137/// returns, so the caller can log its pid and path.
138///
139/// Call it per connection, before reading a request, and do not cache the answer past the
140/// connection. See the module docs for what each platform verifies and for the fail-closed rules.
141pub fn verify_peer(
142    peer: &PeerIdentity,
143    policy: &PeerPolicy,
144) -> Result<HostProcess, HostIdentityError> {
145    verify_peer_process(Some(peer), &expectation(policy))
146}
147
148/// The [`HostExpectation`] that encodes `policy`: the optional executable pins plus a verifier
149/// that runs the platform signature check. Used by [`verify_peer`] and by
150/// `CallContext::verify_peer_signed`.
151pub(crate) fn expectation(policy: &PeerPolicy) -> HostExpectation {
152    let signers = policy.signers.clone();
153    let check = move |host: &HostProcess| check_signature(host, &signers);
154    let mut expect = HostExpectation::any_executable_verified(check.clone());
155    if !policy.executables.is_empty() {
156        expect = HostExpectation::any_of(policy.executables.clone()).with_verifier(check);
157    }
158    expect
159}
160
161fn check_signature(host: &HostProcess, signers: &[SigningIdentity]) -> Result<(), String> {
162    if signers.is_empty() {
163        return Err("the policy names no signing identity".to_string());
164    }
165    platform::check(host, signers).map_err(|e| e.to_string())
166}
167
168/// What `WinVerifyTrust` established about a file.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct AuthenticodeSigner {
171    /// The common name of the leaf signer certificate.
172    pub common_name: String,
173}
174
175/// Verify the Authenticode signature of the file at `path` (Windows, `WinVerifyTrust` with no UI
176/// and cached URL retrieval only) and return its signer (rightkit#405). With
177/// `expected_common_name` the signer's common name must equal it exactly. On other platforms
178/// the answer is [`SignatureError::Unsupported`]: nothing is ever reported as verified there.
179pub fn verify_authenticode(
180    path: &Path,
181    expected_common_name: Option<&str>,
182) -> Result<AuthenticodeSigner, SignatureError> {
183    platform::authenticode(path, expected_common_name)
184}
185
186/// The macOS code requirement for `team_id`, or `None` when it is not a ten-character
187/// upper-case alphanumeric team identifier.
188#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
189pub(crate) fn mac_requirement(team_id: &str) -> Option<String> {
190    let valid = team_id.len() == 10
191        && team_id
192            .bytes()
193            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
194    valid.then(|| format!("anchor apple generic and certificate leaf[subject.OU] = \"{team_id}\""))
195}
196
197/// Exact, case-sensitive signer common-name match. An empty name never matches.
198#[cfg_attr(not(windows), allow(dead_code))]
199pub(crate) fn signer_matches(found: &str, expected: &str) -> bool {
200    !found.is_empty() && found == expected
201}
202
203#[cfg(target_os = "macos")]
204mod platform {
205    use super::{mac_requirement, AuthenticodeSigner, SignatureError, SigningIdentity};
206    use crate::host_identity::HostProcess;
207    use core_foundation::base::TCFType;
208    use core_foundation::data::CFData;
209    use security_framework::os::macos::code_signing::{
210        Flags, GuestAttributes, SecCode, SecRequirement,
211    };
212    use std::path::Path;
213
214    pub(super) fn authenticode(
215        _path: &Path,
216        _expected: Option<&str>,
217    ) -> Result<AuthenticodeSigner, SignatureError> {
218        Err(SignatureError::Unsupported(
219            "Authenticode is a Windows signature",
220        ))
221    }
222
223    pub(super) fn check(
224        host: &HostProcess,
225        signers: &[SigningIdentity],
226    ) -> Result<(), SignatureError> {
227        // The audit token names one process instance; a bare pid can be reused between accept
228        // and check, so a peer without a token is not verified.
229        let Some(token) = host.audit_token else {
230            return Err(SignatureError::Unverifiable(
231                "the kernel gave no audit token for the peer".into(),
232            ));
233        };
234        let mut teams = signers.iter().filter_map(|s| match s {
235            SigningIdentity::MacTeamId(team) => Some(team.as_str()),
236            _ => None,
237        });
238        let Some(first) = teams.next() else {
239            return Err(SignatureError::Unsupported(
240                "the policy names no macOS team identifier",
241            ));
242        };
243        let mut last = verify_audit_token(&token, first);
244        for team in teams {
245            if last.is_ok() {
246                break;
247            }
248            last = verify_audit_token(&token, team);
249        }
250        last
251    }
252
253    /// `SecCodeCopyGuestWithAttributes(kSecGuestAttributeAudit)` then `SecCodeCheckValidity`
254    /// against the team requirement.
255    fn verify_audit_token(token: &[u32; 8], team_id: &str) -> Result<(), SignatureError> {
256        let Some(text) = mac_requirement(team_id) else {
257            return Err(SignatureError::Unverifiable(
258                "the configured team identifier is malformed".into(),
259            ));
260        };
261        let requirement: SecRequirement = text.parse().map_err(|error| {
262            SignatureError::Unverifiable(format!("requirement did not compile: {error}"))
263        })?;
264        let mut bytes = [0u8; 32];
265        for (i, word) in token.iter().enumerate() {
266            bytes[i * 4..i * 4 + 4].copy_from_slice(&word.to_ne_bytes());
267        }
268        let data = CFData::from_buffer(&bytes);
269        let mut attributes = GuestAttributes::new();
270        attributes.set_audit_token(data.as_concrete_TypeRef());
271        let guest = SecCode::copy_guest_with_attribues(None, &attributes, Flags::NONE).map_err(
272            |error| {
273                SignatureError::Unverifiable(format!(
274                    "peer code object not found (OSStatus {}): {error}",
275                    error.code()
276                ))
277            },
278        )?;
279        guest
280            .check_validity(Flags::NONE, &requirement)
281            .map_err(|error| {
282                SignatureError::NotSigned(format!(
283                    "code requirement not satisfied (OSStatus {}): {error}",
284                    error.code()
285                ))
286            })
287    }
288}
289
290#[cfg(windows)]
291mod platform {
292    use super::{signer_matches, AuthenticodeSigner, SignatureError, SigningIdentity};
293    use crate::host_identity::HostProcess;
294    use std::ffi::{c_void, OsString};
295    use std::os::windows::ffi::{OsStrExt, OsStringExt};
296    use std::path::Path;
297    use std::ptr::null_mut;
298
299    use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
300    use windows_sys::Win32::Security::Cryptography::{
301        szOID_COMMON_NAME, CertGetNameStringW, CERT_NAME_ATTR_TYPE,
302    };
303    use windows_sys::Win32::Security::WinTrust::{
304        WTHelperGetProvCertFromChain, WTHelperGetProvSignerFromChain,
305        WTHelperProvDataFromStateData, WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2,
306        WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL,
307        WTD_CHOICE_FILE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY,
308        WTD_UI_NONE,
309    };
310
311    pub(super) fn check(
312        host: &HostProcess,
313        signers: &[SigningIdentity],
314    ) -> Result<(), SignatureError> {
315        let names: Vec<&str> = signers
316            .iter()
317            .filter_map(|s| match s {
318                SigningIdentity::WindowsCommonName(cn) => Some(cn.as_str()),
319                _ => None,
320            })
321            .collect();
322        if names.is_empty() {
323            return Err(SignatureError::Unsupported(
324                "the policy names no Windows signer",
325            ));
326        }
327        let signer = authenticode(&host.executable, None)?;
328        if names.iter().any(|n| signer_matches(&signer.common_name, n)) {
329            Ok(())
330        } else {
331            Err(SignatureError::NotSigned(format!(
332                "signed by another publisher ({})",
333                signer.common_name
334            )))
335        }
336    }
337
338    pub(super) fn authenticode(
339        path: &Path,
340        expected: Option<&str>,
341    ) -> Result<AuthenticodeSigner, SignatureError> {
342        let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
343        // SAFETY: structures are zero-initialised then filled; every pointer outlives the
344        // WinVerifyTrust calls; the state opened by VERIFY is released by the CLOSE call below
345        // on every path.
346        unsafe {
347            let mut file = WINTRUST_FILE_INFO {
348                cbStruct: std::mem::size_of::<WINTRUST_FILE_INFO>() as u32,
349                pcwszFilePath: wide.as_ptr(),
350                ..Default::default()
351            };
352            let mut data = WINTRUST_DATA {
353                cbStruct: std::mem::size_of::<WINTRUST_DATA>() as u32,
354                dwUIChoice: WTD_UI_NONE,
355                fdwRevocationChecks: WTD_REVOKE_NONE,
356                dwUnionChoice: WTD_CHOICE_FILE,
357                Anonymous: WINTRUST_DATA_0 { pFile: &mut file },
358                dwStateAction: WTD_STATEACTION_VERIFY,
359                dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL,
360                ..Default::default()
361            };
362            let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
363            let status = WinVerifyTrust(
364                INVALID_HANDLE_VALUE,
365                &mut action,
366                (&mut data as *mut WINTRUST_DATA).cast::<c_void>(),
367            );
368            let outcome = if status != 0 {
369                Err(SignatureError::NotSigned(format!(
370                    "WinVerifyTrust 0x{:08X}",
371                    status as u32
372                )))
373            } else {
374                match signer_common_name(data.hWVTStateData) {
375                    Some(name) => match expected {
376                        Some(expected) if !signer_matches(&name, expected) => {
377                            Err(SignatureError::NotSigned(format!(
378                                "signed by another publisher ({name})"
379                            )))
380                        }
381                        _ => Ok(AuthenticodeSigner { common_name: name }),
382                    },
383                    None => Err(SignatureError::Unverifiable(
384                        "signer certificate unreadable".into(),
385                    )),
386                }
387            };
388            data.dwStateAction = WTD_STATEACTION_CLOSE;
389            WinVerifyTrust(
390                INVALID_HANDLE_VALUE,
391                &mut action,
392                (&mut data as *mut WINTRUST_DATA).cast::<c_void>(),
393            );
394            outcome
395        }
396    }
397
398    /// Common name of the leaf certificate of the first verified signer in the open
399    /// WinVerifyTrust state.
400    ///
401    /// # Safety
402    /// `state` must be the `hWVTStateData` of a `WTD_STATEACTION_VERIFY` call that has not been
403    /// closed.
404    unsafe fn signer_common_name(state: HANDLE) -> Option<String> {
405        let provider = WTHelperProvDataFromStateData(state);
406        if provider.is_null() {
407            return None;
408        }
409        let signer = WTHelperGetProvSignerFromChain(provider, 0, 0, 0);
410        if signer.is_null() {
411            return None;
412        }
413        let cert = WTHelperGetProvCertFromChain(signer, 0);
414        if cert.is_null() || (*cert).pCert.is_null() {
415            return None;
416        }
417        let context = (*cert).pCert;
418        let oid = szOID_COMMON_NAME as *const c_void;
419        let needed = CertGetNameStringW(context, CERT_NAME_ATTR_TYPE, 0, oid, null_mut(), 0);
420        if needed <= 1 {
421            return None;
422        }
423        let mut buf = vec![0u16; needed as usize];
424        let written = CertGetNameStringW(
425            context,
426            CERT_NAME_ATTR_TYPE,
427            0,
428            oid,
429            buf.as_mut_ptr(),
430            needed,
431        );
432        if written <= 1 {
433            return None;
434        }
435        Some(
436            OsString::from_wide(&buf[..written as usize - 1])
437                .to_string_lossy()
438                .into_owned(),
439        )
440    }
441}
442
443#[cfg(not(any(target_os = "macos", windows)))]
444mod platform {
445    use super::{AuthenticodeSigner, SignatureError, SigningIdentity};
446    use crate::host_identity::HostProcess;
447    use std::path::Path;
448
449    pub(super) fn authenticode(
450        _path: &Path,
451        _expected: Option<&str>,
452    ) -> Result<AuthenticodeSigner, SignatureError> {
453        Err(SignatureError::Unsupported(
454            "Authenticode is a Windows signature",
455        ))
456    }
457
458    /// No signing identity exists on this platform: every peer is unverifiable.
459    pub(super) fn check(
460        _host: &HostProcess,
461        _signers: &[SigningIdentity],
462    ) -> Result<(), SignatureError> {
463        Err(SignatureError::Unsupported(
464            "no peer code-signature verification on this platform",
465        ))
466    }
467}