Compare commits

...
Author SHA1 Message Date
Ian Graham Martinez 9204017cf1 Give each CA its own CRL path
A verifier that cached the CRL of an earlier CA would otherwise apply it to a re-minted one.
2026-10-22 11:00:59 -04:00
ian-at-strixandIan Graham Martinez 7cafa16cc4 Hold the CA key in the TPM on Windows (#9)
* Hold the CA key in the TPM on Windows

A non-exportable machine key in the TPM, or in software where there is
none, signs as the Secure Enclave key does on macOS.

* Require the TPM for the Windows CA key

A failure to open the TPM fell back to a software key, which minted a new CA
and kept it for good. Fail instead, as macOS does without a Secure Enclave.

* Use the windows crate for the TPM key

---------

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-22 10:53:51 -04:00
ian-at-strix 38d9a4500e Layer a system config over the user's, and have the extension read only the system one (#43) 2026-10-21 20:07:55 -04:00
ian-at-strix d03c4cb6e3 Make the app icon an Icon Composer icon so macOS 26 doesn't put it in a grey bubble (#42) 2026-10-21 18:15:02 -04:00
ian-at-strix 8f20758ba0 Offer a restart once setup finishes (#35)
* Offer a restart once setup finishes

* Describe the restart without naming one variable
2026-10-21 18:07:41 -04:00
ian-at-strix 577e0b7eb0 Screening events from the local proxy, and the mode each event came from (#41) 2026-10-21 17:55:21 -04:00
ian-at-strix f94d3bf211 Include why a package was blocked in the screening event (#39) 2026-10-21 17:50:04 -04:00
ian-at-strix 753e21dd19 Block what could not be checked when screening fails closed (#20) 2026-10-21 17:48:29 -04:00
ian-at-strix 7c128de056 Give the app the Strix logo as its icon (#34) 2026-10-21 16:45:38 -04:00
ian-at-strix 64e938bf7a Guide the user to the extension approval switch (#33)
* Guide the user to the extension approval switch

* Allow uninstalling while the extension waits for approval

* Show the approval instruction in larger orange text

* Leave opening System Settings to the macOS alert

* Shrink the approval instruction slightly

* Hide the setup log

* Add a button that opens System Settings while approval is pending
2026-10-21 16:44:27 -04:00
23 changed files with 515 additions and 78 deletions

No files matched your search

+96
View File
@@ -3111,6 +3111,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"which",
"windows",
]
[[package]]
@@ -3709,12 +3710,89 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3724,6 +3802,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -3758,6 +3845,15 @@ dependencies = [
"windows_x86_64_msvc",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
+7
View File
@@ -52,6 +52,10 @@ path = "tests/age.rs"
name = "mitm"
path = "tests/mitm.rs"
[[test]]
name = "tpm"
path = "tests/tpm.rs"
[[test]]
name = "managers"
path = "tests/managers.rs"
@@ -79,6 +83,9 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+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.is_some());
telemetry::screened(firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(method = %req.method(), %url, %reason, "blocked");
return Ok(blocked(&reason));
+37 -11
View File
@@ -1,6 +1,7 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
@@ -68,18 +69,31 @@ impl Default for DenylistSource {
}
impl FirewallConfig {
/// A missing file means defaults; a bad one is an error, so the policy is never quietly relaxed.
pub fn load() -> std::io::Result<Self> {
let Some(path) = path() else {
return Ok(Self::default());
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
serde_json::from_str(&text)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
let mut paths: Vec<PathBuf> = path().into_iter().collect();
paths.extend(crate::os::system_config());
Self::load_from(&paths)
}
/// Later files win key by key, but allow lists add up.
/// A bad file is an error, so the policy is never quietly relaxed.
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> {
let mut merged = Map::new();
for path in paths {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
FirewallConfig::deserialize(&layer).map_err(invalid)?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
merge(&mut merged, layer);
}
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
}
pub fn local_url(&self, path: &str) -> String {
@@ -103,3 +117,15 @@ pub fn path() -> Option<PathBuf> {
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
Some(dirs.config_dir().join("config.json"))
}
fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
for (key, value) in top {
match (base.get_mut(&key), value) {
(Some(Value::Object(base)), Value::Object(top)) => merge(base, top),
(Some(Value::Array(base)), Value::Array(top)) => base.extend(top),
(_, value) => {
base.insert(key, value);
}
}
}
}
+2
View File
@@ -56,6 +56,7 @@ fn options() -> Command {
}
fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load();
let port = config.port;
print_setup(&config);
@@ -83,6 +84,7 @@ fn uninstall() -> ! {
}
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
+8 -1
View File
@@ -1,4 +1,5 @@
use rama::error::BoxError;
use rama::tls::boring::core::sha::sha256;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
@@ -8,7 +9,7 @@ use rcgen::{
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
/// Held in the Secure Enclave on macOS, the TPM on Windows, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
pub struct CertificateAuthority {
@@ -39,6 +40,12 @@ impl CertificateAuthority {
(held == signer.subject_public_key_info()).then_some(ca)
}
/// Unique to this CA, so a verifier's cached CRL from an earlier one never applies to it.
pub fn crl_path(&self) -> String {
let digest = sha256(self.pem.as_bytes());
format!("/{}.crl", digest[..8].iter().map(|b| format!("{b:02x}")).collect::<String>())
}
fn new(pem: String) -> Result<Self, BoxError> {
Ok(Self { crt: X509::from_pem(pem.as_bytes())?, pem })
}
+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.is_some());
telemetry::screened(&self.firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(%url, "blocked by firewall: {reason}");
return Ok(blocked(&reason));
+8 -1
View File
@@ -23,6 +23,7 @@ use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service};
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -32,6 +33,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 CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
#[derive(Clone)]
struct Handler {
@@ -97,9 +99,14 @@ impl TransparentProxyHandlerFactory for Factory {
}
}
pub fn system_config() -> Option<PathBuf> {
Some(CONFIG_FILE.into())
}
fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat();
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).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"))?;
+8 -1
View File
@@ -1,11 +1,18 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
pub use macos::{system_config, uninstall};
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn system_config() -> Option<std::path::PathBuf> {
None
}
+3
View File
@@ -0,0 +1,3 @@
mod tpm;
pub use tpm::CaKey;
+153
View File
@@ -0,0 +1,153 @@
use rama::error::BoxError;
use rama::tls::boring::core::bn::BigNum;
use rama::tls::boring::core::ecdsa::EcdsaSig;
use rama::tls::boring::core::sha::sha256;
use rcgen::{Error, PublicKeyData, SignatureAlgorithm, SigningKey, PKCS_ECDSA_P256_SHA256};
use windows::core::{Owned, Result as WinResult, HSTRING};
use windows::Win32::Foundation::{NTE_BAD_KEY, NTE_BAD_KEYSET};
use windows::Win32::Security::Cryptography::*;
const KEY_NAME: &str = "Strix Firewall CA";
/// A P-256 key held by the TPM. Signing asks the TPM to sign, so the private key never leaves it.
pub struct CaKey {
// Fields drop in order, so the key handle closes before its provider's.
key_handle: Owned<NCRYPT_KEY_HANDLE>,
_provider_handle: Owned<NCRYPT_PROV_HANDLE>,
/// 0x04 || X || Y, the form rcgen writes into a certificate's SubjectPublicKeyInfo.
public_point: Vec<u8>,
}
impl CaKey {
/// A machine key, so the service and an elevated installer share it.
pub fn load_or_create() -> Result<Self, BoxError> {
match open_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
Some(key) => Ok(key),
None => Ok(create_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)?),
}
}
/// A per-user key under a random name, which needs no elevation; `delete_from_tpm` it after.
pub fn create_test_key() -> WinResult<Self> {
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
create_key(&name, NCRYPT_FLAGS(0))
}
/// Deletes the key from the TPM, not just this handle to it.
pub fn delete_from_tpm(mut self) -> WinResult<()> {
// NCryptDeleteKey also frees the handle, so the Owned mustn't free it again.
let key_handle = std::mem::take(&mut *self.key_handle);
unsafe { NCryptDeleteKey(key_handle, 0) }
}
/// Takes both handles and reads the key's public point once, for rcgen.
fn from_handles(
provider_handle: Owned<NCRYPT_PROV_HANDLE>,
key_handle: Owned<NCRYPT_KEY_HANDLE>,
) -> WinResult<Self> {
// A BCRYPT_ECCKEY_BLOB header, then X and Y.
const HEADER_LEN: usize = size_of::<BCRYPT_ECCKEY_BLOB>();
let mut blob = [0u8; HEADER_LEN + 64];
let mut blob_len = 0;
unsafe {
NCryptExportKey(
*key_handle,
None,
BCRYPT_ECCPUBLIC_BLOB,
None,
Some(&mut blob),
&mut blob_len,
NCRYPT_FLAGS(0),
)?
};
let (header, x_and_y) = blob.split_at(HEADER_LEN);
let is_p256 = header[..4] == BCRYPT_ECDSA_PUBLIC_P256_MAGIC.to_le_bytes();
if !is_p256 || blob_len as usize != blob.len() {
return Err(windows::core::Error::new(NTE_BAD_KEY, "key is not an ECDSA P-256 key"));
}
let public_point = [&[0x04], x_and_y].concat();
Ok(CaKey { key_handle, _provider_handle: provider_handle, public_point })
}
}
impl PublicKeyData for CaKey {
fn der_bytes(&self) -> &[u8] {
&self.public_point
}
fn algorithm(&self) -> &'static SignatureAlgorithm {
&PKCS_ECDSA_P256_SHA256
}
}
impl SigningKey for CaKey {
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, Error> {
let mut r_and_s = [0u8; 64];
let mut signature_len = 0;
let signed = unsafe {
NCryptSignHash(
*self.key_handle,
None,
&sha256(msg),
Some(&mut r_and_s),
&mut signature_len,
NCRYPT_SILENT_FLAG,
)
};
signed.map_err(|_| Error::RemoteKeyError)?;
// NCrypt returns r || s, each half the length; X.509 wants DER SEQUENCE { r, s }.
let (r, s) = r_and_s[..signature_len as usize].split_at(signature_len as usize / 2);
let der = BigNum::from_slice(r)
.and_then(|r| EcdsaSig::from_private_components(r, BigNum::from_slice(s)?))
.and_then(|signature| signature.to_der());
der.map_err(|_| Error::RemoteKeyError)
}
}
/// `None` if the TPM holds no key by that name.
/// `scope` is `NCRYPT_MACHINE_KEY_FLAG`, or empty for the current user's keys.
fn open_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<Option<CaKey>> {
let provider_handle = open_tpm_provider()?;
let mut key_handle = NCRYPT_KEY_HANDLE::default();
let opened = unsafe {
NCryptOpenKey(
*provider_handle,
&mut key_handle,
&HSTRING::from(name),
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
scope | NCRYPT_SILENT_FLAG,
)
};
match opened {
Err(error) if error.code() == NTE_BAD_KEYSET => Ok(None),
Err(error) => Err(error),
Ok(()) => CaKey::from_handles(provider_handle, unsafe { Owned::new(key_handle) }).map(Some),
}
}
/// Generates the key inside the TPM, which never lets it out.
fn create_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<CaKey> {
let provider_handle = open_tpm_provider()?;
let mut key_handle = NCRYPT_KEY_HANDLE::default();
unsafe {
NCryptCreatePersistedKey(
*provider_handle,
&mut key_handle,
NCRYPT_ECDSA_P256_ALGORITHM,
&HSTRING::from(name),
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
scope,
)?
};
let key_handle = unsafe { Owned::new(key_handle) };
// Until finalized the key is only a handle; this generates and stores it.
unsafe { NCryptFinalizeKey(*key_handle, NCRYPT_SILENT_FLAG)? };
CaKey::from_handles(provider_handle, key_handle)
}
/// The Microsoft Platform Crypto Provider, which keeps its keys in the TPM.
fn open_tpm_provider() -> WinResult<Owned<NCRYPT_PROV_HANDLE>> {
let mut provider_handle = NCRYPT_PROV_HANDLE::default();
unsafe { NCryptOpenStorageProvider(&mut provider_handle, MS_PLATFORM_CRYPTO_PROVIDER, 0)? };
Ok(unsafe { Owned::new(provider_handle) })
}
+3
View File
@@ -72,6 +72,9 @@ 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,
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, reply, text};
use crate::policies::{self, Check, Decision};
use crate::{Firewall, FirewallConfig};
use crate::{telemetry, Firewall, FirewallConfig};
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
@@ -19,6 +19,7 @@ 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| {
@@ -44,6 +45,7 @@ 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)
}
@@ -51,9 +53,11 @@ 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")
}
}
+20 -7
View File
@@ -1,10 +1,11 @@
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};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
@@ -29,29 +30,40 @@ 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, blocked: bool) {
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
if TELEMETRY.is_none() {
return;
}
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
return;
};
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
return;
};
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
if blocked {
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 {
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 }));
}
@@ -81,6 +93,7 @@ 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();
+27 -1
View File
@@ -1,6 +1,6 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::Package;
use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig};
#[test]
@@ -21,6 +21,23 @@ fn the_config_lives_under_the_config_directory() {
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[test]
fn a_later_file_wins_and_allow_lists_add_up() {
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let user = dir.join("user.json");
let system = dir.join("system.json");
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
let missing = dir.join("missing.json");
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(config.min_age_hours, 24);
assert!(!config.fail_open);
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
@@ -31,3 +48,12 @@ 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:?}");
}
+9
View File
@@ -50,3 +50,12 @@ 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");
}
/// A re-minted CA must not inherit the old one's CRL from a verifier's cache.
#[test]
fn each_ca_names_its_own_crl() {
let signer = signer();
let npm = mitm::CertificateAuthority::generate(&["registry.npmjs.org"], &signer).expect("mint a ca");
let pypi = mitm::CertificateAuthority::generate(&["pypi.org"], &signer).expect("mint a ca");
assert_ne!(npm.crl_path(), pypi.crl_path());
}
+21
View File
@@ -0,0 +1,21 @@
#![cfg(windows)]
use firewall::mitm::CertificateAuthority;
use firewall::os::windows::CaKey;
use rcgen::PublicKeyData;
/// Skipped on machines without a TPM.
#[test]
fn key_signs_a_verifiable_ca() {
let key = match CaKey::create_test_key() {
Ok(key) => key,
Err(error) => return eprintln!("skipping, no TPM key: {error}"),
};
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
let spki = key.subject_public_key_info();
key.delete_from_tpm().expect("delete the test key");
let ca = ca.expect("mint a ca");
let public = ca.crt.public_key().expect("ca public key");
assert!(ca.crt.verify(&public).expect("verify the ca"));
assert_eq!(public.public_key_to_der().unwrap(), spki);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"fill" : {
"solid" : "srgb:0.07000,0.07000,0.07000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "logo.png",
"name" : "logo",
"position" : {
"scale" : 1.8,
"translation-in-points" : [0, 0]
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+19 -30
View File
@@ -10,11 +10,9 @@ struct ContentView: View {
Divider()
steps
Divider()
transcript
Divider()
footer
}
.frame(width: 540, height: 560)
.frame(width: 540)
.task { await model.refresh() }
.onAppear(perform: recenterIfOffscreen)
.onChange(of: model.completed) { done in
@@ -38,9 +36,11 @@ struct ContentView: View {
private var header: some View {
HStack(spacing: 14) {
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 34, weight: .light))
.foregroundStyle(.tint)
Image(nsImage: NSImage(named: "logo-transparent")!)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 40)
VStack(alignment: .leading, spacing: 3) {
Text("Firewall").font(.title2.weight(.semibold))
Text("Screens package downloads before they reach your machine.")
@@ -70,27 +70,6 @@ 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) }
@@ -105,8 +84,11 @@ struct ContentView: View {
.font(.callout)
}
Spacer()
if model.awaitingApproval {
Button("Open System Settings") { model.openApprovalSettings() }
}
Button("Uninstall") { Task { await model.uninstall() } }
.disabled(model.busy)
.disabled(model.busy && !model.awaitingApproval)
Button("Install") { Task { await model.install() } }
.keyboardShortcut(.defaultAction)
.disabled(model.busy || model.completed)
@@ -129,9 +111,10 @@ private struct StepRow: View {
Text(title).font(.body.weight(.medium))
Text(note).font(.caption).foregroundStyle(.secondary)
Text(status.detail)
.font(.caption)
.font(font)
.foregroundStyle(tint)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
@@ -146,7 +129,7 @@ private struct StepRow: View {
.foregroundStyle(.secondary)
.frame(width: 20, height: 20)
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
case .running:
case .running, .needsAction:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
@@ -155,9 +138,15 @@ 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
}
+59 -23
View File
@@ -7,13 +7,15 @@ 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 .done(text), let .failed(text): return text
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
}
}
@@ -43,14 +45,15 @@ final class FirewallModel: 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(set) var busy = false
@Published private var operations = 0
var busy: Bool { operations > 0 }
@Published private(set) var awaitingApproval = 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: CheckedContinuation<Void, Error>?
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
private var statusObserver: NSObjectProtocol?
// NE only posts status notifications for connections that are still referenced.
@Published private var watched: NETransparentProxyManager?
@@ -88,8 +91,8 @@ final class FirewallModel: NSObject, ObservableObject {
}
func install() async {
busy = true
defer { busy = false }
operations += 1
defer { operations -= 1 }
removed = false
guard CompetingExtensions.confirmInstall() else {
@@ -111,14 +114,34 @@ final class FirewallModel: NSObject, ObservableObject {
completed = true
log("Setup complete")
offerRestart()
}
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
private func offerRestart() {
let alert = NSAlert()
alert.messageText = "Restart to finish setting up"
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
alert.addButton(withTitle: "Restart")
alert.addButton(withTitle: "Later")
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
let osascript = Process()
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
try? osascript.run()
}
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
func uninstall() async {
busy = true
defer { busy = false }
operations += 1
defer { operations -= 1 }
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 {
@@ -151,16 +174,16 @@ final class FirewallModel: NSObject, ObservableObject {
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
func restart() async {
busy = true
defer { busy = false }
operations += 1
defer { operations -= 1 }
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 {
busy = true
defer { busy = false }
operations += 1
defer { operations -= 1 }
if enabled {
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
} else {
@@ -168,6 +191,14 @@ 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>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
@@ -268,14 +299,18 @@ final class FirewallModel: NSObject, ObservableObject {
private func submit(_ request: OSSystemExtensionRequest) async throws {
request.delegate = self
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
pendingRequest = continuation
pendingRequest = (request, continuation)
OSSystemExtensionManager.shared.submitRequest(request)
}
}
private func finishRequest(_ result: Result<Void, Error>) {
pendingRequest?.resume(with: result)
/// 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 }
pendingRequest = nil
awaitingApproval = false
NSApp.activate(ignoringOtherApps: true)
pending.continuation.resume(with: result)
}
// MARK: - Network extension
@@ -391,7 +426,6 @@ final class FirewallModel: NSObject, ObservableObject {
}
private func log(_ line: String) {
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
logger.info("\(line, privacy: .public)")
}
}
@@ -405,12 +439,14 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
Task { @MainActor in
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)
// 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
log("Waiting for user approval")
}
}
@@ -420,12 +456,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
if result == .willCompleteAfterReboot {
log("Extension will finish installing after a reboot")
}
finishRequest(.success(()))
finishRequest(request, .success(()))
}
}
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
Task { @MainActor in finishRequest(.failure(error)) }
Task { @MainActor in finishRequest(request, .failure(error)) }
}
}
+1
View File
@@ -97,6 +97,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: App/Info.plist
properties: