Compare 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
18 changed files with 117 additions and 62 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]
+1 -1
View File
@@ -82,7 +82,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
};
let reason = policies::screen(&url, firewall).await;
telemetry::screened(firewall, &url, reason.as_deref());
telemetry::screened(firewall, &url, reason.is_some());
if let Some(reason) = reason {
warn!(method = %req.method(), %url, %reason, "blocked");
return Ok(blocked(&reason));
-2
View File
@@ -56,7 +56,6 @@ fn options() -> Command {
}
fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load();
let port = config.port;
print_setup(&config);
@@ -84,7 +83,6 @@ fn uninstall() -> ! {
}
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
+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;
+1 -1
View File
@@ -37,7 +37,7 @@ where
async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
if let Some(url) = https_url(&req) {
let reason = policies::screen(&url, &self.firewall).await;
telemetry::screened(&self.firewall, &url, reason.as_deref());
telemetry::screened(&self.firewall, &url, reason.is_some());
if let Some(reason) = reason {
warn!(%url, "blocked by firewall: {reason}");
return Ok(blocked(&reason));
+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 -4
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 {
@@ -98,15 +99,22 @@ impl TransparentProxyHandlerFactory for Factory {
}
fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat();
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();
-3
View File
@@ -72,9 +72,6 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
}
_ => None,
}
}
+1 -5
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, reply, text};
use crate::policies::{self, Check, Decision};
use crate::{telemetry, Firewall, FirewallConfig};
use crate::{Firewall, FirewallConfig};
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
@@ -19,7 +19,6 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config));
telemetry::heartbeat();
let exec = Executor::default();
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
@@ -45,7 +44,6 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
}
Decision::Block { eco, pkg, filter, reason } => {
let reason = format!("{} {reason}", pkg.label());
telemetry::screened_package(eco, &pkg, Some(&reason));
warn!(%eco, %filter, "blocked by firewall: {reason}");
blocked(&reason)
}
@@ -53,11 +51,9 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
match &check {
Check::NoPackage => {}
Check::Allowed(pkg) => {
telemetry::screened_package(target.eco, pkg, None);
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
}
Check::Unverified { pkg, filter, error } => {
telemetry::screened_package(target.eco, pkg, None);
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
}
}
+7 -20
View File
@@ -1,11 +1,10 @@
use crate::policies::Package;
use crate::{Firewall, FirewallConfig};
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
use rama::http::service::client::HttpClientExt;
use rama::rt::blocking::Runtime;
use serde_json::{json, Value};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
@@ -30,40 +29,29 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
})
});
/// How the firewall is running: "extension", "wrapper" or "proxy".
static MODE: OnceLock<&'static str> = OnceLock::new();
struct Telemetry {
key: &'static str,
session: String,
events: Mutex<Vec<Value>>,
}
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
if TELEMETRY.is_none() {
return;
}
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
return;
};
if let Some(pkg) = firewall.resolve(registry, rest).pkg {
screened_package(registry.eco, &pkg, reason);
}
}
pub fn screened_package(eco: &str, pkg: &Package, reason: Option<&str>) {
let mut properties = json!({ "eco": eco, "blocked": reason.is_some() });
if let Some(reason) = reason {
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
return;
};
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
if blocked {
properties["package"] = pkg.label().into();
properties["reason"] = reason.into();
}
record("package_screened", properties);
}
pub fn set_mode(mode: &'static str) {
MODE.set(mode).ok();
}
pub fn error(kind: &'static str) {
record("error", json!({ "kind": kind }));
}
@@ -93,7 +81,6 @@ pub fn flush() {
fn record(event: &str, mut properties: Value) {
let Some(telemetry) = &*TELEMETRY else { return };
properties["distinct_id"] = telemetry.session.clone().into();
properties["mode"] = MODE.get().copied().into();
properties["os"] = std::env::consts::OS.into();
properties["arch"] = std::env::consts::ARCH.into();
properties["$process_person_profile"] = false.into();
+1 -10
View File
@@ -1,6 +1,6 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::{screen, Package};
use firewall::policies::Package;
use firewall::{Firewall, FirewallConfig};
#[test]
@@ -31,12 +31,3 @@ async fn an_allowed_version_skips_the_filters() {
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
+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.