Compare commits

..
Author SHA1 Message Date
Ian Graham Martinez e7958d6f21 Let the Windows service own its hosts-file block
The service writes the block once it listens. Pausing it removes the block
and continuing rewrites it; a stop, crash or shutdown leaves it, so a
restart fails closed and picks up a changed host list. Interactive users
may start, stop and pause the service, for the tray.
2026-10-12 23:51:25 -04:00
Ian Graham Martinez 07d2533208 Install and uninstall machine-wide screening on Windows
install copies the binary where only admins can replace it, trusts the CA,
sets the environment for clients that read no trust store, starts the
service, then points the hosts file at it. uninstall reverses each step,
the hosts file first.
2026-10-12 23:24:47 -04:00
Ian Graham Martinez feccd84f0f Screen redirected registry traffic in a Windows service
Each registry host gets its own loopback address, where a LocalSystem
service serves mitm_service, connecting upstream through a resolver that
ignores the hosts file. It trusts its CA machine-wide and serves the empty
CRL that schannel needs.
2026-10-12 23:23:52 -04:00
Ian Graham Martinez cb1f9f6faf 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-12 21:20:17 -04:00
Ian Graham Martinez a6c378c0d6 Sign an empty CRL and point leaves at it
schannel's revocation check, which cargo enables, fails closed on a leaf
without a reachable CRL. The CA gains cRLSign, which rcgen needs to sign
one, and leaves carry a distribution point when the platform serves it.
2026-10-12 19:59:29 -04:00
Ian Graham Martinez 959e71e07c List the screened hosts from the config
Building a Firewall needs a runtime, which an installer has no use for.
2026-10-12 19:59:28 -04:00
33 changed files with 1093 additions and 311 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

+218 -3
View File
@@ -172,6 +172,17 @@ 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"
@@ -456,6 +467,16 @@ 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"
@@ -496,6 +517,12 @@ 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"
@@ -997,6 +1024,78 @@ 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"
@@ -1158,6 +1257,19 @@ 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"
@@ -1644,6 +1756,10 @@ 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"
@@ -1778,6 +1894,17 @@ 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"
@@ -2121,6 +2248,7 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2419,7 +2547,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation",
"core-foundation 0.10.1",
"csv",
"idna",
"ipnet",
@@ -2751,6 +2879,12 @@ 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"
@@ -2886,7 +3020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3094,7 +3228,7 @@ name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"core-foundation",
"core-foundation 0.10.1",
"directories",
"percent-encoding",
"rama",
@@ -3111,6 +3245,10 @@ dependencies = [
"tracing",
"tracing-subscriber",
"which",
"windows-registry",
"windows-result",
"windows-service",
"windows-sys 0.61.2",
]
[[package]]
@@ -3181,6 +3319,27 @@ 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"
@@ -3269,6 +3428,12 @@ 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"
@@ -3522,6 +3687,19 @@ 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"
@@ -3669,6 +3847,12 @@ 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"
@@ -3715,6 +3899,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[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"
@@ -3724,6 +3919,26 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
+16 -5
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"
@@ -67,7 +63,7 @@ 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"] }
@@ -79,6 +75,21 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
rama = { version = "0.4.0", features = ["dns-hickory"] }
tokio = { version = "1.53.1", features = ["net"] }
windows-registry = "0.6.1"
windows-result = "0.4.1"
windows-service = "0.8.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_System_Services",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+10 -15
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(),
}
@@ -60,6 +54,7 @@ impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
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,
+2 -6
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 }
}
+37 -30
View File
@@ -12,6 +12,8 @@ fn main() {
firewall::init_logging("info");
serve()
}
#[cfg(windows)]
windows(&matches);
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
@@ -25,13 +27,11 @@ fn options() -> Command {
"Run a command through {program} to screen what it installs:\n \
{program} npm install left-pad\n\n\
Alias it to screen every install without changing how you type:\n \
{}\n\n\
alias npm='{program} npm'\n\n\
Supported package managers: {}",
alias(&program),
managers.join(", ")
);
Command::new("firewall")
.bin_name(&program)
let command = Command::new("firewall")
.about("Screens package downloads before they reach your machine")
.after_help(footer)
.arg_required_else_help(true)
@@ -46,7 +46,36 @@ fn options() -> Command {
.num_args(1..)
.trailing_var_arg(true)
.allow_hyphen_values(true),
)
);
#[cfg(windows)]
let command = command
.subcommand(Command::new("install").about("Screen every package download on this machine"))
.subcommand(Command::new("uninstall").about("Stop screening and remove the firewall"))
.subcommand(Command::new("service").hide(true));
command
}
/// Machine-wide screening through the hosts file and a service; needs an elevated shell.
#[cfg(windows)]
fn windows(matches: &clap::ArgMatches) {
use firewall::os::windows;
let (kind, result) = match matches.subcommand_name() {
// The service logs to a file; it has no console.
Some("service") => ("service", windows::service().map_err(Into::into)),
Some("install") => {
firewall::init_logging("info");
("install", windows::install())
}
Some("uninstall") => {
firewall::init_logging("info");
("uninstall", windows::uninstall())
}
_ => return,
};
match result {
Ok(()) => exit(0),
Err(e) => fail(kind, &format!("could not {kind}: {e}")),
}
}
fn serve() -> ! {
@@ -93,32 +122,12 @@ fn print_setup(config: &FirewallConfig) {
let base = config.local_url("");
println!("Firewall listening on {base}\n");
println!("Point your package managers at it:");
println!(" npm {}", variable("npm_config_registry", &format!("{base}/npm/")));
println!(" pip {}", variable("PIP_INDEX_URL", &format!("{base}/pypi/simple/")));
println!(" npm npm_config_registry={base}/npm/");
println!(" pip PIP_INDEX_URL={base}/pypi/simple/");
println!(" cargo [source.crates-io] replace-with = \"firewall\"");
println!(" [source.firewall] registry = \"sparse+{base}/cargo/index/\"\n");
}
fn alias(program: &str) -> String {
if cfg!(windows) {
// Set-Alias cannot carry arguments, so a function stands in.
format!(
"PowerShell: function npm {{ {program} npm @args }}\n \
cmd: doskey npm={program} npm $*"
)
} else {
format!("alias npm='{program} npm'")
}
}
fn variable(key: &str, value: &str) -> String {
if cfg!(windows) {
format!("$env:{key} = \"{value}\"")
} else {
format!("{key}={value}")
}
}
fn load() -> FirewallConfig {
match FirewallConfig::load() {
Ok(config) => config,
@@ -135,9 +144,7 @@ fn fail(kind: &'static str, message: &str) -> ! {
fn program_name() -> String {
let arg = std::env::args_os().next().unwrap_or_default();
// Without `.exe` on Windows, which is how it is typed.
let path = Path::new(&arg);
match if cfg!(windows) { path.file_stem() } else { path.file_name() } {
match Path::new(&arg).file_name() {
Some(name) => name.to_string_lossy().into_owned(),
None => "firewall".into(),
}
+8 -4
View File
@@ -8,7 +8,11 @@ use rcgen::{
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
/// The CA's subject, by which a trust store entry is recognized as ours.
pub const COMMON_NAME: &str = "Strix Firewall";
pub const ORGANIZATION: &str = "Strix";
/// 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 {
@@ -45,7 +49,7 @@ impl CertificateAuthority {
}
/// 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.
/// Valid for a week; the caller re-signs it well before then.
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
let now = OffsetDateTime::now_utc();
let list = CertificateRevocationListParams {
@@ -63,8 +67,8 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
/// 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, "Strix Firewall");
name.push(DnType::OrganizationName, "Strix");
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::OrganizationName, ORGANIZATION);
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};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+3 -3
View File
@@ -25,10 +25,10 @@ use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
const BUNDLE_ID: &str = "com.usestrix.firewall";
const BUNDLE_ID: &str = "ai.strix.firewall";
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.
/// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
#[derive(Clone)]
@@ -113,7 +113,7 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
}
/// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
+1 -1
View File
@@ -7,7 +7,7 @@ use std::io::ErrorKind;
use tracing::info;
/// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
/// 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.
+2
View File
@@ -1,2 +1,4 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
+106
View File
@@ -0,0 +1,106 @@
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 std::io;
use tracing::warn;
use windows_sys::Win32::Security::Cryptography::*;
use windows_sys::Win32::Security::*;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
const DATA_DIR_ACL: &str = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFX;;;BU)";
/// Trusted machine-wide so restarts keep it; replaced if the hosts change.
pub fn certificate_authority(
hosts: &[&str],
signer: &dyn SigningKey,
) -> Result<CertificateAuthority, BoxError> {
let stored = registry_get(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 store = RootStore::open()?;
let (encoding, flags) = (X509_ASN_ENCODING, CERT_STORE_ADD_REPLACE_EXISTING);
let len = der.len() as u32;
let added = unsafe {
CertAddEncodedCertificateToStore(store.0, encoding, der.as_ptr(), len, flags, ptr::null_mut())
};
if added == 0 {
return Err(io::Error::last_os_error().into());
}
distrust(Some(&der))?;
// For clients that read no trust store; screening still works for the rest if this fails.
if let Err(error) = data_dir_ready().and_then(|dir| std::fs::write(dir.join("ca.pem"), &ca.pem)) {
warn!(%error, "could not export the CA");
}
Ok(ca)
}
/// LocalMachine\Root, closed on drop.
struct RootStore(HCERTSTORE);
impl RootStore {
fn open() -> io::Result<Self> {
let flags = CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG;
let name = wide("ROOT");
let store = unsafe { CertOpenStore(CERT_STORE_PROV_SYSTEM_W, 0, 0, flags, name.as_ptr().cast()) };
match store.is_null() {
true => Err(io::Error::last_os_error()),
false => Ok(RootStore(store)),
}
}
}
impl Drop for RootStore {
fn drop(&mut self) {
unsafe { CertCloseStore(self.0, 0) };
}
}
/// Created on first use, its ACL reset every time.
pub(super) fn data_dir_ready() -> io::Result<PathBuf> {
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
let info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION;
let path = wide(&dir.display().to_string());
with_descriptor(DATA_DIR_ACL, |acl| unsafe { SetFileSecurityW(path.as_ptr(), info, acl) })?;
Ok(dir)
}
/// Drops every CA of ours from LocalMachine\Root but `keep`.
pub(super) fn distrust(keep: Option<&[u8]>) -> Result<(), BoxError> {
let store = RootStore::open()?;
let mut cert = ptr::null();
loop {
cert = unsafe { CertEnumCertificatesInStore(store.0, cert) };
let Some(context) = (unsafe { cert.as_ref() }) else { return Ok(()) };
let der = unsafe { std::slice::from_raw_parts(context.pbCertEncoded, context.cbCertEncoded as usize) };
if keep != Some(der) && is_ours(der) {
// Deleting frees the context, which enumeration still needs, so delete a copy.
if unsafe { CertDeleteCertificateFromStore(CertDuplicateCertificateContext(cert)) } == 0 {
let error = io::Error::last_os_error();
unsafe { CertFreeCertificateContext(cert) };
return Err(error.into());
}
}
}
}
fn is_ours(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)
}
+82
View File
@@ -0,0 +1,82 @@
use super::ca::certificate_authority;
use super::tpm::TpmKey;
use super::uninstall::stop_service;
use super::*;
use crate::FirewallConfig;
use std::time::{Duration, Instant};
use tracing::info;
use windows_service::service::*;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION;
use windows_sys::Win32::System::Services::SetServiceObjectSecurity;
/// The defaults, except that interactive users may also start, stop and pause it, as the tray does.
const SERVICE_ACL: &str = "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)\
(A;;CCLCSWRPWPDTLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)";
/// Needs an elevated shell. The service points the hosts file at itself once it listens.
pub fn install() -> Result<(), BoxError> {
let hosts = FirewallConfig::default().hosts();
stop_service(false)?;
let (current, exe) = (std::env::current_exe()?, service_exe());
if current != exe {
std::fs::create_dir_all(exe.parent().expect("service_exe has a directory"))?;
std::fs::copy(current, &exe)?;
}
certificate_authority(&hosts, &TpmKey::load_or_create()?)?;
for (name, value) in environment() {
LOCAL_MACHINE.create(ENVIRONMENT_KEY)?.set_string(name, value)?;
}
broadcast_environment();
start_service()?;
info!(?hosts, "registry downloads are now screened");
Ok(())
}
fn start_service() -> Result<(), BoxError> {
let access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let manager = ServiceManager::local_computer(None::<&str>, access)?;
let info = ServiceInfo {
name: SERVICE_NAME.into(),
display_name: "Strix Firewall".into(),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: service_exe(),
launch_arguments: vec!["service".into()],
dependencies: vec![],
account_name: None,
account_password: None,
};
let access = ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::QUERY_STATUS
| ServiceAccess::WRITE_DAC;
let service = match manager.open_service(SERVICE_NAME, access) {
Ok(service) => service.change_config(&info).map(|()| service)?,
Err(_) => manager.create_service(&info, access)?,
};
service.set_description("Screens package downloads for known malware")?;
with_descriptor(SERVICE_ACL, |acl| unsafe {
SetServiceObjectSecurity(service.raw_handle(), DACL_SECURITY_INFORMATION, acl)
})?;
// Registry hosts lead nowhere while it is down, so bring it back.
let restart = ServiceAction { action_type: ServiceActionType::Restart, delay: Duration::from_secs(5) };
service.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(3600)),
reboot_msg: None,
command: None,
actions: Some(vec![restart; 3]),
})?;
service.set_failure_actions_on_non_crash_failures(true)?;
service.start::<&str>(&[])?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match service.query_status()?.current_state {
ServiceState::Running => return Ok(()),
ServiceState::Stopped => return Err("the service stopped, see firewall.log".into()),
_ if Instant::now() > deadline => return Err("the service did not start".into()),
_ => std::thread::sleep(Duration::from_millis(250)),
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! 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 install;
mod service;
mod tpm;
mod uninstall;
pub use install::install;
pub use service::run as service;
pub use tpm::TpmKey;
pub use uninstall::uninstall;
use rama::error::BoxError;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::ptr;
use windows_registry::LOCAL_MACHINE;
use windows_result::HRESULT;
use windows_sys::core::BOOL;
use windows_sys::Win32::Foundation::{LocalFree, ERROR_FILE_NOT_FOUND, LPARAM};
use windows_sys::Win32::Security::Authorization::*;
use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
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";
const ENVIRONMENT_KEY: &str = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
const BEGIN: &str = "# BEGIN Strix Firewall";
const END: &str = "# END 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 and the service log.
fn data_dir() -> PathBuf {
known_dir("ProgramData", r"C:\ProgramData").join("Strix Firewall")
}
/// The service binary, copied where only admins can replace it.
fn service_exe() -> PathBuf {
known_dir("ProgramFiles", r"C:\Program Files").join(r"Strix Firewall\firewall.exe")
}
fn hosts_file() -> PathBuf {
known_dir("SystemRoot", r"C:\Windows").join(r"System32\drivers\etc\hosts")
}
fn known_dir(var: &str, default: &str) -> PathBuf {
std::env::var_os(var).map_or_else(|| default.into(), PathBuf::from)
}
/// For clients that read no trust store. SSL_CERT_FILE would replace OpenSSL's bundle.
fn environment() -> [(&'static str, String); 2] {
let ca = data_dir().join("ca.pem");
[("NODE_EXTRA_CA_CERTS", ca.display().to_string()), ("UV_SYSTEM_CERTS", "1".into())]
}
/// From 127.0.0.3, leaving 127.0.0.1 to whatever else serves locally.
fn redirects(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, or with none removes our block.
fn write_hosts(hosts: &[&'static str]) -> std::io::Result<()> {
let mut contents = without_block(&std::fs::read_to_string(hosts_file())?);
if !hosts.is_empty() {
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push_str("\r\n");
}
contents += &format!("{BEGIN}\r\n");
for (address, host) in redirects(hosts) {
contents += &format!("{address} {host}\r\n");
}
contents += &format!("{END}\r\n");
}
std::fs::write(hosts_file(), contents)
}
/// `contents` without our block.
fn without_block(contents: &str) -> String {
let Some((before, rest)) = contents.split_once(BEGIN) else { return contents.into() };
let Some((_, after)) = rest.split_once(END) else { return contents.into() };
before.to_owned() + after.strip_prefix("\r\n").unwrap_or(after)
}
/// Parses `sddl` for `apply`, which returns zero on failure.
fn with_descriptor(sddl: &str, apply: impl FnOnce(PSECURITY_DESCRIPTOR) -> BOOL) -> std::io::Result<()> {
let (mut descriptor, sddl) = (ptr::null_mut(), wide(sddl));
let parsed = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.as_ptr(), SDDL_REVISION_1, &mut descriptor, ptr::null_mut())
};
if parsed == 0 {
return Err(std::io::Error::last_os_error());
}
let applied = apply(descriptor);
let error = std::io::Error::last_os_error();
unsafe { LocalFree(descriptor) };
match applied {
0 => Err(error),
_ => Ok(()),
}
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain([0]).collect()
}
fn registry_get(key: &str, value: &str) -> Result<Option<String>, BoxError> {
found(LOCAL_MACHINE.open(key).and_then(|key| key.get_string(value)))
}
/// `None` for a missing key or value.
fn found<T>(result: windows_registry::Result<T>) -> Result<Option<T>, BoxError> {
match result {
Err(error) if error.code() == HRESULT::from_win32(ERROR_FILE_NOT_FOUND) => Ok(None),
result => Ok(Some(result?)),
}
}
/// So new processes pick up the machine environment without a sign-out.
fn broadcast_environment() {
let environment = wide("Environment");
let lparam = environment.as_ptr() as LPARAM;
unsafe {
SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, lparam, SMTO_ABORTIFHUNG, 5000, ptr::null_mut())
};
}
+177
View File
@@ -0,0 +1,177 @@
use super::ca::{certificate_authority, data_dir_ready};
use super::tpm::TpmKey;
use super::{redirects, write_hosts, 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;
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 std::convert::Infallible;
use std::ffi::OsString;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use windows_service::service::*;
use windows_service::service_control_handler::{self, ServiceControlHandlerResult, ServiceStatusHandle};
use windows_service::{define_windows_service, service_dispatcher};
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 = data_dir_ready()
.and_then(|dir| std::fs::File::options().create(true).append(true).open(dir.join("firewall.log")));
if let Ok(file) = log {
let filter = crate::log_filter("info");
let _ = tracing_subscriber::fmt().with_env_filter(filter).with_ansi(false).with_writer(Mutex::new(file)).try_init();
}
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
if let Err(error) = serve() {
tracing::error!(%error, "the service stopped");
}
}
fn serve() -> Result<(), BoxError> {
let (controls, mut received) = 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 _ = controls.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})?;
report(&status, ServiceState::StartPending, false)?;
let served = crate::runtime()?.block_on(async {
let hosts = match listen(&status).await {
Ok(hosts) => hosts,
Err(error) => {
telemetry::send().await;
return Err(error);
}
};
// Only pausing lifts the block; a stop or crash keeps it, so traffic fails closed.
while let Some(control) = received.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts(hosts) {
Ok(()) => report(&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.
report(&status, ServiceState::Stopped, served.is_err())?;
served
}
async fn listen(status: &ServiceStatusHandle) -> Result<Vec<&'static str>, BoxError> {
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().inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = TpmKey::load_or_create().inspect_err(|_| telemetry::error("tpm"))?;
let signer: mitm::CaSigner = Arc::new(key);
let ca = certificate_authority(&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_hosts, crl_signer) = (hosts.clone(), Arc::clone(&signer));
let crl = HttpServer::auto(exec.clone()).service(service_fn(move |req: Request| {
let crl = match &*req.uri().request_target() {
"/ca.crl" => mitm::crl(&crl_hosts, &*crl_signer).map(|der| reply(200, "application/pkix-crl", der)),
_ => Ok(text(404, "No route.\n")),
};
async move { Ok::<_, Infallible>(crl.unwrap_or_else(|error| text(503, &format!("{error}\n")))) }
}));
tokio::spawn(bind(CRL_ADDRESS, 80, &exec)?.serve(crl));
let crl_url = format!("http://{CRL_ADDRESS}/ca.crl");
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in redirects(&hosts) {
for port in [80, 443] {
let upstream = Upstream { host, port, resolver: resolver.clone(), service: service.clone() };
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(bind(address, port, &exec)?.serve(upstream));
}
}
// Rewritten on every start, so a restart picks up a changed host list.
write_hosts(&hosts)?;
tracing::info!(?hosts, "screening registry traffic redirected by the hosts file");
report(status, ServiceState::Running, false)?;
Ok(hosts)
}
fn bind(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 Upstream {
host: &'static str,
port: u16,
resolver: HickoryDnsResolver,
service: mitm::MitmService,
}
impl Service<TcpStream> for Upstream {
type Output = ();
type Error = BoxError;
async fn serve(&self, ingress: TcpStream) -> Result<(), BoxError> {
let domain = Domain::from_static(self.host);
let address: IpAddr = match self.resolver.lookup_ipv4_first(domain.clone()).await {
Some(Ok(v4)) => v4.into(),
_ => self.resolver.lookup_ipv6_first(domain).await.ok_or(self.host)??.into(),
};
let egress = tokio::net::TcpStream::connect((address, self.port)).await?;
self.service.serve(BridgeIo(ingress, TcpStream::from(egress))).await
}
}
fn report(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,
})
}
+172
View File
@@ -0,0 +1,172 @@
use super::wide;
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 std::io;
use std::ptr;
use tracing::warn;
use windows_sys::core::{HRESULT, PCWSTR};
use windows_sys::Win32::Foundation::NTE_BAD_KEYSET;
use windows_sys::Win32::Security::Cryptography::*;
const KEY_NAME: &str = "Strix Firewall CA";
/// The TPM, then software for machines without one.
const PROVIDERS: [PCWSTR; 2] = [MS_PLATFORM_CRYPTO_PROVIDER, MS_KEY_STORAGE_PROVIDER];
/// The private key never leaves the TPM, or, without one, a non-exportable machine key.
#[derive(Default)]
pub struct TpmKey {
key: usize,
provider: usize,
/// The uncompressed point, which is what rcgen writes into a SubjectPublicKeyInfo.
public: Vec<u8>,
}
// NCrypt handles may be used from any thread.
unsafe impl Send for TpmKey {}
unsafe impl Sync for TpmKey {}
impl TpmKey {
/// A machine key, so the service and an elevated installer share it.
pub fn load_or_create() -> Result<Self, BoxError> {
let flags = NCRYPT_MACHINE_KEY_FLAG;
if let Some(key) = PROVIDERS.into_iter().find_map(|p| open(p, KEY_NAME, flags).transpose()) {
return Ok(key?);
}
let key = create(false, KEY_NAME, flags).or_else(|error| {
warn!(%error, "no TPM key, falling back to a software key");
create(true, KEY_NAME, flags)
});
Ok(key?)
}
pub fn delete() -> Result<(), BoxError> {
for provider in PROVIDERS {
if let Some(key) = open(provider, KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
key.destroy()?;
}
}
Ok(())
}
/// Deletes the key itself, not just this handle to it.
fn destroy(mut self) -> io::Result<()> {
check(unsafe { NCryptDeleteKey(std::mem::take(&mut self.key), 0) })
}
}
impl Drop for TpmKey {
fn drop(&mut self) {
for handle in [self.key, self.provider].into_iter().filter(|&h| h != 0) {
unsafe { NCryptFreeObject(handle) };
}
}
}
impl PublicKeyData for TpmKey {
fn der_bytes(&self) -> &[u8] {
&self.public
}
fn algorithm(&self) -> &'static SignatureAlgorithm {
&PKCS_ECDSA_P256_SHA256
}
}
impl SigningKey for TpmKey {
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, Error> {
let digest = sha256(msg);
let (mut raw, mut len) = ([0u8; 64], 0);
let flags = NCRYPT_SILENT_FLAG;
check(unsafe {
NCryptSignHash(self.key, ptr::null(), digest.as_ptr(), 32, raw.as_mut_ptr(), 64, &mut len, flags)
})
.map_err(|_| Error::RemoteKeyError)?;
// NCrypt returns r || s; an X.509 signature holds DER SEQUENCE { r, s }.
let (r, s) = raw[..len as usize].split_at(len as usize / 2);
let sig = BigNum::from_slice(r)
.and_then(|r| EcdsaSig::from_private_components(r, BigNum::from_slice(s)?))
.and_then(|sig| sig.to_der());
sig.map_err(|_| Error::RemoteKeyError)
}
}
/// `None` if the provider is absent or holds no such key.
fn open(provider: PCWSTR, name: &str, flags: u32) -> io::Result<Option<TpmKey>> {
let mut key = TpmKey::default();
if check(unsafe { NCryptOpenStorageProvider(&mut key.provider, provider, 0) }).is_err() {
return Ok(None);
}
let name = wide(name);
match unsafe { NCryptOpenKey(key.provider, &mut key.key, name.as_ptr(), 0, flags | NCRYPT_SILENT_FLAG) } {
NTE_BAD_KEYSET => Ok(None),
status => {
check(status)?;
key.public = public_point(key.key)?;
Ok(Some(key))
}
}
}
fn create(software: bool, name: &str, flags: u32) -> io::Result<TpmKey> {
let mut key = TpmKey::default();
check(unsafe { NCryptOpenStorageProvider(&mut key.provider, PROVIDERS[software as usize], 0) })?;
let (algorithm, name) = (NCRYPT_ECDSA_P256_ALGORITHM, wide(name));
check(unsafe { NCryptCreatePersistedKey(key.provider, &mut key.key, algorithm, name.as_ptr(), 0, flags) })?;
// TPM keys cannot be exported at all; a software key must be told not to allow it.
if software {
let policy = 0u32.to_ne_bytes();
check(unsafe {
NCryptSetProperty(key.key, NCRYPT_EXPORT_POLICY_PROPERTY, policy.as_ptr(), 4, NCRYPT_PERSIST_FLAG)
})?;
}
check(unsafe { NCryptFinalizeKey(key.key, NCRYPT_SILENT_FLAG) })?;
key.public = public_point(key.key)?;
Ok(key)
}
/// 0x04 || X || Y, from a BCRYPT_ECCKEY_BLOB header followed by X and Y.
fn public_point(key: usize) -> io::Result<Vec<u8>> {
let (mut blob, mut len) = ([0u8; 8 + 64], 0);
let format = BCRYPT_ECCPUBLIC_BLOB;
check(unsafe { NCryptExportKey(key, 0, format, ptr::null(), blob.as_mut_ptr(), 72, &mut len, 0) })?;
if blob[..4] != BCRYPT_ECDSA_PUBLIC_P256_MAGIC.to_le_bytes() || len != 72 {
return Err(io::Error::other("key is not an ECDSA P-256 key"));
}
Ok([&[0x04], &blob[8..]].concat())
}
fn check(status: HRESULT) -> io::Result<()> {
match status {
0 => Ok(()),
status => Err(io::Error::from_raw_os_error(status)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mitm::CertificateAuthority;
/// Per-user keys; the TPM one is skipped on machines without a TPM.
#[test]
fn keys_sign_a_verifiable_ca() {
for software in [true, false] {
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
let key = match create(software, &name, 0) {
Ok(key) => key,
Err(error) if !software => return eprintln!("skipping, no TPM key: {error}"),
Err(error) => panic!("create a software key: {error}"),
};
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
let spki = key.subject_public_key_info();
key.destroy().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);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
use super::ca::distrust;
use super::tpm::TpmKey;
use super::*;
use std::io::ErrorKind;
use std::time::{Duration, Instant};
use tracing::info;
use windows_service::service::{ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_sys::Win32::Foundation::*;
/// Reverses `install` in the opposite order, and does nothing for what is already gone.
pub fn uninstall() -> Result<(), BoxError> {
// Stop redirecting first, so nothing reaches the service as it goes.
write_hosts(&[])?;
stop_service(true)?;
for (name, value) in environment() {
if registry_get(ENVIRONMENT_KEY, name)? == Some(value) {
LOCAL_MACHINE.create(ENVIRONMENT_KEY)?.remove_value(name)?;
}
}
broadcast_environment();
distrust(None)?;
found(LOCAL_MACHINE.remove_tree(REGISTRY_KEY))?;
TpmKey::delete()?;
for dir in [data_dir(), service_exe().parent().expect("service_exe has a directory").into()] {
match std::fs::remove_dir_all(dir) {
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
_ => {}
}
}
info!("removed the firewall, its CA and its key");
Ok(())
}
/// Waits until it has stopped, so its binary can be replaced; `delete` also unregisters it.
pub(super) fn stop_service(delete: bool) -> Result<(), BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE;
let is = |error: &windows_service::Error, code: u32| {
matches!(error, windows_service::Error::Winapi(e) if e.raw_os_error() == Some(code as i32))
};
let service = match manager.open_service(SERVICE_NAME, access) {
Err(error) if is(&error, ERROR_SERVICE_DOES_NOT_EXIST) => return Ok(()),
service => service?,
};
match service.stop() {
Err(error) if is(&error, ERROR_SERVICE_NOT_ACTIVE) => {}
stopped => _ = stopped?,
}
let deadline = Instant::now() + Duration::from_secs(30);
while service.query_status()?.current_state != ServiceState::Stopped {
if Instant::now() > deadline {
return Err("the service did not stop".into());
}
std::thread::sleep(Duration::from_millis(250));
}
match delete.then(|| service.delete()) {
Some(Err(error)) if !is(&error, ERROR_SERVICE_MARKED_FOR_DELETE) => Err(error.into()),
_ => 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());
}
+2 -2
View File
@@ -1,7 +1,7 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "com.usestrix.firewall"
static let extensionBundleID = "com.usestrix.firewall.extension"
static let appBundleID = "ai.strix.firewall"
static let extensionBundleID = "ai.strix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.usestrix.firewall.env</string>
<string>ai.strix.firewall.env</string>
<key>ProgramArguments</key>
<array>
<string>/bin/launchctl</string>
+11 -26
View File
@@ -1,9 +1,8 @@
#!/bin/bash
# Builds and signs Firewall.app, installs it to /Applications, 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.
#
# Xcode builds the bundles unsigned; they are signed here with Developer ID.
# 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")"
@@ -11,19 +10,13 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}"
export TEAM
CONFIGURATION=Release
if [ "${1:-}" = debug ]; then CONFIGURATION=Debug; fi
[ $CONFIGURATION = Debug ] || : "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
APP_ID=com.usestrix.firewall
EXT_ID=com.usestrix.firewall.extension
APP_ID=ai.strix.firewall
EXT_ID=ai.strix.firewall.extension
VERSION=0.1
# Matched as a substring of the certificate name; set these if more than one certificate matches.
IDENTITY=${IDENTITY:-Developer ID Application}
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/Installer.app
APP=build/Build/Products/Debug/Installer.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
@@ -32,13 +25,12 @@ main() {
sign
install_app
package
[ $CONFIGURATION = Debug ] || notarize
}
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme Installer -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
}
@@ -47,9 +39,9 @@ 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 \
codesign --force --options runtime --timestamp=none \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
codesign --force --options runtime --timestamp=none \
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$APP"
}
@@ -67,19 +59,12 @@ package() {
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
cp LaunchAgents/ai.strix.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
}
notarize() {
echo "==> Notarizing build/Firewall.pkg"
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple build/Firewall.pkg
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
}
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
+1 -1
View File
@@ -3,7 +3,7 @@
user=$(stat -f %Su /dev/console)
[ "$user" = root ] && exit 0
uid=$(id -u "$user")
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
agent=/Library/LaunchAgents/ai.strix.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/Firewall.app --args --install
+10 -10
View File
@@ -1,6 +1,6 @@
name: Firewall
options:
bundleIdPrefix: com.usestrix.firewall
bundleIdPrefix: ai.strix.firewall
deploymentTarget: { macOS: "13.0" }
createIntermediateGroups: true
@@ -33,8 +33,8 @@ targets:
sources: [Extension]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
PRODUCT_NAME: com.usestrix.firewall.extension
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
PRODUCT_NAME: ai.strix.firewall.extension
PRODUCT_MODULE_NAME: Extension
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
info:
@@ -44,7 +44,7 @@ targets:
CFBundleShortVersionString: "0.1"
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension:
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
NEProviderClasses:
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
entitlements:
@@ -52,13 +52,13 @@ targets:
properties:
# Xcode injects these from the profile; we sign by hand, and NE's IPC
# check rejects a caller without them.
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.networking.networkextension:
- app-proxy-provider-systemextension
- app-proxy-provider
# NE requires NEMachServiceName to start with one of these.
com.apple.security.application-groups:
- ${TEAM}.com.usestrix.firewall
- ${TEAM}.ai.strix.firewall
com.apple.security.network.client: true
com.apple.security.network.server: true
dependencies:
@@ -96,7 +96,7 @@ targets:
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
info:
path: Installer/Info.plist
properties:
@@ -107,11 +107,11 @@ targets:
entitlements:
path: Installer/Installer.entitlements
properties:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
com.apple.application-identifier: ${TEAM}.ai.strix.firewall
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.system-extension.install: true
com.apple.developer.networking.networkextension:
- app-proxy-provider-systemextension
- app-proxy-provider
com.apple.security.network.client: true
dependencies:
- target: Extension