Compare commits

..
Author SHA1 Message Date
Ian afa0b9a2ec Notarize only when NOTARY_PROFILE is set 2026-10-21 12:59:09 -04:00
Ian 620ed57c9a Say how signing identities are matched 2026-10-21 12:54:49 -04:00
Ian 7ed69632a2 Name signing identities by type 2026-10-21 12:51:15 -04:00
Ian ccf59a1d0c Sign with Developer ID and notarize the package 2026-10-21 12:42:24 -04:00
19 changed files with 63 additions and 122 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

+2 -2
View File
@@ -59,8 +59,8 @@ impl Default for FirewallConfig {
impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
.into(),
poll_secs: 10,
}
-13
View File
@@ -12,10 +12,6 @@ fn main() {
firewall::init_logging("info");
serve()
}
if matches.subcommand_matches("uninstall").is_some() {
firewall::init_logging("info");
uninstall()
}
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
@@ -44,8 +40,6 @@ fn options() -> Command {
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
// Run as root by the macOS app's uninstall.
.subcommand(Command::new("uninstall").hide(true))
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
@@ -75,13 +69,6 @@ fn serve() -> ! {
}
}
fn uninstall() -> ! {
match firewall::os::uninstall() {
Ok(()) => exit(0),
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
}
}
fn run(invocation: Invocation) -> ! {
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
+1 -3
View File
@@ -60,12 +60,10 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
}
pub const COMMON_NAME: &str = "Strix Firewall";
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default();
+1 -1
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
pub use ca::{crl, CaSigner, CertificateAuthority};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+5 -3
View File
@@ -2,8 +2,6 @@ mod enclave;
mod install;
mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::{mitm, telemetry};
use crate::Firewall;
@@ -51,11 +49,15 @@ impl TransparentProxyHandler for Handler {
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
}
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
/// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"uninstall" => match uninstall::certificate_authority(&self.ca) {
Ok(()) => "ok".to_owned(),
Err(error) => format!("error: {error}"),
},
other => format!("error: unknown message {other}"),
}))
}
+9 -30
View File
@@ -1,47 +1,26 @@
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::COMMON_NAME;
use crate::mitm::CertificateAuthority;
use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework_sys::base::errSecItemNotFound;
use std::io::ErrorKind;
use tracing::info;
/// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
/// Removes what only root can: every CA the extension minted, its enclave key and exported
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those.
pub fn uninstall() -> Result<(), BoxError> {
let deleted = ItemSearchOptions::new()
.class(ItemClass::certificate())
.keychains(&[SecKeychain::open("/Library/Keychains/System.keychain")?])
.label(COMMON_NAME)
.limit(Limit::All)
.delete();
let mut removed = match deleted {
Ok(()) => true,
Err(error) if error.code() == errSecItemNotFound => false,
Err(error) => return Err(error.into()),
};
if system_keychain::load_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?.is_some() {
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
removed = true;
}
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
EnclaveKey::delete()?;
for path in [CA_FILE, ENV_AGENT] {
match std::fs::remove_file(path) {
Ok(()) => removed = true,
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
_ => {}
}
}
if removed {
info!("removed the CA from the system keychain");
} else {
info!("nothing to uninstall");
}
info!("removed the CA from the system keychain");
Ok(())
}
-9
View File
@@ -1,11 +1,2 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
+2 -2
View File
@@ -3,5 +3,5 @@ Firewall.xcodeproj
.env
Extension/Extension.entitlements
Extension/Info.plist
App/App.entitlements
App/Info.plist
Installer/Installer.entitlements
Installer/Info.plist
@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.dismiss) private var dismiss
var body: some View {
File renamed without changes.
@@ -1,8 +1,8 @@
import SwiftUI
@main
struct FirewallApp: App {
@StateObject private var model = FirewallModel()
struct InstallerApp: App {
@StateObject private var model = InstallerModel()
var body: some Scene {
MenuBarExtra {
@@ -31,7 +31,7 @@ enum SetupWindow {
/// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
private struct MenuBarLabel: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.openWindow) private var openWindow
@State private var raised = false
@@ -23,20 +23,22 @@ enum StepStatus: Equatable {
}
}
enum FirewallError: Error, LocalizedError {
enum InstallerError: Error, LocalizedError {
case extensionUnavailable
case extensionRefused(String)
case screeningDidNotStart(NEVPNStatus)
var errorDescription: String? {
switch self {
case .extensionUnavailable: return "The extension is not running"
case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
}
}
}
@MainActor
final class FirewallModel: NSObject, ObservableObject {
final class InstallerModel: NSObject, ObservableObject {
@Published private(set) var extensionStep = StepStatus.pending
@Published private(set) var screeningStep = StepStatus.pending
@Published private(set) var trustStep = StepStatus.pending
@@ -122,9 +124,9 @@ final class FirewallModel: NSObject, ObservableObject {
log("Uninstall cancelled")
return
}
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
// The extension owns the private key, so ask it before tearing down the session that carries the request.
let strandedKey = await removeStoredCA()
await removeConfiguration()
let strandedKey = removeStoredCA()
await deactivate()
if let strandedKey { trustStep = .failed(strandedKey) }
@@ -165,7 +167,7 @@ final class FirewallModel: NSObject, ObservableObject {
}
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
self[keyPath: step] = .running(running)
@@ -243,18 +245,17 @@ final class FirewallModel: NSObject, ObservableObject {
return true
}
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
private func removeStoredCA() -> String? {
let cli = Bundle.main.path(forAuxiliaryExecutable: "firewall") ?? ""
let script = "do shell script quoted form of \"\(cli)\" & \" uninstall\" with administrator privileges"
var error: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&error)
if let error {
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)")
/// Only root can delete the key, so the extension does it; returns what to report if it survived.
private func removeStoredCA() async -> String? {
do {
let reply = try await providerMessage("uninstall")
guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
log("Removed the CA, its exported copy and the env agent")
return nil
} catch {
log("Could not remove the CA: \(error.localizedDescription)")
return "The CA private key is still in the System keychain"
}
log("Removed the CA, its exported copy and the env agent")
return nil
}
// MARK: - System extension
@@ -296,14 +297,14 @@ final class FirewallModel: NSObject, ObservableObject {
try manager.connection.startVPNTunnel()
guard try await wait(for: .connected, on: manager.connection) else {
throw FirewallError.screeningDidNotStart(manager.connection.status)
throw InstallerError.screeningDidNotStart(manager.connection.status)
}
return "Screening package downloads"
}
private func restartScreening() async throws -> String {
guard let manager = try await loadManager() else {
throw FirewallError.extensionUnavailable
throw InstallerError.extensionUnavailable
}
manager.connection.stopVPNTunnel()
_ = try await wait(for: .disconnected, on: manager.connection)
@@ -311,7 +312,7 @@ final class FirewallModel: NSObject, ObservableObject {
}
private func stopScreening() async throws -> String {
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
manager.isEnabled = false
try await manager.saveToPreferences()
manager.connection.stopVPNTunnel()
@@ -357,13 +358,13 @@ final class FirewallModel: NSObject, ObservableObject {
private func providerMessage(_ message: String) async throws -> String {
guard let session = watched?.connection as? NETunnelProviderSession else {
throw FirewallError.extensionUnavailable
throw InstallerError.extensionUnavailable
}
return try await withCheckedThrowingContinuation { continuation in
do {
try session.sendProviderMessage(Data(message.utf8)) { response in
guard let response, let reply = String(data: response, encoding: .utf8) else {
continuation.resume(throwing: FirewallError.extensionUnavailable)
continuation.resume(throwing: InstallerError.extensionUnavailable)
return
}
continuation.resume(returning: reply)
@@ -380,7 +381,7 @@ final class FirewallModel: NSObject, ObservableObject {
}
}
extension FirewallModel: OSSystemExtensionRequestDelegate {
extension InstallerModel: OSSystemExtensionRequestDelegate {
nonisolated func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
@@ -1,7 +1,7 @@
import SwiftUI
struct MenuBarView: View {
@ObservedObject var model: FirewallModel
@ObservedObject var model: InstallerModel
@Environment(\.openWindow) private var openWindow
var body: some View {
@@ -9,7 +9,7 @@ struct MenuBarView: View {
Divider()
Button("Setup") {
Button("Installer") {
SetupWindow.open(openWindow)
}
File renamed without changes.
+10 -16
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# Builds and signs StrixFirewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
# makes a Debug build and skips notarization.
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# Set NOTARY_PROFILE in macos/.env to a notarytool keychain profile to notarize the package.
#
# Xcode builds the bundles unsigned; they are signed here with Developer ID.
set -euo pipefail
@@ -11,10 +10,6 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}"
export TEAM
CONFIGURATION=Release
if [ "${1:-}" = debug ]; then CONFIGURATION=Debug; fi
[ $CONFIGURATION = Debug ] || : "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
APP_ID=com.usestrix.firewall
EXT_ID=com.usestrix.firewall.extension
VERSION=0.1
@@ -23,7 +18,7 @@ IDENTITY=${IDENTITY:-Developer ID Application}
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
APP=build/Build/Products/Release/Installer.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
@@ -32,13 +27,13 @@ main() {
sign
install_app
package
[ $CONFIGURATION = Debug ] || notarize
[ -z "${NOTARY_PROFILE:-}" ] || notarize
}
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Release \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
@@ -47,19 +42,18 @@ sign() {
echo "==> Signing with $IDENTITY"
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$APP"
}
# The extension can only be activated from /Applications.
install_app() {
echo "==> Installing /Applications/StrixFirewall.app"
rm -rf /Applications/StrixFirewall.app
cp -R "$APP" /Applications/StrixFirewall.app
echo "==> Installing /Applications/Firewall.app"
rm -rf /Applications/Firewall.app
cp -R "$APP" /Applications/Firewall.app
}
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
@@ -67,7 +61,7 @@ package() {
echo "==> Packaging build/Firewall.pkg"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app"
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
+1 -1
View File
@@ -6,5 +6,5 @@ uid=$(id -u "$user")
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
launchctl bootstrap "gui/$uid" "$agent"
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
exit 0
+4 -15
View File
@@ -87,25 +87,25 @@ targets:
$([ "$CONFIGURATION" = Debug ] || echo --release)
done
StrixFirewall:
Installer:
type: application
platform: macOS
sources:
- App
- Installer
- path: ../assets/logo-transparent.png
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
info:
path: App/Info.plist
path: Installer/Info.plist
properties:
CFBundleShortVersionString: "0.1"
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true
entitlements:
path: App/App.entitlements
path: Installer/Installer.entitlements
properties:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
com.apple.developer.team-identifier: ${TEAM}
@@ -117,14 +117,3 @@ targets:
- target: Extension
embed: true
codeSign: true
# Uninstall runs the CLI as root to remove what the extension left.
postBuildScripts:
- name: Embed the CLI
basedOnDependencyAnalysis: false
script: |
export PATH="$HOME/.cargo/bin:$PATH"
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"