Compare commits

...
1 Commits
Author SHA1 Message Date
Ian 2254b26808 Use a CA enrolled by MDM when one is installed
Managed Macs can enroll a CA over ACME with a Secure Enclave key, signed by a root the MDM trusts, so standard users never need to trust a CA. Leaves are now issued from the CA certificate itself, since a managed CA's subject is not ours.
2026-10-21 17:48:12 -04:00
11 changed files with 106 additions and 19 deletions

No files matched your search

+2 -2
View File
@@ -62,7 +62,7 @@ directories = "6.0.0"
percent-encoding = "2.3.2"
rama = { version = "0.4.0", features = ["boring", "http-full", "ring", "rustls"] }
rand = "0.10.2"
rcgen = "0.14.10"
rcgen = { version = "0.14.10", features = ["x509-parser"] }
regex = "1.13.1"
semver = "1.0.28"
serde = { version = "1.0.229", features = ["derive"] }
@@ -76,7 +76,7 @@ which = "8"
[target.'cfg(target_vendor = "apple")'.dependencies]
core-foundation = "0.10.1"
rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework = { version = "3.7.0", features = ["macos-12"] }
security-framework-sys = "2.17.0"
[dev-dependencies]
+5 -3
View File
@@ -39,7 +39,8 @@ impl CertificateAuthority {
(held == signer.subject_public_key_info()).then_some(ca)
}
fn new(pem: String) -> Result<Self, BoxError> {
/// The first certificate is the CA; any after it are its chain, for clients that read no keychain.
pub fn new(pem: String) -> Result<Self, BoxError> {
Ok(Self { crt: X509::from_pem(pem.as_bytes())?, pem })
}
}
@@ -61,9 +62,10 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
}
pub const COMMON_NAME: &str = "Strix Firewall";
/// Of a CA an MDM enrolls instead, which must not match `COMMON_NAME`: uninstall deletes those.
pub const MANAGED_COMMON_NAME: &str = "Strix Firewall Managed";
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::OrganizationName, "Strix");
+4 -6
View File
@@ -1,17 +1,15 @@
use super::ca::{params, CaSigner};
use super::ca::CaSigner;
use rama::error::BoxError;
use rama::tls::boring::core::pkey::PKey;
use rama::tls::boring::core::x509::X509;
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
use rama::utils::collections::non_empty_vec;
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
#[derive(Clone)]
pub struct ConstrainedCertIssuer {
ca_crt: X509,
ca_params: Arc<CertificateParams>,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
@@ -24,8 +22,7 @@ impl ConstrainedCertIssuer {
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts, crl }
Self { ca_crt, signer, hosts, crl }
}
}
@@ -54,7 +51,8 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
// The TLS server needs the leaf key in memory, so it can't be the platform key.
let leaf_key = KeyPair::generate()?;
let issuer = Issuer::from_params(&self.ca_params, &*self.signer);
let ca_der = self.ca_crt.to_der()?;
let issuer = Issuer::from_ca_cert_der(&ca_der.into(), &*self.signer)?;
let crt = X509::from_der(leaf.signed_by(&leaf_key, &issuer)?.der())?;
let key = PKey::private_key_from_pem(leaf_key.serialize_pem().as_bytes())?;
+1 -1
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, MANAGED_COMMON_NAME};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+7
View File
@@ -33,10 +33,17 @@ impl EnclaveKey {
Some(blob) => load(&blob)?,
None => create()?,
};
Self::new(key)
}
pub fn new(key: SecKey) -> Result<Self, BoxError> {
let public = key
.public_key()
.and_then(|public| public.external_representation())
.ok_or("secure enclave key has no exportable public half")?;
if public.len() != 65 {
return Err("the CA key must be P-256".into());
}
Ok(EnclaveKey { key, public: public.to_vec() })
}
+44 -2
View File
@@ -1,11 +1,53 @@
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::CertificateAuthority;
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
use crate::mitm::{CertificateAuthority, MANAGED_COMMON_NAME};
use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain;
use rama::tls::boring::core::x509::X509;
use rcgen::SigningKey;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework::policy::SecPolicy;
use security_framework::trust::SecTrust;
use security_framework_sys::base::errSecItemNotFound;
use std::path::Path;
use tracing::{info, warn};
/// A CA an MDM enrolled over ACME. The MDM trusts its issuer, so the user is never asked to.
pub fn managed_certificate_authority(
) -> Result<Option<(EnclaveKey, CertificateAuthority)>, BoxError> {
let found = ItemSearchOptions::new()
.class(ItemClass::identity())
.keychains(&[SecKeychain::open(SYSTEM_KEYCHAIN)?])
.load_refs(true)
.limit(Limit::All)
.search();
let results = match found {
Ok(results) => results,
Err(error) if error.code() == errSecItemNotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
for result in results {
let SearchResult::Ref(Reference::Identity(identity)) = result else { continue };
let crt = identity.certificate()?;
if crt.subject_summary() != MANAGED_COMMON_NAME {
continue;
}
let key = EnclaveKey::new(identity.private_key()?)?;
let trust = SecTrust::create_with_certificates(&[crt], &[SecPolicy::create_x509()])?;
trust.evaluate_with_error().map_err(|e| format!("the managed CA is not trusted: {e}"))?;
let mut pem = String::new();
for crt in trust.chain() {
pem.push_str(std::str::from_utf8(&X509::from_der(&crt.to_der())?.to_pem()?)?);
}
let ca = CertificateAuthority::new(pem)?;
info!("using the CA enrolled by MDM");
export_pem(&ca);
return Ok(Some((key, ca)));
}
Ok(None)
}
/// Kept in the System keychain so trust survives restarts; replaced if the hosts change.
pub fn certificate_authority(
hosts: &[&str],
+12 -3
View File
@@ -32,6 +32,7 @@ const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
const SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
#[derive(Clone)]
struct Handler {
@@ -102,10 +103,18 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
let managed = install::managed_certificate_authority().inspect_err(|_| telemetry::error("ca"))?;
let (key, ca) = match managed {
Some(managed) => managed,
None => {
let key = enclave::EnclaveKey::load_or_create()
.inspect_err(|_| telemetry::error("enclave"))?;
let ca = install::certificate_authority(&hosts, &key);
(key, ca.inspect_err(|_| telemetry::error("ca"))?)
}
};
let signer: mitm::CaSigner = Arc::new(key);
let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let ca = Arc::new(ca);
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
Ok(Handler { ca, hosts, service })
}
+2 -2
View File
@@ -1,5 +1,5 @@
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
use crate::mitm::COMMON_NAME;
use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain;
@@ -17,7 +17,7 @@ const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
pub fn uninstall() -> Result<(), BoxError> {
let deleted = ItemSearchOptions::new()
.class(ItemClass::certificate())
.keychains(&[SecKeychain::open("/Library/Keychains/System.keychain")?])
.keychains(&[SecKeychain::open(SYSTEM_KEYCHAIN)?])
.label(COMMON_NAME)
.limit(Limit::All)
.delete();
+1
View File
@@ -4,4 +4,5 @@ enum FirewallIDs {
static let appBundleID = "com.usestrix.firewall"
static let extensionBundleID = "com.usestrix.firewall.extension"
static let caCommonName = "Strix Firewall"
static let managedCaCommonName = "Strix Firewall Managed"
}
+4
View File
@@ -34,7 +34,11 @@ enum TrustStore {
return certificate
}
/// The extension only uses a managed CA after checking that it chains to a root the MDM trusts.
static func isTrusted(_ certificate: SecCertificate) -> Bool {
var name: CFString?
if SecCertificateCopyCommonName(certificate, &name) == errSecSuccess,
name as String? == FirewallIDs.managedCaCommonName { return true }
var settings: CFArray?
return SecTrustSettingsCopyTrustSettings(certificate, .admin, &settings) == errSecSuccess
}
+24
View File
@@ -0,0 +1,24 @@
# Deploying with MDM
On a managed Mac the firewall can use a CA your MDM enrolls instead of minting its own, so users
need no admin rights and see no trust prompt. Each Mac still has its own key in its Secure Enclave;
your PKI signs its certificate.
Push one device-channel profile with:
1. **Certificate** (`com.apple.security.root`): your root CA.
2. **ACME Certificate** (`com.apple.security.acme`), macOS 14 or later:
- `KeyType` `ECSECPrimeRandom`, `KeySize` `256`, `HardwareBound` `true`, `Attest` `true`
- `Subject` `[[["CN", "Strix Firewall Managed"]]]`; the extension finds the identity by this name
- `AllowAllAppsAccess` `true`, so the extension can sign with the key
3. **System Extensions**: allow `com.usestrix.firewall.extension` for the Strix team ID.
Your ACME server must issue the enrolled certificate as a CA, not a leaf:
- basic constraints `CA:TRUE, pathlen:0` and key usage `keyCertSign`
- critical name constraints permitting only the screened hosts: `registry.npmjs.org`, `pypi.org`,
`files.pythonhosted.org`, `index.crates.io`, `static.crates.io`, `crates.io`,
`proxy.golang.org`, `rubygems.org`
The extension checks the certificate chains to a trusted root before using it. Install the profile
before the firewall: without a managed CA the extension mints its own, which needs an admin to trust.