Compare commits

..
Author SHA1 Message Date
Ian Graham Martinez bd1de7c1fd Use the windows crate for the TPM key 2026-10-22 10:34:45 -04:00
Ian 20c33a0c8b 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.
2026-10-21 20:20:53 -04:00
Ian Graham Martinez a00d568212 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.
2026-10-21 20:20:53 -04:00
15 changed files with 36 additions and 770 deletions

No files matched your search

+3 -209
View File
@@ -172,17 +172,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "asynk-strim"
version = "0.1.5"
@@ -467,16 +456,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -517,12 +496,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
@@ -1024,78 +997,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-net"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"futures-channel",
"futures-io",
"futures-util",
"hickory-proto",
"idna",
"ipnet",
"jni",
"rand",
"thiserror",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-proto"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni",
"once_cell",
"prefix-trie",
"rand",
"ring",
"serde",
"thiserror",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981"
dependencies = [
"cfg-if",
"futures-util",
"hickory-net",
"hickory-proto",
"ipconfig",
"ipnet",
"jni",
"moka",
"ndk-context",
"once_cell",
"parking_lot",
"rand",
"resolv-conf",
"serde",
"smallvec",
"system-configuration",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1257,19 +1158,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1756,10 +1644,6 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
@@ -1894,17 +1778,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prefix-trie"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
dependencies = [
"either",
"ipnet",
"num-traits",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2248,7 +2121,6 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2547,7 +2419,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation 0.10.1",
"core-foundation",
"csv",
"idna",
"ipnet",
@@ -2879,12 +2751,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "ring"
version = "0.17.14"
@@ -3020,7 +2886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3228,14 +3094,13 @@ name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"core-foundation 0.10.1",
"core-foundation",
"directories",
"percent-encoding",
"rama",
"rand",
"rcgen",
"regex",
"schannel",
"security-framework",
"security-framework-sys",
"semver",
@@ -3244,12 +3109,9 @@ dependencies = [
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"which",
"windows",
"windows-registry",
"windows-service",
]
[[package]]
@@ -3320,27 +3182,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -3429,12 +3270,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -3688,19 +3523,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3848,12 +3670,6 @@ dependencies = [
"libc",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "wildcard"
version = "0.3.0"
@@ -3977,17 +3793,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3997,17 +3802,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags 2.13.2",
"widestring",
"windows-sys 0.61.2",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
+1 -31
View File
@@ -10,14 +10,6 @@ homepage = "https://github.com/usestrix/firewall"
# as an integration test; new tests/*.rs need an explicit [[test]] entry.
autotests = false
# Only Windows ships the service.
[package.metadata.dist.binaries]
x86_64-pc-windows-msvc = ["firewall", "firewall-service"]
aarch64-apple-darwin = ["firewall"]
x86_64-apple-darwin = ["firewall"]
aarch64-unknown-linux-gnu = ["firewall"]
x86_64-unknown-linux-gnu = ["firewall"]
[lib]
name = "firewall"
# The macOS extension links a staticlib, built on demand:
@@ -28,14 +20,6 @@ crate-type = ["lib"]
name = "firewall"
path = "src/main.rs"
[[bin]]
name = "firewall-service"
path = "src/bin/firewall-service.rs"
[[bin]]
name = "firewall-uninstall"
path = "src/bin/firewall-uninstall.rs"
[[example]]
name = "proxy-test"
path = "tests/proxy-test/main.rs"
@@ -100,21 +84,7 @@ security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
rama = { version = "0.4.0", features = ["dns-hickory"] }
schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_System_Com",
"Win32_System_Environment",
"Win32_UI_Shell",
] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
-15
View File
@@ -1,15 +0,0 @@
//! Started by the Windows service manager. It logs to a file, having no console.
#[cfg(windows)]
fn main() {
if let Err(e) = firewall::os::windows::run_service() {
tracing::error!("could not run the service: {e}");
firewall::telemetry::error("service");
firewall::telemetry::flush();
std::process::exit(1)
}
}
/// The service is Windows-only.
#[cfg(not(windows))]
fn main() {}
-16
View File
@@ -1,16 +0,0 @@
//! Run as root by the macOS app's uninstall.
#[cfg(target_os = "macos")]
fn main() {
firewall::init_logging("info");
if let Err(e) = firewall::os::macos::uninstall() {
tracing::error!("could not uninstall: {e}");
firewall::telemetry::error("uninstall");
firewall::telemetry::flush();
std::process::exit(1)
}
}
/// Only macOS installs anything system-wide.
#[cfg(not(target_os = "macos"))]
fn main() {}
+13
View File
@@ -12,6 +12,10 @@ 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");
@@ -40,6 +44,8 @@ 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")
@@ -70,6 +76,13 @@ fn serve() -> ! {
}
}
fn uninstall() -> ! {
match firewall::os::uninstall() {
Ok(()) => exit(0),
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
}
}
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
+1 -3
View File
@@ -61,14 +61,12 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
}
pub const COMMON_NAME: &str = "Strix Firewall";
/// With `COMMON_NAME`, how a trust store entry is recognized as ours.
pub const ORGANIZATION: &str = "Strix";
/// 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::OrganizationName, ORGANIZATION);
name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default();
params.distinguished_name = name;
+1 -1
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+8 -4
View File
@@ -4,11 +4,15 @@ pub mod macos;
pub mod windows;
#[cfg(target_os = "macos")]
pub use macos::system_config;
#[cfg(windows)]
pub use windows::system_config;
pub use macos::{system_config, uninstall};
#[cfg(not(any(target_os = "macos", windows)))]
/// 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
}
-120
View File
@@ -1,120 +0,0 @@
use super::*;
use crate::mitm::{CertificateAuthority, COMMON_NAME, ORGANIZATION};
use rama::tls::boring::core::nid::Nid;
use rama::tls::boring::core::x509::X509;
use rcgen::SigningKey;
use schannel::cert_context::CertContext;
use schannel::cert_store::{CertAdd, CertStore};
use std::io;
use std::path::Path;
use tracing::warn;
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::*;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
const DATA_DIR_ACL: &str = concat!(
"O:BA", // owner: Administrators
"D:P", // protected: ignore ProgramData's rules, which let any user create files
"(A;OICI;FA;;;SY)", // allow SYSTEM full control, inherited by files (OI) and subfolders (CI)
"(A;OICI;FA;;;BA)", // allow Administrators full control
"(A;OICI;FRFX;;;BU)", // allow Users read and execute only
);
/// Trusted machine-wide so restarts keep it; replaced if the hosts change.
pub fn load_or_create_ca(
hosts: &[&str],
signer: &dyn SigningKey,
) -> Result<CertificateAuthority, BoxError> {
let stored = read_registry_string(REGISTRY_KEY, "ca")?;
let ca = match stored.and_then(|blob| CertificateAuthority::from_blob(&blob, hosts, signer)) {
Some(ca) => ca,
None => {
warn!(?hosts, "minting a new CA");
let ca = CertificateAuthority::generate(hosts, signer)?;
LOCAL_MACHINE
.create(REGISTRY_KEY)?
.set_string("ca", ca.to_blob(hosts))?;
ca
}
};
// Idempotent, and the registry value can outlive the certificate.
let der = ca.crt.to_der()?;
let mut store = CertStore::open_local_machine("Root")?;
store.add_cert(&CertContext::new(&der)?, CertAdd::ReplaceExisting)?;
remove_our_cas_except(Some(&der))?;
// For clients that read no trust store; screening still works for the rest if this fails.
let exported = secure_data_dir().and_then(|dir| std::fs::write(dir.join("ca.pem"), &ca.pem));
if let Err(error) = exported {
warn!(%error, "could not export the CA");
}
Ok(ca)
}
/// Created on first use, its owner and ACL reset every time. One a user made first, or a link,
/// is replaced: its owner could grant itself access again, and a link would redirect our writes.
pub(super) fn secure_data_dir() -> io::Result<PathBuf> {
let dir = data_dir();
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
Ok(_) if !owned_by_admins(&dir)? => {
warn!(dir = %dir.display(), "replacing a data folder not created by an admin");
std::fs::remove_dir_all(&dir)?
}
_ => {}
}
std::fs::create_dir_all(&dir)?;
let info = OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION;
let path = HSTRING::from(dir.as_path());
let acl = security_descriptor(DATA_DIR_ACL)?;
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
Ok(dir)
}
/// By SYSTEM or the Administrators group, as what an elevated process creates is.
fn owned_by_admins(path: &Path) -> windows::core::Result<bool> {
let (mut owner, mut descriptor) = (PSID::default(), PSECURITY_DESCRIPTOR::default());
unsafe {
GetNamedSecurityInfoW(
&HSTRING::from(path),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
&mut descriptor,
)
.ok()?
};
let admins = [WinBuiltinAdministratorsSid, WinLocalSystemSid];
let owned = admins
.into_iter()
.any(|sid| unsafe { IsWellKnownSid(owner, sid) }.as_bool());
// `owner` points into the descriptor, so it is freed last.
unsafe { LocalFree(Some(HLOCAL(descriptor.0))) };
Ok(owned)
}
/// Drops every CA of ours from LocalMachine\Root but `keep`.
pub(super) fn remove_our_cas_except(keep: Option<&[u8]>) -> Result<(), BoxError> {
for cert in CertStore::open_local_machine("Root")?.certs() {
if keep != Some(cert.to_der()) && is_our_ca(cert.to_der()) {
cert.delete()?;
}
}
Ok(())
}
fn is_our_ca(der: &[u8]) -> bool {
let Ok(crt) = X509::from_der(der) else {
return false;
};
let name = |nid| {
let entry = crt.subject_name().entries_by_nid(nid).next()?;
Some(entry.data().as_utf8().ok()?.to_string())
};
name(Nid::COMMONNAME).as_deref() == Some(COMMON_NAME)
&& name(Nid::ORGANIZATIONNAME).as_deref() == Some(ORGANIZATION)
}
-128
View File
@@ -1,131 +1,3 @@
//! The hosts file sends each registry host to its own loopback address, where a
//! LocalSystem service screens it as the macOS extension does.
mod ca;
mod service;
mod tpm;
pub use service::run as run_service;
pub use tpm::CaKey;
use rama::error::BoxError;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use windows::core::{Owned, GUID, HSTRING};
use windows::Win32::Foundation::{LocalFree, ERROR_FILE_NOT_FOUND, HLOCAL};
use windows::Win32::Security::Authorization::*;
use windows::Win32::Security::PSECURITY_DESCRIPTOR;
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows::Win32::UI::Shell::*;
use windows_registry::LOCAL_MACHINE;
const SERVICE_NAME: &str = "StrixFirewall";
/// Admin-only, so it holds the CA as the System keychain does on macOS.
const REGISTRY_KEY: &str = r"SOFTWARE\Strix Firewall";
/// Serves the CRL our leaves point at; schannel fails closed without one.
const CRL_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2);
/// Admin-only; holds the exported CA, the service log and the config.
fn data_dir() -> PathBuf {
known_folder(&FOLDERID_ProgramData).join("Strix Firewall")
}
/// Admin-only, so users cannot relax what the service screens.
fn config_file() -> PathBuf {
data_dir().join("config.json")
}
pub fn system_config() -> Option<PathBuf> {
Some(config_file())
}
/// In the folder Windows reads it from, normally %SystemRoot%\System32\drivers\etc.
fn hosts_file() -> PathBuf {
let key = r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters";
let configured = LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_hstring("DataBasePath"));
let dir = match configured {
Ok(dir) => expand_environment(&dir),
Err(_) => known_folder(&FOLDERID_System).join(r"drivers\etc"),
};
dir.join("hosts")
}
/// Windows always has its known folders.
fn known_folder(id: &GUID) -> PathBuf {
let path = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, None) }.expect("a known folder");
let dir = PathBuf::from(unsafe { path.to_hstring() }.to_os_string());
unsafe { CoTaskMemFree(Some(path.0 as *const _)) };
dir
}
fn expand_environment(path: &HSTRING) -> PathBuf {
let len = unsafe { ExpandEnvironmentStringsW(path, None) };
let mut expanded = vec![0; len as usize];
let len = unsafe { ExpandEnvironmentStringsW(path, Some(&mut expanded)) };
// Less the terminating null.
PathBuf::from(String::from_utf16_lossy(
&expanded[..(len as usize).saturating_sub(1)],
))
}
/// From 127.0.0.3, leaving 127.0.0.1 to whatever else serves locally.
fn loopback_addresses(hosts: &[&'static str]) -> Vec<(Ipv4Addr, &'static str)> {
(3..)
.zip(hosts)
.map(|(i, host)| (Ipv4Addr::new(127, 0, 0, i), *host))
.collect()
}
/// Points each host at its listener, replacing the lines we wrote before; with none, only removes
/// them. Bytes, as the file needn't be UTF-8, and every other line is kept as it was.
fn write_hosts_entries(hosts: &[&'static str]) -> std::io::Result<()> {
const TAG: &str = " # Strix Firewall";
let old = std::fs::read(hosts_file())?;
let ours = |line: &&[u8]| line.trim_ascii_end().ends_with(TAG.as_bytes());
let lines = old.split_inclusive(|&byte| byte == b'\n');
let mut new: Vec<u8> = lines
.filter(|line| !ours(line))
.flatten()
.copied()
.collect();
if !new.is_empty() && !new.ends_with(b"\n") {
new.extend_from_slice(b"\r\n");
}
for (address, host) in loopback_addresses(hosts) {
new.extend_from_slice(format!("{address} {host}{TAG}\r\n").as_bytes());
}
std::fs::write(hosts_file(), new)
}
/// Parses `sddl`; freed on drop.
fn security_descriptor(sddl: &str) -> windows::core::Result<Owned<HLOCAL>> {
let mut descriptor = PSECURITY_DESCRIPTOR::default();
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
&HSTRING::from(sddl),
SDDL_REVISION_1,
&mut descriptor,
None,
)?
};
Ok(unsafe { Owned::new(HLOCAL(descriptor.0)) })
}
fn read_registry_string(key: &str, value: &str) -> Result<Option<String>, BoxError> {
none_if_missing(
LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_string(value)),
)
}
/// `None` for a missing key or value.
fn none_if_missing<T>(result: windows_registry::Result<T>) -> Result<Option<T>, BoxError> {
match result {
Err(error) if error.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(None),
result => Ok(Some(result?)),
}
}
-234
View File
@@ -1,234 +0,0 @@
use super::ca::{load_or_create_ca, secure_data_dir};
use super::tpm::CaKey;
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME};
use crate::config::FirewallConfig;
use crate::http::{reply, text};
use crate::{mitm, telemetry, Firewall};
use rama::dns::client::hickory::resolver::config::ResolveHosts;
use rama::dns::client::hickory::{default_resolver_opts, HickoryDnsResolver};
use rama::dns::client::resolver::DnsAddressResolver;
use rama::dns::client::try_init_global_dns_resolver;
use rama::error::BoxError;
use rama::http::server::HttpServer;
use rama::http::{Request, Response};
use rama::io::BridgeIo;
use rama::layer::ConsumeErrLayer;
use rama::net::address::Domain;
use rama::rt::Executor;
use rama::service::service_fn;
use rama::tcp::server::TcpListener;
use rama::tcp::TcpStream;
use rama::{Layer, Service};
use rcgen::SigningKey;
use std::convert::Infallible;
use std::ffi::OsString;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
};
use windows_service::{define_windows_service, service_dispatcher};
const CRL_PATH: &str = "/ca.crl";
define_windows_service!(ffi_service_main, service_main);
/// Called by the service control manager, never by hand.
pub fn run() -> windows_service::Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
/// stderr goes nowhere in a service, so events go to a file beside the CA.
fn service_main(_arguments: Vec<OsString>) {
let log = secure_data_dir().ok().and_then(|dir| {
RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix("firewall")
.filename_suffix("log")
.max_log_files(7)
.build(dir)
.ok()
});
if let Some(appender) = log {
let filter = crate::log_filter("info");
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(appender)
.try_init();
}
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
if let Err(error) = run_until_stopped() {
tracing::error!(%error, "the service stopped");
}
}
fn run_until_stopped() -> Result<(), BoxError> {
let (control_sender, mut control_receiver) = tokio::sync::mpsc::unbounded_channel();
let status = service_control_handler::register(SERVICE_NAME, move |control| match control {
ServiceControl::Stop
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})?;
set_status(&status, ServiceState::StartPending, false)?;
let served = crate::runtime()?.block_on(async {
let hosts = match start_screening(&status).await {
Ok(hosts) => hosts,
Err(error) => {
telemetry::send().await;
return Err(error);
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some(control) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
// A failed stop is what makes the service manager restart it.
set_status(&status, ServiceState::Stopped, served.is_err())?;
served
}
async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static str>, BoxError> {
telemetry::set_mode("service");
telemetry::heartbeat();
// Never the hosts file, which points back at us; the Firewall's lookups included.
let mut options = default_resolver_opts();
options.use_hosts_file = ResolveHosts::Never;
let resolver = HickoryDnsResolver::try_new_system_with_options(options)?;
let _ = try_init_global_dns_resolver(resolver.clone());
let config =
FirewallConfig::load_from(&[config_file()]).inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = CaKey::load_or_create().inspect_err(|_| telemetry::error("tpm"))?;
let signer: mitm::CaSigner = Arc::new(key);
let ca = load_or_create_ca(&hosts, &*signer).inspect_err(|_| telemetry::error("ca"))?;
let exec = Executor::default();
// An empty CRL for the leaves to point at, signed fresh for each fetch.
let crl_url = format!("http://{CRL_ADDRESS}{CRL_PATH}");
let (crl_hosts, crl_signer) = (hosts.clone(), Arc::clone(&signer));
let crl = service_fn(move |req: Request| {
let response = if req.uri().request_target() == CRL_PATH {
crl_response(&crl_hosts, &*crl_signer)
} else {
text(404, "No route.\n")
};
async move { Ok::<_, Infallible>(response) }
});
let crl = HttpServer::auto(exec.clone()).service(crl);
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in loopback_addresses(&hosts) {
for port in [80, 443] {
let upstream = RedirectedHost {
host,
port,
resolver: resolver.clone(),
service: service.clone(),
};
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
}
// Rewritten on every start, so a restart picks up a changed host list.
write_hosts_entries(&hosts)?;
tracing::info!(?hosts, "screening redirected registry traffic");
set_status(status, ServiceState::Running, false)?;
Ok(hosts)
}
fn crl_response(hosts: &[&str], signer: &dyn SigningKey) -> Response {
match mitm::crl(hosts, signer) {
Ok(der) => reply(200, "application/pkix-crl", der),
Err(error) => text(503, &format!("{error}\n")),
}
}
fn listen_on(address: Ipv4Addr, port: u16, exec: &Executor) -> std::io::Result<TcpListener> {
let bound = std::net::TcpListener::bind((address, port))?;
bound.set_nonblocking(true)?;
TcpListener::try_from_std_tcp_listener(bound, exec.clone())
}
/// Each listener is bound to one host's address, which is how it knows where to connect.
#[derive(Clone)]
struct RedirectedHost {
host: &'static str,
port: u16,
resolver: HickoryDnsResolver,
service: mitm::MitmService,
}
impl Service<TcpStream> for RedirectedHost {
type Output = ();
type Error = BoxError;
async fn serve(&self, ingress: TcpStream) -> Result<(), BoxError> {
let address = self.resolve().await?;
let egress = tokio::net::TcpStream::connect((address, self.port)).await?;
let streams = BridgeIo(ingress, TcpStream::from(egress));
self.service.serve(streams).await
}
}
impl RedirectedHost {
/// IPv4 if it has one, else IPv6.
async fn resolve(&self) -> Result<IpAddr, BoxError> {
let domain = Domain::from_static(self.host);
if let Some(Ok(v4)) = self.resolver.lookup_ipv4_first(domain.clone()).await {
return Ok(v4.into());
}
match self.resolver.lookup_ipv6_first(domain).await {
Some(v6) => Ok(v6?.into()),
None => Err(format!("{} has no address", self.host).into()),
}
}
}
fn set_status(
status: &ServiceStatusHandle,
state: ServiceState,
failed: bool,
) -> windows_service::Result<()> {
status.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: match state {
ServiceState::Running | ServiceState::Paused => {
ServiceControlAccept::STOP
| ServiceControlAccept::SHUTDOWN
| ServiceControlAccept::PAUSE_CONTINUE
}
_ => ServiceControlAccept::empty(),
},
exit_code: if failed {
ServiceExitCode::ServiceSpecific(1)
} else {
ServiceExitCode::Win32(0)
},
checkpoint: 0,
wait_hint: Duration::from_secs(30),
process_id: None,
})
}
+1 -1
View File
@@ -30,7 +30,7 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
})
});
/// How the firewall is running: "extension", "service", "wrapper" or "proxy".
/// How the firewall is running: "extension", "wrapper" or "proxy".
static MODE: OnceLock<&'static str> = OnceLock::new();
struct Telemetry {
+3 -3
View File
@@ -280,10 +280,10 @@ final class FirewallModel: NSObject, ObservableObject {
return true
}
/// The extension wrote these as root, so the bundled firewall-uninstall removes them behind an admin prompt; returns what to report if it failed.
/// 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 uninstaller = Bundle.main.path(forAuxiliaryExecutable: "firewall-uninstall") ?? ""
let script = "do shell script quoted form of \"\(uninstaller)\" with administrator privileges"
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 {
+1 -1
View File
@@ -55,7 +55,7 @@ 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-uninstall"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
+4 -4
View File
@@ -118,14 +118,14 @@ targets:
- target: Extension
embed: true
codeSign: true
# Uninstall runs this as root to remove what the extension left.
# Uninstall runs the CLI as root to remove what the extension left.
postBuildScripts:
- name: Embed firewall-uninstall
- 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-uninstall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall-uninstall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"
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/"