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
18 changed files with 1068 additions and 53 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"
+15
View File
@@ -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"] }
+12
View File
@@ -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> {
+1 -8
View File
@@ -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
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(),
}
+29 -6
View File
@@ -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
View File
@@ -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()?;
+4 -2
View File
@@ -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 }));
+1 -1
View File
@@ -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 })
}
+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(()),
}
}
+7
View File
@@ -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");
}