diff --git a/fluxer_desktop/native/gateway-socket/Cargo.lock b/fluxer_desktop/native/gateway-socket/Cargo.lock index 2fa60aa38..1d1453114 100644 --- a/fluxer_desktop/native/gateway-socket/Cargo.lock +++ b/fluxer_desktop/native/gateway-socket/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "napi-derive", "rustls", "rustls-native-certs", + "schannel", "tokio", "tokio-tungstenite", "url", diff --git a/fluxer_desktop/native/gateway-socket/Cargo.toml b/fluxer_desktop/native/gateway-socket/Cargo.toml index bd34aaecc..ae433fe28 100644 --- a/fluxer_desktop/native/gateway-socket/Cargo.toml +++ b/fluxer_desktop/native/gateway-socket/Cargo.toml @@ -23,3 +23,6 @@ url = "2.5.8" [build-dependencies] napi-build = "2.4.3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1.29" diff --git a/fluxer_desktop/native/gateway-socket/index.d.ts b/fluxer_desktop/native/gateway-socket/index.d.ts index 2b9be5315..a75ab84fa 100644 --- a/fluxer_desktop/native/gateway-socket/index.d.ts +++ b/fluxer_desktop/native/gateway-socket/index.d.ts @@ -33,4 +33,5 @@ export declare const connect: ) => NativeGatewayConnection) | null; export declare const warmup: (() => void) | null; +export declare const windowsIntermediateStoreRoots: (() => Array) | null; export declare const loadError: Error | null; diff --git a/fluxer_desktop/native/gateway-socket/index.js b/fluxer_desktop/native/gateway-socket/index.js index 070d5ffb6..21baae6a5 100644 --- a/fluxer_desktop/native/gateway-socket/index.js +++ b/fluxer_desktop/native/gateway-socket/index.js @@ -58,5 +58,9 @@ try { module.exports = { connect: binding ? binding.connect : null, warmup: binding && typeof binding.warmup === 'function' ? binding.warmup : null, + windowsIntermediateStoreRoots: + binding && typeof binding.windowsIntermediateStoreRoots === 'function' + ? binding.windowsIntermediateStoreRoots + : null, loadError, }; diff --git a/fluxer_desktop/native/gateway-socket/src/lib.rs b/fluxer_desktop/native/gateway-socket/src/lib.rs index 5fddb4698..054e1eb96 100644 --- a/fluxer_desktop/native/gateway-socket/src/lib.rs +++ b/fluxer_desktop/native/gateway-socket/src/lib.rs @@ -639,14 +639,13 @@ fn ensure_rustls_crypto_provider() { fn build_tls_client_config() -> std::result::Result, String> { ensure_rustls_crypto_provider(); let native = rustls_native_certs::load_native_certs(); - if native.certs.is_empty() { + let roots = trust_roots(native.certs, &intermediate_store_roots()); + if roots.is_empty() { return Err(format!( - "no native root CA certificates found (errors: {:?})", + "no root CA certificates found (errors: {:?})", native.errors )); } - let mut roots = rustls::RootCertStore::empty(); - roots.add_parsable_certificates(native.certs); Ok(Arc::new( rustls::ClientConfig::builder() .with_root_certificates(roots) @@ -654,6 +653,118 @@ fn build_tls_client_config() -> std::result::Result, S )) } +fn trust_roots( + native: Vec>, + extra: &[rustls::pki_types::CertificateDer<'static>], +) -> rustls::RootCertStore { + let mut roots = rustls::RootCertStore::empty(); + roots.add_parsable_certificates(native); + roots.add_parsable_certificates(extra.iter().cloned()); + roots +} + +#[cfg(any(windows, test))] +fn der_element(input: &[u8]) -> Option<(u8, &[u8], &[u8])> { + let (&tag, rest) = input.split_first()?; + let (&first, rest) = rest.split_first()?; + let (length, rest) = if first & 0x80 == 0 { + (usize::from(first), rest) + } else { + let size = usize::from(first & 0x7f); + if size == 0 || size > 4 || rest.len() < size { + return None; + } + let (bytes, rest) = rest.split_at(size); + ( + bytes + .iter() + .fold(0usize, |length, &byte| (length << 8) | usize::from(byte)), + rest, + ) + }; + if rest.len() < length { + return None; + } + let (content, rest) = rest.split_at(length); + Some((tag, content, rest)) +} + +#[cfg(any(windows, test))] +fn is_self_issued(certificate: &[u8]) -> bool { + let Some((0x30, certificate, [])) = der_element(certificate) else { + return false; + }; + let Some((0x30, tbs, _)) = der_element(certificate) else { + return false; + }; + let mut fields = std::iter::successors(der_element(tbs), |&(_, _, rest)| der_element(rest)) + .map(|(tag, content, _)| (tag, content)); + let mut serial = fields.next(); + if matches!(serial, Some((0xa0, _))) { + serial = fields.next(); + } + let ( + Some((0x02, _)), + Some((0x30, _)), + Some((0x30, issuer)), + Some((0x30, _)), + Some((0x30, subject)), + ) = ( + serial, + fields.next(), + fields.next(), + fields.next(), + fields.next(), + ) + else { + return false; + }; + issuer == subject +} + +#[cfg(windows)] +fn intermediate_store_roots() -> Vec> { + use schannel::{cert_context::ValidUses, cert_store::CertStore}; + use std::collections::BTreeSet; + + const SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; + const ANY_EXTENDED_KEY_USAGE: &str = "2.5.29.37.0"; + let store_certificates = |name: &str| { + [ + CertStore::open_current_user(name), + CertStore::open_local_machine(name), + ] + .into_iter() + .flatten() + .flat_map(|store| store.certs().collect::>()) + }; + let disallowed: BTreeSet> = store_certificates("Disallowed") + .map(|certificate| certificate.to_der().to_vec()) + .collect(); + store_certificates("CA") + .filter(|certificate| { + certificate.is_time_valid().unwrap_or(false) + && match certificate.valid_uses() { + Ok(ValidUses::All) => true, + Ok(ValidUses::Oids(oids)) => oids + .iter() + .any(|oid| oid == SERVER_AUTH || oid == ANY_EXTENDED_KEY_USAGE), + Err(_) => false, + } + }) + .map(|certificate| certificate.to_der().to_vec()) + .filter(|der| is_self_issued(der) && !disallowed.contains(der)) + .collect::>() + .into_iter() + .map(rustls::pki_types::CertificateDer::from) + .collect() +} + +#[cfg(not(windows))] +fn intermediate_store_roots() -> Vec> { + Vec::new() +} + fn tls_client_config() -> std::result::Result, String> { TLS_CLIENT_CONFIG .get_or_init(build_tls_client_config) @@ -1045,6 +1156,14 @@ impl EventDelivery for NapiEventDelivery { } } +#[napi(js_name = "windowsIntermediateStoreRoots")] +pub fn windows_intermediate_store_roots() -> Vec { + intermediate_store_roots() + .into_iter() + .map(|certificate| Buffer::from(certificate.to_vec())) + .collect() +} + #[napi] pub fn warmup() -> Result<()> { let runtime = transport_runtime()?; @@ -1225,6 +1344,122 @@ mod tests { assert_eq!(event.kind, EVENT_KIND_OPEN, "{:?}", event.message); } + const TLS_FIXTURE_HOST: &str = "ca.example"; + const TLS_FIXTURE_LEAF: &[u8] = + include_bytes!("../../../src/main/fixtures/desktop-tls/leaf.pem"); + const TLS_FIXTURE_LEAF_KEY: &[u8] = + include_bytes!("../../../src/main/fixtures/desktop-tls/leaf.key"); + const TLS_FIXTURE_TRUSTED_CA: &[u8] = + include_bytes!("../../../src/main/fixtures/desktop-tls/trusted-ca.pem"); + const TLS_FIXTURE_UNTRUSTED_CA: &[u8] = + include_bytes!("../../../src/main/fixtures/desktop-tls/untrusted-ca.pem"); + + fn fixture_certificate(pem: &[u8]) -> rustls::pki_types::CertificateDer<'static> { + use rustls::pki_types::pem::PemObject; + rustls::pki_types::CertificateDer::from_pem_slice(pem).expect("fixture certificate") + } + + fn fixture_server_config() -> Arc { + use rustls::pki_types::pem::PemObject; + ensure_rustls_crypto_provider(); + Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![fixture_certificate(TLS_FIXTURE_LEAF)], + rustls::pki_types::PrivateKeyDer::from_pem_slice(TLS_FIXTURE_LEAF_KEY) + .expect("fixture key"), + ) + .expect("fixture server config"), + ) + } + + fn pump( + from: &mut rustls::Connection, + to: &mut rustls::Connection, + ) -> std::result::Result<(), rustls::Error> { + let mut bytes = Vec::new(); + while from.wants_write() { + from.write_tls(&mut bytes).expect("write tls"); + } + let mut cursor = bytes.as_slice(); + while !cursor.is_empty() { + to.read_tls(&mut cursor).expect("read tls"); + to.process_new_packets()?; + } + Ok(()) + } + + fn handshake(roots: rustls::RootCertStore) -> std::result::Result<(), rustls::Error> { + ensure_rustls_crypto_provider(); + let client_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::Connection::Client( + rustls::ClientConnection::new( + Arc::new(client_config), + rustls::pki_types::ServerName::try_from(TLS_FIXTURE_HOST).expect("server name"), + ) + .expect("client connection"), + ); + let mut server = rustls::Connection::Server( + rustls::ServerConnection::new(fixture_server_config()).expect("server connection"), + ); + while client.is_handshaking() || server.is_handshaking() { + pump(&mut client, &mut server).expect("server accepts the client flight"); + pump(&mut server, &mut client)?; + } + Ok(()) + } + + #[test] + fn trust_roots_add_extra_anchors_to_the_native_store() { + let unknown_issuer = Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::UnknownIssuer, + )); + assert_eq!(handshake(trust_roots(Vec::new(), &[])), unknown_issuer); + assert_eq!( + handshake(trust_roots( + vec![fixture_certificate(TLS_FIXTURE_UNTRUSTED_CA)], + &[] + )), + unknown_issuer + ); + assert_eq!( + handshake(trust_roots( + vec![fixture_certificate(TLS_FIXTURE_UNTRUSTED_CA)], + &[fixture_certificate(TLS_FIXTURE_TRUSTED_CA)] + )), + Ok(()) + ); + assert_eq!( + handshake(trust_roots( + vec![fixture_certificate(TLS_FIXTURE_TRUSTED_CA)], + &[] + )), + Ok(()) + ); + } + + #[test] + fn only_self_issued_certificates_count_as_intermediate_store_roots() { + assert!(is_self_issued(&fixture_certificate(TLS_FIXTURE_TRUSTED_CA))); + assert!(is_self_issued(&fixture_certificate( + TLS_FIXTURE_UNTRUSTED_CA + ))); + let leaf = fixture_certificate(TLS_FIXTURE_LEAF); + assert!(!is_self_issued(&leaf)); + let root = fixture_certificate(TLS_FIXTURE_TRUSTED_CA); + assert!(!is_self_issued(&root[..root.len() - 1])); + let mut trailing = root.to_vec(); + trailing.push(0); + assert!(!is_self_issued(&trailing)); + assert!(!is_self_issued(&[])); + assert!(!is_self_issued(&[0x30, 0x84, 0xff, 0xff, 0xff, 0xff])); + #[cfg(not(windows))] + assert!(windows_intermediate_store_roots().is_empty()); + } + #[test] fn connection_targets_reject_unusable_urls() { for url in [ diff --git a/fluxer_desktop/src/main/DesktopSystemTrustVerifier.test.mjs b/fluxer_desktop/src/main/DesktopSystemTrustVerifier.test.mjs index 930c3f5e9..9674e0d11 100644 --- a/fluxer_desktop/src/main/DesktopSystemTrustVerifier.test.mjs +++ b/fluxer_desktop/src/main/DesktopSystemTrustVerifier.test.mjs @@ -66,6 +66,17 @@ describe('desktop system trust verifier', () => { assert.equal(buildDesktopTrustAnchors([read('kz_root')]).size, 0); }); + test('anchors on DER roots read from the Windows intermediate store', () => { + const der = (name) => new X509Certificate(read(name)).raw; + const store = buildDesktopTrustAnchors([der('root')]); + const request = {hostname: 'host.example', certificate: chain(['leaf', 'int']), errorCode: AUTHORITY_INVALID}; + assert.equal( + verifyAgainstDesktopSystemTrust(request, () => store), + ACCEPT, + ); + assert.equal(buildDesktopTrustAnchors([der('kz_root'), Buffer.from('not a certificate')]).size, 0); + }); + test('anchors only on admin added CAs that the distro bundle still trusts', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-trust-')); const write = (file, content) => { diff --git a/fluxer_desktop/src/main/DesktopSystemTrustVerifier.ts b/fluxer_desktop/src/main/DesktopSystemTrustVerifier.ts index c30503a78..23755071d 100644 --- a/fluxer_desktop/src/main/DesktopSystemTrustVerifier.ts +++ b/fluxer_desktop/src/main/DesktopSystemTrustVerifier.ts @@ -5,6 +5,7 @@ import net from 'node:net'; import {createChildLogger} from '@electron/common/Logger'; import {CHROMIUM_SPKI_BLOCKLIST, CHROMIUM_SPKI_BLOCKLIST_SOURCE} from '@electron/main/DesktopChromiumSPKIBlocklist'; import {resolveDesktopLocallyAddedCertificates} from '@electron/main/DesktopTrustedCertificates'; +import {readWindowsIntermediateStoreRoots} from '@electron/main/GatewaySocketNativeBoundary'; import type {Certificate, Session} from 'electron'; const logger = createChildLogger('DesktopSystemTrustVerifier'); @@ -398,14 +399,14 @@ function chainsToDesktopTrustAnchor( } export function buildDesktopTrustAnchors( - certificates: ReadonlyArray, + certificates: ReadonlyArray, now: number = Date.now(), ): DesktopTrustAnchors { const anchors = new Map>(); - for (const pem of certificates) { + for (const certificate of certificates) { let anchor: ParsedCertificate; try { - anchor = parseCertificate(new X509Certificate(pem)); + anchor = parseCertificate(new X509Certificate(certificate)); } catch { continue; } @@ -434,12 +435,23 @@ export function verifyAgainstDesktopSystemTrust( return CHROMIUM_DEFAULT_RESULT; } +function resolvePlatformAnchorCertificates(): ReadonlyArray { + switch (process.platform) { + case 'linux': + return resolveDesktopLocallyAddedCertificates(); + case 'win32': + return readWindowsIntermediateStoreRoots(); + default: + return []; + } +} + export function installDesktopSystemTrustVerifier(target: Session): void { - if (process.platform !== 'linux') return; - const anchors = buildDesktopTrustAnchors(resolveDesktopLocallyAddedCertificates()); + const anchors = buildDesktopTrustAnchors(resolvePlatformAnchorCertificates()); if (anchors.size === 0) return; logger.info('Trusting locally added CAs for Chromium requests', { count: anchors.size, + platform: process.platform, blocklist: CHROMIUM_SPKI_BLOCKLIST_SOURCE, }); target.setCertificateVerifyProc((request, callback) => { diff --git a/fluxer_desktop/src/main/GatewaySocketNativeBoundary.ts b/fluxer_desktop/src/main/GatewaySocketNativeBoundary.ts index 4f089238b..40bc52ff0 100644 --- a/fluxer_desktop/src/main/GatewaySocketNativeBoundary.ts +++ b/fluxer_desktop/src/main/GatewaySocketNativeBoundary.ts @@ -27,6 +27,7 @@ export interface GatewaySocketBoundary { type GatewaySocketNativeModule = { connect: GatewaySocketConnectBinding | null; warmup?: (() => void) | null; + windowsIntermediateStoreRoots?: (() => Array) | null; loadError: Error | null; }; @@ -34,6 +35,7 @@ const requireModule = createRequire(import.meta.url); let bindingCache: GatewaySocketConnectBinding | null | undefined; let warmupCache: (() => void) | null = null; +let windowsIntermediateStoreRootsCache: (() => Array) | null = null; export function loadGatewaySocketBinding(): GatewaySocketConnectBinding | null { if (bindingCache !== undefined) return bindingCache; @@ -47,6 +49,8 @@ export function loadGatewaySocketBinding(): GatewaySocketConnectBinding | null { const nativeModule = requireModule(GATEWAY_SOCKET_ADDON_PACKAGE) as GatewaySocketNativeModule; bindingCache = nativeModule.connect ?? null; warmupCache = bindingCache === null ? null : (nativeModule.warmup ?? null); + windowsIntermediateStoreRootsCache = + bindingCache === null ? null : (nativeModule.windowsIntermediateStoreRoots ?? null); } catch (error) { log.warn('[NativeGateway] The native gateway addon is unavailable, using the browser transport', error); bindingCache = null; @@ -63,6 +67,16 @@ export function warmGatewaySocketTransport(): void { } } +export function readWindowsIntermediateStoreRoots(): ReadonlyArray { + if (loadGatewaySocketBinding() === null || windowsIntermediateStoreRootsCache === null) return []; + try { + return windowsIntermediateStoreRootsCache(); + } catch (error) { + log.warn('[NativeGateway] Failed to read the Windows intermediate certificate store', error); + return []; + } +} + export function isNativeGatewayAvailable(): boolean { return loadGatewaySocketBinding() !== null; }