mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
1
Commits
main
...
macos/mdm-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2254b26808 |
No files matched your search
+2
-2
@@ -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
@@ -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");
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user