Compare commits

..
Author SHA1 Message Date
Ian e5f4dd70ac Use the com.usestrix.firewall bundle ID 2026-10-21 12:07:56 -04:00
46 changed files with 215 additions and 688 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

+3 -7
View File
@@ -44,10 +44,6 @@ path = "tests/denylist.rs"
name = "osv"
path = "tests/osv.rs"
[[test]]
name = "age"
path = "tests/age.rs"
[[test]]
name = "mitm"
path = "tests/mitm.rs"
@@ -62,12 +58,12 @@ 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 = { version = "0.14.10", features = ["x509-parser"] }
rcgen = "0.14.10"
regex = "1.13.1"
semver = "1.0.28"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
time = { version = "0.3.47", features = ["formatting", "parsing"] }
time = { version = "0.3.47", features = ["formatting"] }
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
@@ -76,7 +72,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 = { version = "3.7.0", features = ["macos-12"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[dev-dependencies]
+13 -30
View File
@@ -1,11 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -13,43 +13,37 @@ pub struct FirewallConfig {
pub port: u16,
/// Allow a package when a filter could not check it.
pub fail_open: bool,
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub name: String,
/// Every version when absent.
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DenylistSource {
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
pub base: String,
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
pub token: Option<String>,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: u64,
}
impl DenylistSource {
pub fn token(&self) -> Option<String> {
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
}
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: BUILT_IN.to_vec(),
}
@@ -59,8 +53,9 @@ impl Default for FirewallConfig {
impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
token: None,
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
.into(),
poll_secs: 10,
}
@@ -85,21 +80,9 @@ impl FirewallConfig {
pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port, path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
}
pub fn path() -> Option<PathBuf> {
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
Some(dirs.config_dir().join("config.json"))
}
+10 -7
View File
@@ -1,5 +1,4 @@
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter;
@@ -27,11 +26,8 @@ impl Firewall {
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
}
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
Firewall { config, patterns, filters }
}
@@ -44,7 +40,14 @@ impl Firewall {
}
pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts()
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.config.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
pub fn is_index_url(&self, url: &str) -> bool {
-13
View File
@@ -12,10 +12,6 @@ fn main() {
firewall::init_logging("info");
serve()
}
if matches.subcommand_matches("uninstall").is_some() {
firewall::init_logging("info");
uninstall()
}
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
@@ -44,8 +40,6 @@ fn options() -> Command {
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
// Run as root by the macOS app's uninstall.
.subcommand(Command::new("uninstall").hide(true))
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
@@ -75,13 +69,6 @@ fn serve() -> ! {
}
}
fn uninstall() -> ! {
match firewall::os::uninstall() {
Ok(()) => exit(0),
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
}
}
fn run(invocation: Invocation) -> ! {
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
+7 -30
View File
@@ -1,12 +1,10 @@
use rama::error::BoxError;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
SerialNumber, SigningKey,
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyUsagePurpose, NameConstraints, SigningKey,
};
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
@@ -39,42 +37,21 @@ impl CertificateAuthority {
(held == signer.subject_public_key_info()).then_some(ca)
}
/// 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> {
fn new(pem: String) -> Result<Self, BoxError> {
Ok(Self { crt: X509::from_pem(pem.as_bytes())?, pem })
}
}
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
/// Signed fresh for each fetch; a week's validity outlasts any verifier's cache.
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
let now = OffsetDateTime::now_utc();
let list = CertificateRevocationListParams {
this_update: now - Duration::hours(1),
next_update: now + Duration::days(7),
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
issuing_distribution_point: None,
revoked_certs: Vec::new(),
key_identifier_method: KeyIdMethod::Sha256,
};
let ca = params(hosts);
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
}
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";
fn params(hosts: &[&str]) -> CertificateParams {
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default();
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
// rcgen signs a CRL only for a CA that may.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts
.iter()
+8 -15
View File
@@ -1,28 +1,25 @@
use super::ca::CaSigner;
use super::ca::{params, 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 rcgen::{CertificateParams, 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>,
}
impl ConstrainedCertIssuer {
pub(super) fn new(
ca_crt: X509,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
Self { ca_crt, signer, hosts, crl }
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts }
}
}
@@ -45,14 +42,10 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
subject.push(DnType::CommonName, common_name);
let mut leaf = CertificateParams::new(names)?;
leaf.distinguished_name = subject;
if let Some(crl) = &self.crl {
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
}
// The TLS server needs the leaf key in memory, so it can't be the platform key.
let leaf_key = KeyPair::generate()?;
let ca_der = self.ca_crt.to_der()?;
let issuer = Issuer::from_ca_cert_der(&ca_der.into(), &*self.signer)?;
let issuer = Issuer::from_params(&self.ca_params, &*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())?;
+2 -4
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, MANAGED_COMMON_NAME};
pub use ca::{CaSigner, CertificateAuthority};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
@@ -25,16 +25,14 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
type InterceptService =
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
pub fn mitm_service(
firewall: Arc<Firewall>,
ca: &CertificateAuthority,
signer: CaSigner,
crl: Option<String>,
exec: Executor,
) -> MitmService {
let hosts = firewall.hosts();
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
-7
View File
@@ -33,17 +33,10 @@ 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() })
}
+2 -44
View File
@@ -1,53 +1,11 @@
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
use crate::mitm::{CertificateAuthority, MANAGED_COMMON_NAME};
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::CertificateAuthority;
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],
+9 -16
View File
@@ -2,8 +2,6 @@ mod enclave;
mod install;
mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::{mitm, telemetry};
use crate::Firewall;
@@ -32,7 +30,6 @@ 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 {
@@ -52,11 +49,15 @@ impl TransparentProxyHandler for Handler {
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
}
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
/// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"uninstall" => match uninstall::certificate_authority(&self.ca) {
Ok(()) => "ok".to_owned(),
Err(error) => format!("error: {error}"),
},
other => format!("error: unknown message {other}"),
}))
}
@@ -103,19 +104,11 @@ 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 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 key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
let signer: mitm::CaSigner = Arc::new(key);
let ca = Arc::new(ca);
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let service = mitm::mitm_service(firewall, &ca, signer, exec);
Ok(Handler { ca, hosts, service })
}
+9 -28
View File
@@ -1,45 +1,26 @@
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
use crate::mitm::COMMON_NAME;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::CertificateAuthority;
use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework_sys::base::errSecItemNotFound;
use std::io::ErrorKind;
use tracing::info;
/// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
/// Removes what only root can: every CA the extension minted, its enclave key and exported
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those.
pub fn uninstall() -> Result<(), BoxError> {
let deleted = ItemSearchOptions::new()
.class(ItemClass::certificate())
.keychains(&[SecKeychain::open(SYSTEM_KEYCHAIN)?])
.label(COMMON_NAME)
.limit(Limit::All)
.delete();
let mut removed = match deleted {
Ok(()) => true,
Err(error) if error.code() == errSecItemNotFound => false,
Err(error) => return Err(error.into()),
};
// Reading a secret's data is limited to the extension, so delete without checking it exists.
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
EnclaveKey::delete()?;
for path in [CA_FILE, ENV_AGENT] {
match std::fs::remove_file(path) {
Ok(()) => removed = true,
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
_ => {}
}
}
if removed {
info!("removed the CA from the system keychain");
} else {
info!("nothing to uninstall");
}
info!("removed the CA from the system keychain");
Ok(())
}
-9
View File
@@ -1,11 +1,2 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
-72
View File
@@ -1,72 +0,0 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use crate::policies::registries::{Published, Registry};
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::USER_AGENT;
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
pub struct Age {
min: Duration,
}
impl Age {
pub fn new(hours: u64) -> Self {
Age { min: Duration::hours(hours as i64) }
}
}
impl Filter for Age {
fn name(&self) -> &'static str {
"age"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
let Some(published) = &registry.published else { return Verdict::Allow };
let published_at = match published_at(published, &pkg.name, version).await {
Ok(at) => at,
Err(e) => return Verdict::Unknown(e),
};
let age = OffsetDateTime::now_utc() - published_at;
if age >= self.min {
return Verdict::Allow;
}
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
})
}
}
async fn published_at(
published: &Published,
name: &str,
version: &str,
) -> Result<OffsetDateTime, String> {
let name = match published.escape {
Some(escape) => escape(name),
None => name.to_string(),
};
let url = published.url.replace("{name}", &name).replace("{version}", version);
let pointer = published.pointer.replace("{version}", version);
let client = EasyHttpWebClient::default();
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
let response = response.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
}
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
let body: Value =
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
return Err(format!("{url} gave no publish time"));
};
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
}
+33 -15
View File
@@ -1,9 +1,9 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use super::{Filter, Verdict, VerdictFuture};
use crate::config::DenylistSource;
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use semver::Version;
@@ -14,6 +14,9 @@ use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use tracing::{info, warn};
/// The GitHub API turns away requests without a user agent.
const AGENT: &str = "strix-firewall";
#[derive(Debug, PartialEq)]
pub enum AffectedVersions {
All,
@@ -37,15 +40,20 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
pub struct Denylist {
source: DenylistSource,
token: Option<String>,
lists: Arc<Lists>,
watch: JoinHandle<()>,
}
impl Denylist {
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
pub fn new(
source: DenylistSource,
token: Option<String>,
ecosystems: impl Iterator<Item = &'static str>,
) -> Self {
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
Denylist { source, lists, watch }
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
Denylist { source, token, lists, watch }
}
}
@@ -64,7 +72,7 @@ impl Filter for Denylist {
Box::pin(async move {
let eco = registry.osv_name;
let load = || async {
RwLock::new(Arc::new(load(&self.source, eco).await))
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
};
let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock"));
@@ -142,12 +150,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
}
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, None).await;
let mut seen = revision(&source, token.as_deref(), None).await;
loop {
tokio::time::sleep(period).await;
let changed = revision(&source, seen.as_deref()).await;
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
if let Some(current) = &changed {
info!(revision = %current, "denylist source changed");
seen = changed.clone();
@@ -158,7 +166,7 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
if changed.is_none() && !failed {
continue;
}
if let Ok(packages) = load(&source, eco).await {
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
}
}
@@ -166,9 +174,16 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
}
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
async fn revision(
source: &DenylistSource,
token: Option<&str>,
seen: Option<&str>,
) -> Option<String> {
let client = EasyHttpWebClient::default();
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
if let Some(seen) = seen {
request = request.header(IF_NONE_MATCH, seen);
}
@@ -185,8 +200,8 @@ async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String>
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
}
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, &eco.to_lowercase()).await;
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, token, &eco.to_lowercase()).await;
match &list {
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
Err(error) => warn!(%eco, %error, "denylist unavailable"),
@@ -194,10 +209,13 @@ async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
list
}
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
let url = format!("{}/{eco}", source.base);
let client = EasyHttpWebClient::default();
let request = client.get(&url).header(USER_AGENT, AGENT);
let mut request = client.get(&url).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
-22
View File
@@ -1,4 +1,3 @@
pub mod age;
pub mod denylist;
pub mod osv;
@@ -7,9 +6,6 @@ use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
/// Some registry APIs turn away requests without a user agent.
const AGENT: &str = "strix-firewall";
pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
/// `Unknown` means the filter could not check the package.
@@ -30,9 +26,6 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
return None;
}
let mut unsure = None;
for filter in firewall.filters() {
match filter.check(registry, pkg).await {
@@ -44,18 +37,3 @@ pub async fn consult(
}
unsure
}
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
})
}
+1 -8
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static CARGO_INDEX: Registry = Registry {
prefix: "/cargo/index/",
@@ -16,7 +16,6 @@ pub static CARGO_INDEX: Registry = Registry {
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
],
published: None,
};
pub static CARGO_DL: Registry = Registry {
@@ -27,11 +26,6 @@ pub static CARGO_DL: Registry = Registry {
index: false,
normalize: None,
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: None }],
published: Some(Published {
url: "https://crates.io/api/v1/crates/{name}/{version}",
pointer: "/version/created_at",
escape: None,
}),
};
/// `cargo add` resolves versions here, named by config.json's "api" field.
@@ -43,5 +37,4 @@ pub static CRATES_API: Registry = Registry {
index: false,
normalize: None,
patterns: &[],
published: None,
};
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GEM: Registry = Registry {
prefix: "/gem/",
@@ -16,9 +16,4 @@ pub static GEM: Registry = Registry {
// The compact index, which names no version.
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None },
],
published: Some(Published {
url: "https://rubygems.org/api/v2/rubygems/{name}/versions/{version}.json",
pointer: "/created_at",
escape: None,
}),
};
+1 -17
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GO: Registry = Registry {
prefix: "/go/",
@@ -13,11 +13,6 @@ pub static GO: Registry = Registry {
// The version list and the latest lookup, which name no version.
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None },
],
published: Some(Published {
url: "https://proxy.golang.org/{name}/@v/v{version}.info",
pointer: "/Time",
escape: Some(escape),
}),
};
/// The proxy protocol writes every capital as !<lowercase>; advisories do not.
@@ -32,14 +27,3 @@ fn unescape(path: &str) -> String {
}
out
}
fn escape(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for c in path.chars() {
if c.is_ascii_uppercase() {
out.push('!');
}
out.push(c.to_ascii_lowercase());
}
out
}
-9
View File
@@ -48,15 +48,6 @@ pub struct Registry {
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
pub normalize: Option<fn(&str) -> String>,
pub patterns: &'static [Pattern],
pub published: Option<Published>,
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
pub escape: Option<fn(&str) -> String>,
}
impl Registry {
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static NPM: Registry = Registry {
prefix: "/npm/",
@@ -18,9 +18,4 @@ pub static NPM: Registry = Registry {
rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
},
],
published: Some(Published {
url: "https://registry.npmjs.org/{name}",
pointer: "/time/{version}",
escape: None,
}),
};
+1 -7
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
@@ -13,7 +13,6 @@ pub static PYPI: Registry = Registry {
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
Pattern { re: "", rewrite: Some(FILES) },
],
published: None,
};
/// The directories are hash shards, but the filename names the distribution.
@@ -36,11 +35,6 @@ pub static PYPI_FILES: Registry = Registry {
rewrite: None,
},
],
published: Some(Published {
url: "https://pypi.org/pypi/{name}/{version}/json",
pointer: "/urls/0/upload_time_iso_8601",
escape: None,
}),
};
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
-27
View File
@@ -1,27 +0,0 @@
use firewall::policies::screen;
use firewall::{Firewall, FirewallConfig};
use rama::rt::blocking::Runtime;
const OLD_DOWNLOADS: &[&str] = &[
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"https://files.pythonhosted.org/packages/aa/bb/requests-2.31.0-py3-none-any.whl",
"https://static.crates.io/crates/serde/1.0.200",
"https://rubygems.org/gems/rake-13.0.6.gem",
"https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/v1.3.2.zip",
];
#[test]
#[ignore = "queries every registry"]
fn every_registry_reports_when_a_version_was_published() {
let runtime = Runtime::try_new().expect("runtime");
runtime.block_on(async {
let firewall =
|hours| Firewall::new(FirewallConfig { min_age_hours: hours, ..Default::default() });
let (lenient, strict) = (firewall(24), firewall(24 * 365 * 100));
for url in OLD_DOWNLOADS {
assert_eq!(screen(url, &lenient).await, None, "{url}");
let blocked = screen(url, &strict).await.unwrap_or_default();
assert!(blocked.contains("hour minimum"), "{url}: {blocked:?}");
}
});
}
+2 -14
View File
@@ -1,13 +1,12 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::Package;
use firewall::{Firewall, FirewallConfig};
use firewall::FirewallConfig;
#[test]
fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT);
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
}
#[test]
@@ -20,14 +19,3 @@ fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory");
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
-7
View File
@@ -43,10 +43,3 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
}
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
#[test]
fn ca_signs_an_empty_crl() {
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
}
+2 -2
View File
@@ -3,5 +3,5 @@ Firewall.xcodeproj
.env
Extension/Extension.entitlements
Extension/Info.plist
App/App.entitlements
App/Info.plist
Installer/Installer.entitlements
Installer/Info.plist
@@ -1,68 +0,0 @@
{
"images": [
{
"idiom": "mac",
"size": "16x16",
"scale": "1x",
"filename": "icon_16.png"
},
{
"idiom": "mac",
"size": "16x16",
"scale": "2x",
"filename": "icon_32.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "1x",
"filename": "icon_32.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "2x",
"filename": "icon_64.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "1x",
"filename": "icon_128.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "2x",
"filename": "icon_256.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "1x",
"filename": "icon_256.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "2x",
"filename": "icon_512.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "1x",
"filename": "icon_512.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "2x",
"filename": "icon_1024.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

-3
View File
@@ -1,3 +0,0 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -10,9 +10,11 @@ struct ContentView: View {
Divider()
steps
Divider()
transcript
Divider()
footer
}
.frame(width: 540)
.frame(width: 540, height: 560)
.task { await model.refresh() }
.onAppear(perform: recenterIfOffscreen)
.onChange(of: model.completed) { done in
@@ -36,11 +38,9 @@ struct ContentView: View {
private var header: some View {
HStack(spacing: 14) {
Image(nsImage: NSImage(named: "logo-transparent")!)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 40)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 34, weight: .light))
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text("Firewall").font(.title2.weight(.semibold))
Text("Screens package downloads before they reach your machine.")
@@ -70,6 +70,27 @@ struct ContentView: View {
.padding(20)
}
private var transcript: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.transcript.indices, id: \.self) { line in
Text(model.transcript[line])
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.id(line)
}
}
.padding(12)
}
.onChange(of: model.transcript.count) { count in
proxy.scrollTo(count - 1)
}
}
.frame(maxHeight: .infinity)
}
private var footer: some View {
HStack {
if model.busy { ProgressView().controlSize(.small) }
@@ -84,11 +105,8 @@ struct ContentView: View {
.font(.callout)
}
Spacer()
if model.awaitingApproval {
Button("Open System Settings") { model.openApprovalSettings() }
}
Button("Uninstall") { Task { await model.uninstall() } }
.disabled(model.busy && !model.awaitingApproval)
.disabled(model.busy)
Button("Install") { Task { await model.install() } }
.keyboardShortcut(.defaultAction)
.disabled(model.busy || model.completed)
@@ -111,10 +129,9 @@ private struct StepRow: View {
Text(title).font(.body.weight(.medium))
Text(note).font(.caption).foregroundStyle(.secondary)
Text(status.detail)
.font(font)
.font(.caption)
.foregroundStyle(tint)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
@@ -129,7 +146,7 @@ private struct StepRow: View {
.foregroundStyle(.secondary)
.frame(width: 20, height: 20)
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
case .running, .needsAction:
case .running:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
@@ -138,15 +155,9 @@ private struct StepRow: View {
}
}
private var font: Font {
if case .needsAction = status { return .system(size: 14) }
return .caption
}
private var tint: Color {
switch status {
case .pending, .running: return .secondary
case .needsAction: return .orange
case .done: return .green
case .failed: return .orange
}
@@ -4,5 +4,4 @@ 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"
}
@@ -1,8 +1,8 @@
import SwiftUI
@main
struct FirewallApp: App {
@StateObject private var model = FirewallModel()
struct InstallerApp: App {
@StateObject private var model = InstallerModel()
var body: some Scene {
MenuBarExtra {
@@ -31,7 +31,7 @@ enum SetupWindow {
/// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
private struct MenuBarLabel: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.openWindow) private var openWindow
@State private var raised = false
@@ -7,15 +7,13 @@ import SystemExtensions
enum StepStatus: Equatable {
case pending
case running(String)
/// Waiting on the user to do something outside the app.
case needsAction(String)
case done(String)
case failed(String)
var detail: String {
switch self {
case .pending: return "Not started"
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
case let .running(text), let .done(text), let .failed(text): return text
}
}
@@ -25,35 +23,33 @@ enum StepStatus: Equatable {
}
}
enum FirewallError: Error, LocalizedError {
enum InstallerError: Error, LocalizedError {
case extensionUnavailable
case extensionRefused(String)
case screeningDidNotStart(NEVPNStatus)
case extensionStopped(String)
var errorDescription: String? {
switch self {
case .extensionUnavailable: return "The extension is not running"
case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
case let .extensionStopped(reason):
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
}
}
}
@MainActor
final class FirewallModel: NSObject, ObservableObject {
final class InstallerModel: NSObject, ObservableObject {
@Published private(set) var extensionStep = StepStatus.pending
@Published private(set) var screeningStep = StepStatus.pending
@Published private(set) var trustStep = StepStatus.pending
@Published private var operations = 0
var busy: Bool { operations > 0 }
@Published private(set) var awaitingApproval = false
@Published private(set) var busy = false
@Published private(set) var completed = false
@Published private(set) var removed = false
@Published private(set) var transcript: [String] = []
@Published private(set) var screening = false
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
private var pendingRequest: CheckedContinuation<Void, Error>?
private var statusObserver: NSObjectProtocol?
// NE only posts status notifications for connections that are still referenced.
@Published private var watched: NETransparentProxyManager?
@@ -91,8 +87,8 @@ final class FirewallModel: NSObject, ObservableObject {
}
func install() async {
operations += 1
defer { operations -= 1 }
busy = true
defer { busy = false }
removed = false
guard CompetingExtensions.confirmInstall() else {
@@ -118,23 +114,19 @@ final class FirewallModel: NSObject, ObservableObject {
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
func uninstall() async {
operations += 1
defer { operations -= 1 }
busy = true
defer { busy = false }
completed = false
removed = false
// Stop waiting on an activation the user never approved; the deactivation below withdraws it.
if let pending = pendingRequest {
finishRequest(pending.request, .failure(CocoaError(.userCancelled)))
}
// Install needs admin, so uninstall does too: untrusting the CA prompts for it, and a cancel stops here.
guard await untrustAuthorities() else {
log("Uninstall cancelled")
return
}
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
// The extension owns the private key, so ask it before tearing down the session that carries the request.
let strandedKey = await removeStoredCA()
await removeConfiguration()
let strandedKey = removeStoredCA()
await deactivate()
if let strandedKey { trustStep = .failed(strandedKey) }
@@ -158,16 +150,16 @@ final class FirewallModel: NSObject, ObservableObject {
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
func restart() async {
operations += 1
defer { operations -= 1 }
busy = true
defer { busy = false }
guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return }
// A restart can mint a new CA when the screened hosts change.
_ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority)
}
func setEnabled(_ enabled: Bool) async {
operations += 1
defer { operations -= 1 }
busy = true
defer { busy = false }
if enabled {
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
} else {
@@ -175,15 +167,7 @@ final class FirewallModel: NSObject, ObservableObject {
}
}
func openApprovalSettings() {
var pane = "x-apple.systempreferences:com.apple.preference.security"
if #available(macOS 15, *) {
pane = "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension"
}
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
self[keyPath: step] = .running(running)
@@ -227,9 +211,6 @@ final class FirewallModel: NSObject, ObservableObject {
forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main))
extensionStep = .pending
log("System extension deactivated, macOS finishes removing it on the next reboot")
} catch OSSystemExtensionError.extensionNotFound {
extensionStep = .pending
log("No system extension to deactivate")
} catch {
extensionStep = .failed(error.localizedDescription)
log("Deactivation failed: \(error.localizedDescription)")
@@ -264,18 +245,17 @@ final class FirewallModel: NSObject, ObservableObject {
return true
}
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
private func removeStoredCA() -> String? {
let cli = Bundle.main.path(forAuxiliaryExecutable: "firewall") ?? ""
let script = "do shell script quoted form of \"\(cli)\" & \" uninstall\" with administrator privileges"
var error: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&error)
if let error {
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)")
/// Only root can delete the key, so the extension does it; returns what to report if it survived.
private func removeStoredCA() async -> String? {
do {
let reply = try await providerMessage("uninstall")
guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
log("Removed the CA, its exported copy and the env agent")
return nil
} catch {
log("Could not remove the CA: \(error.localizedDescription)")
return "The CA private key is still in the System keychain"
}
log("Removed the CA, its exported copy and the env agent")
return nil
}
// MARK: - System extension
@@ -283,18 +263,14 @@ final class FirewallModel: NSObject, ObservableObject {
private func submit(_ request: OSSystemExtensionRequest) async throws {
request.delegate = self
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
pendingRequest = (request, continuation)
pendingRequest = continuation
OSSystemExtensionManager.shared.submitRequest(request)
}
}
/// Callbacks for an abandoned request can still arrive, so only the pending one is finished.
private func finishRequest(_ request: OSSystemExtensionRequest, _ result: Result<Void, Error>) {
guard let pending = pendingRequest, pending.request === request else { return }
private func finishRequest(_ result: Result<Void, Error>) {
pendingRequest?.resume(with: result)
pendingRequest = nil
awaitingApproval = false
NSApp.activate(ignoringOtherApps: true)
pending.continuation.resume(with: result)
}
// MARK: - Network extension
@@ -319,22 +295,16 @@ final class FirewallModel: NSObject, ObservableObject {
try await manager.loadFromPreferences()
watched = manager
screeningStep = .running("Starting the extension")
try manager.connection.startVPNTunnel()
guard try await wait(for: .connected, on: manager.connection) else {
do {
try await manager.connection.fetchLastDisconnectError()
} catch {
throw FirewallError.extensionStopped(error.localizedDescription)
}
throw FirewallError.screeningDidNotStart(manager.connection.status)
throw InstallerError.screeningDidNotStart(manager.connection.status)
}
return "Screening package downloads"
}
private func restartScreening() async throws -> String {
guard let manager = try await loadManager() else {
throw FirewallError.extensionUnavailable
throw InstallerError.extensionUnavailable
}
manager.connection.stopVPNTunnel()
_ = try await wait(for: .disconnected, on: manager.connection)
@@ -342,7 +312,7 @@ final class FirewallModel: NSObject, ObservableObject {
}
private func stopScreening() async throws -> String {
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
manager.isEnabled = false
try await manager.saveToPreferences()
manager.connection.stopVPNTunnel()
@@ -350,13 +320,9 @@ final class FirewallModel: NSObject, ObservableObject {
return "Screening disabled"
}
/// Gives up early once a start has failed, which shows as connecting then disconnected.
private func wait(for status: NEVPNStatus, on connection: NEVPNConnection) async throws -> Bool {
var connecting = false
for _ in 0 ..< 60 {
if connection.status == status { return true }
if connection.status == .connecting { connecting = true }
if connecting && connection.status == .disconnected { return false }
try await Task.sleep(nanoseconds: 250_000_000)
}
return false
@@ -392,13 +358,13 @@ final class FirewallModel: NSObject, ObservableObject {
private func providerMessage(_ message: String) async throws -> String {
guard let session = watched?.connection as? NETunnelProviderSession else {
throw FirewallError.extensionUnavailable
throw InstallerError.extensionUnavailable
}
return try await withCheckedThrowingContinuation { continuation in
do {
try session.sendProviderMessage(Data(message.utf8)) { response in
guard let response, let reply = String(data: response, encoding: .utf8) else {
continuation.resume(throwing: FirewallError.extensionUnavailable)
continuation.resume(throwing: InstallerError.extensionUnavailable)
return
}
continuation.resume(returning: reply)
@@ -410,11 +376,12 @@ final class FirewallModel: NSObject, ObservableObject {
}
private func log(_ line: String) {
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
logger.info("\(line, privacy: .public)")
}
}
extension FirewallModel: OSSystemExtensionRequestDelegate {
extension InstallerModel: OSSystemExtensionRequestDelegate {
nonisolated func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
@@ -423,14 +390,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
Task { @MainActor in
// macOS shows its own alert that opens System Settings; this says what to do there.
var instruction = "In System Settings › Privacy & Security, click Allow for StrixFirewall"
if #available(macOS 15, *) {
instruction = "In System Settings › General › Login Items & Extensions, click Network Extensions and turn on StrixFirewall"
}
extensionStep = .needsAction(instruction)
awaitingApproval = true
extensionStep = .running("Approve Firewall in System Settings › General › Login Items & Extensions")
log("Waiting for user approval")
// Only MDM can pre-approve a system extension, so take them to the switch.
if let pane = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension") {
NSWorkspace.shared.open(pane)
}
}
}
@@ -440,12 +405,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
if result == .willCompleteAfterReboot {
log("Extension will finish installing after a reboot")
}
finishRequest(request, .success(()))
finishRequest(.success(()))
}
}
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
Task { @MainActor in finishRequest(request, .failure(error)) }
Task { @MainActor in finishRequest(.failure(error)) }
}
}
@@ -1,7 +1,7 @@
import SwiftUI
struct MenuBarView: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.openWindow) private var openWindow
var body: some View {
@@ -9,7 +9,7 @@ struct MenuBarView: View {
Divider()
Button("Setup") {
Button("Installer") {
SetupWindow.open(openWindow)
}
@@ -34,11 +34,7 @@ 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
@@ -1,24 +0,0 @@
# 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.
+22 -42
View File
@@ -1,10 +1,8 @@
#!/bin/bash
# Builds and signs StrixFirewall.app and packages it as build/Firewall.pkg.
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
# makes a Debug build and skips notarization.
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
#
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing
# because macOS refuses to activate a Developer ID extension that is not notarized.
# Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
set -euo pipefail
cd "$(dirname "$0")"
@@ -12,41 +10,27 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}"
export TEAM
# Identities are matched as a substring of the certificate name; set them if more than one matches.
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension.
if [ "${1:-}" = debug ]; then
CONFIGURATION=Debug
IDENTITY=${DEBUG_IDENTITY:-Apple Development}
NE_PROVIDER=app-proxy-provider
else
CONFIGURATION=Release
IDENTITY=${IDENTITY:-Developer ID Application}
NE_PROVIDER=app-proxy-provider-systemextension
: "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
fi
export NE_PROVIDER
APP_ID=com.usestrix.firewall
EXT_ID=com.usestrix.firewall.extension
VERSION=0.1
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
APP=build/Build/Products/Debug/Installer.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
main() {
build
sign
install_app
package
[ $CONFIGURATION = Debug ] || notarize
}
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
@@ -55,44 +39,40 @@ sign() {
echo "==> Signing with $IDENTITY"
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp \
codesign --force --options runtime --timestamp=none \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
codesign --force --options runtime --timestamp=none \
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$APP"
}
# The extension can only be activated from /Applications.
install_app() {
echo "==> Installing /Applications/Firewall.app"
rm -rf /Applications/Firewall.app
cp -R "$APP" /Applications/Firewall.app
}
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
package() {
echo "==> Packaging build/Firewall.pkg"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app"
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
build/Firewall.pkg >/dev/null
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
}
notarize() {
echo "==> Notarizing build/Firewall.pkg"
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple build/Firewall.pkg
}
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
profile_for() {
local want=$TEAM.$1 newest= newest_time=0 profile id t
local want=$TEAM.$1 newest= newest_time=0 id t
for p in "$PROFILES"/*.provisionprofile; do
profile=$(security cms -D -i "$p")
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
[ "$id" = "$want" ] || continue
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
grep -q "\"$NE_PROVIDER\"" || continue
t=$(stat -f %m "$p")
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
done
+1 -1
View File
@@ -6,5 +6,5 @@ uid=$(id -u "$user")
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
launchctl bootstrap "gui/$uid" "$agent"
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
exit 0
+6 -18
View File
@@ -55,7 +55,7 @@ targets:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.networking.networkextension:
- ${NE_PROVIDER}
- app-proxy-provider
# NE requires NEMachServiceName to start with one of these.
com.apple.security.application-groups:
- ${TEAM}.com.usestrix.firewall
@@ -87,45 +87,33 @@ targets:
$([ "$CONFIGURATION" = Debug ] || echo --release)
done
StrixFirewall:
Installer:
type: application
platform: macOS
sources:
- App
- Installer
- path: ../assets/logo-transparent.png
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: App/Info.plist
path: Installer/Info.plist
properties:
CFBundleShortVersionString: "0.1"
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true
entitlements:
path: App/App.entitlements
path: Installer/Installer.entitlements
properties:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.system-extension.install: true
com.apple.developer.networking.networkextension:
- ${NE_PROVIDER}
- app-proxy-provider
com.apple.security.network.client: true
dependencies:
- target: Extension
embed: true
codeSign: true
# Uninstall runs the CLI as root to remove what the extension left.
postBuildScripts:
- name: Embed the CLI
basedOnDependencyAnalysis: false
script: |
export PATH="$HOME/.cargo/bin:$PATH"
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"