mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7958d6f21 | ||
|
|
07d2533208 | ||
|
|
feccd84f0f | ||
|
|
cb1f9f6faf | ||
|
|
a6c378c0d6 | ||
|
|
959e71e07c |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Generated
+218
-3
@@ -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"
|
||||
|
||||
@@ -75,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"] }
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ impl FirewallConfig {
|
||||
pub fn local_url(&self, path: &str) -> String {
|
||||
crate::local_url(self.port, path)
|
||||
}
|
||||
|
||||
/// Without building a `Firewall`, which needs a runtime.
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
|
||||
@@ -40,14 +40,7 @@ impl Firewall {
|
||||
}
|
||||
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.config.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
self.config.hosts()
|
||||
}
|
||||
|
||||
pub fn is_index_url(&self, url: &str) -> bool {
|
||||
|
||||
+37
-30
@@ -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(),
|
||||
}
|
||||
|
||||
+29
-6
@@ -1,12 +1,18 @@
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
|
||||
KeyUsagePurpose, NameConstraints, SigningKey,
|
||||
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
|
||||
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
|
||||
SerialNumber, SigningKey,
|
||||
};
|
||||
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 {
|
||||
@@ -42,16 +48,33 @@ impl CertificateAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
|
||||
/// 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 {
|
||||
this_update: now - Duration::hours(1),
|
||||
next_update: now + Duration::days(7),
|
||||
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs: Vec::new(),
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
let ca = params(hosts);
|
||||
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
|
||||
// rcgen signs a CRL only for a CA that may.
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.name_constraints = Some(NameConstraints {
|
||||
permitted_subtrees: hosts
|
||||
.iter()
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
|
||||
use rama::utils::collections::non_empty_vec;
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
|
||||
@@ -14,12 +14,18 @@ pub struct ConstrainedCertIssuer {
|
||||
ca_params: Arc<CertificateParams>,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
}
|
||||
|
||||
impl ConstrainedCertIssuer {
|
||||
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
|
||||
pub(super) fn new(
|
||||
ca_crt: X509,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
) -> Self {
|
||||
let ca_params = Arc::new(params(&hosts));
|
||||
Self { ca_crt, ca_params, signer, hosts }
|
||||
Self { ca_crt, ca_params, signer, hosts, crl }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +48,9 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
|
||||
subject.push(DnType::CommonName, common_name);
|
||||
let mut leaf = CertificateParams::new(names)?;
|
||||
leaf.distinguished_name = subject;
|
||||
if let Some(crl) = &self.crl {
|
||||
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
|
||||
}
|
||||
|
||||
// The TLS server needs the leaf key in memory, so it can't be the platform key.
|
||||
let leaf_key = KeyPair::generate()?;
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{CaSigner, CertificateAuthority};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
@@ -25,14 +25,16 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
|
||||
type InterceptService =
|
||||
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
|
||||
|
||||
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
|
||||
pub fn mitm_service(
|
||||
firewall: Arc<Firewall>,
|
||||
ca: &CertificateAuthority,
|
||||
signer: CaSigner,
|
||||
crl: Option<String>,
|
||||
exec: Executor,
|
||||
) -> MitmService {
|
||||
let hosts = firewall.hosts();
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
|
||||
|
||||
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
let signer: mitm::CaSigner = Arc::new(key);
|
||||
let ca = install::certificate_authority(&hosts, &*signer);
|
||||
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, exec);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, service })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,3 +43,10 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
|
||||
|
||||
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
|
||||
}
|
||||
|
||||
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
|
||||
#[test]
|
||||
fn ca_signs_an_empty_crl() {
|
||||
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
|
||||
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
|
||||
}
|
||||
Reference in new issue
Block a user