diff --git a/schemas/browserpicker-settings.schema.json b/schemas/browserpicker-settings.schema.json index 37f409d..050c137 100644 --- a/schemas/browserpicker-settings.schema.json +++ b/schemas/browserpicker-settings.schema.json @@ -46,6 +46,15 @@ "FaviconsForDefaults": { "type": "boolean" }, + "CheckCertificateRecords": { + "type": "boolean" + }, + "HideManualConnectionCheck": { + "type": "boolean" + }, + "SkipConnectionCheckConfirmation": { + "type": "boolean" + }, "AutoCloseOnFocusLost": { "type": "boolean" }, diff --git a/src/BrowserPicker.Common/BrowserPicker.Common.csproj b/src/BrowserPicker.Common/BrowserPicker.Common.csproj index 8b97f42..afdb468 100644 --- a/src/BrowserPicker.Common/BrowserPicker.Common.csproj +++ b/src/BrowserPicker.Common/BrowserPicker.Common.csproj @@ -1,5 +1,6 @@  + diff --git a/src/BrowserPicker.Common/ISecuritySettings.cs b/src/BrowserPicker.Common/ISecuritySettings.cs index df4d979..d162527 100644 --- a/src/BrowserPicker.Common/ISecuritySettings.cs +++ b/src/BrowserPicker.Common/ISecuritySettings.cs @@ -24,4 +24,19 @@ public interface ISecuritySettings /// When true, only probes favicons for URLs matching a Defaults rule. /// bool FaviconsForDefaults { get; set; } + + /// + /// When true, explicit certificate checks also inspect DNS CAA records and certificate transparency evidence. + /// + bool CheckCertificateRecords { get; set; } + + /// + /// When true, hides the manual connection check action from the picker. + /// + bool HideManualConnectionCheck { get; set; } + + /// + /// When true, manual connection checks start immediately without showing the confirmation prompt. + /// + bool SkipConnectionCheckConfirmation { get; set; } } diff --git a/src/BrowserPicker.Common/SecurityOptions.cs b/src/BrowserPicker.Common/SecurityOptions.cs index 5d1c8c1..c9ad7e3 100644 --- a/src/BrowserPicker.Common/SecurityOptions.cs +++ b/src/BrowserPicker.Common/SecurityOptions.cs @@ -6,6 +6,9 @@ public sealed record SecurityOptions public bool RedirectsKnownOnly { get; set; } public bool ProbeFavicons { get; set; } public bool FaviconsForDefaults { get; set; } + public bool CheckCertificateRecords { get; set; } + public bool HideManualConnectionCheck { get; set; } + public bool SkipConnectionCheckConfirmation { get; set; } public static SecurityOptions Default => new() @@ -14,6 +17,9 @@ public sealed record SecurityOptions RedirectsKnownOnly = true, ProbeFavicons = true, FaviconsForDefaults = true, + CheckCertificateRecords = true, + HideManualConnectionCheck = false, + SkipConnectionCheckConfirmation = false, }; public static SecurityOptions MaxPrivacy => @@ -23,6 +29,9 @@ public sealed record SecurityOptions RedirectsKnownOnly = true, ProbeFavicons = false, FaviconsForDefaults = true, + CheckCertificateRecords = false, + HideManualConnectionCheck = true, + SkipConnectionCheckConfirmation = false, }; public static SecurityOptions EnableAll => @@ -32,6 +41,9 @@ public sealed record SecurityOptions RedirectsKnownOnly = false, ProbeFavicons = true, FaviconsForDefaults = false, + CheckCertificateRecords = true, + HideManualConnectionCheck = false, + SkipConnectionCheckConfirmation = true, }; } @@ -45,6 +57,9 @@ public static SecurityOptions GetSecurityOptions(this ISecuritySettings settings RedirectsKnownOnly = settings.RedirectsKnownOnly, ProbeFavicons = settings.ProbeFavicons, FaviconsForDefaults = settings.FaviconsForDefaults, + CheckCertificateRecords = settings.CheckCertificateRecords, + HideManualConnectionCheck = settings.HideManualConnectionCheck, + SkipConnectionCheckConfirmation = settings.SkipConnectionCheckConfirmation, }; } @@ -54,5 +69,8 @@ public static void ApplySecurityOptions(this ISecuritySettings settings, Securit settings.RedirectsKnownOnly = options.RedirectsKnownOnly; settings.ProbeFavicons = options.ProbeFavicons; settings.FaviconsForDefaults = options.FaviconsForDefaults; + settings.CheckCertificateRecords = options.CheckCertificateRecords; + settings.HideManualConnectionCheck = options.HideManualConnectionCheck; + settings.SkipConnectionCheckConfirmation = options.SkipConnectionCheckConfirmation; } } diff --git a/src/BrowserPicker.Common/SerializableSettings.cs b/src/BrowserPicker.Common/SerializableSettings.cs index 5c9ef4e..becf572 100644 --- a/src/BrowserPicker.Common/SerializableSettings.cs +++ b/src/BrowserPicker.Common/SerializableSettings.cs @@ -32,6 +32,9 @@ public SerializableSettings(IApplicationSettings applicationSettings) RedirectsKnownOnly = applicationSettings.RedirectsKnownOnly; ProbeFavicons = applicationSettings.ProbeFavicons; FaviconsForDefaults = applicationSettings.FaviconsForDefaults; + CheckCertificateRecords = applicationSettings.CheckCertificateRecords; + HideManualConnectionCheck = applicationSettings.HideManualConnectionCheck; + SkipConnectionCheckConfirmation = applicationSettings.SkipConnectionCheckConfirmation; UrlShorteners = applicationSettings.UrlShorteners; BrowserList = [.. applicationSettings.BrowserList.Where(b => !b.Removed)]; Defaults = [.. applicationSettings.Defaults.Where(d => !d.Deleted && !string.IsNullOrWhiteSpace(d.Browser))]; @@ -97,6 +100,15 @@ public SerializableSettings() { } /// public bool FaviconsForDefaults { get; set; } = true; + /// + public bool CheckCertificateRecords { get; set; } = true; + + /// + public bool HideManualConnectionCheck { get; set; } + + /// + public bool SkipConnectionCheckConfirmation { get; set; } + /// /// When true, the picker closes itself when it loses focus. Defaults to being enabled. /// diff --git a/src/BrowserPicker.Common/TlsCertificateSummary.cs b/src/BrowserPicker.Common/TlsCertificateSummary.cs new file mode 100644 index 0000000..4a98795 --- /dev/null +++ b/src/BrowserPicker.Common/TlsCertificateSummary.cs @@ -0,0 +1,597 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Formats.Asn1; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DnsClient; + +namespace BrowserPicker.Common; + +public sealed record TlsCertificateSummary( + Uri Uri, + string Host, + int Port, + string Subject, + string Issuer, + DateTimeOffset ValidFrom, + DateTimeOffset Expires, + string? CommonName, + IReadOnlyList SubjectAlternativeNames, + bool HostNameMatchesCertificate, + SslPolicyErrors PolicyErrors, + IReadOnlyList ChainStatus, + SslProtocols Protocol, + TlsCipherSuite? CipherSuite, + bool CertificateRecordsChecked, + IReadOnlyList CaaRecords, + string CertificateTransparencyStatus +) +{ + private const int DefaultHttpsPort = 443; + private const int DefaultTimeoutMilliseconds = 5000; + private const string EmbeddedSctOid = "1.3.6.1.4.1.11129.2.4.2"; + + public string ValidationText => FormatValidation(); + + public string HostMatchText => $"{(HostNameMatchesCertificate ? "Yes" : "No")} ({Host})"; + + public string ValidFromText => $"{ValidFrom.LocalDateTime:g}"; + + public string ExpiresText => $"{Expires.LocalDateTime:g}"; + + public string? SubjectAlternativeNamesText => + SubjectAlternativeNames.Count == 0 ? null : FormatNames(SubjectAlternativeNames); + + public string? ProtocolText => Protocol == SslProtocols.None ? null : Protocol.ToString(); + + public string? CipherStrengthText => CipherSuite == null ? null : FormatCipherStrength(); + + public string? CipherSuiteText => CipherSuite?.ToString(); + + public string ChainText => FormatChainStatus(); + + public string CertificateAuthorityAuthorizationText => + CertificateRecordsChecked ? FormatCaaStatus() : "Not checked"; + + public string CertificateTransparencyText => + CertificateRecordsChecked ? CertificateTransparencyStatus : "Not checked"; + + public static bool TryCreateTarget(string? targetUrl, out Uri uri, out string errorMessage) + { + if (string.IsNullOrWhiteSpace(targetUrl)) + { + uri = null!; + errorMessage = "No URL is available to check."; + return false; + } + + if (!Uri.TryCreate(targetUrl, UriKind.Absolute, out uri!)) + { + errorMessage = "This is not an absolute URL, so there is no TLS host to check."; + return false; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + errorMessage = + $"TLS certificate checks are only available for HTTPS URLs. This URL uses {uri.Scheme.ToUpperInvariant()}."; + return false; + } + + if (string.IsNullOrWhiteSpace(uri.Host)) + { + errorMessage = "This URL does not include a host name to check."; + return false; + } + + errorMessage = string.Empty; + return true; + } + + public static Task InspectAsync(Uri uri, CancellationToken cancellationToken) => + InspectAsync(uri, includeCertificateRecords: true, cancellationToken); + + public static Task InspectAsync( + Uri uri, + bool includeCertificateRecords, + CancellationToken cancellationToken + ) => + InspectAsync( + uri, + includeCertificateRecords, + TimeSpan.FromMilliseconds(DefaultTimeoutMilliseconds), + cancellationToken + ); + + public static async Task InspectAsync( + Uri uri, + bool includeCertificateRecords, + TimeSpan timeout, + CancellationToken cancellationToken + ) + { + if (!TryCreateTarget(uri.ToString(), out var targetUri, out var errorMessage)) + { + throw new ArgumentException(errorMessage, nameof(uri)); + } + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + using var client = new TcpClient(); + await client.ConnectAsync(targetUri.Host, GetPort(targetUri), timeoutSource.Token).ConfigureAwait(false); + + await using var networkStream = client.GetStream(); + X509Certificate2? certificate = null; + var policyErrors = SslPolicyErrors.None; + var chainStatus = Array.Empty(); + + using var sslStream = new SslStream( + networkStream, + leaveInnerStreamOpen: false, + (_, remoteCertificate, chain, errors) => + { + policyErrors = errors; + certificate = remoteCertificate == null ? null : new X509Certificate2(remoteCertificate); + chainStatus = + [ + .. chain?.ChainStatus.Select(status => + string.IsNullOrWhiteSpace(status.StatusInformation) + ? status.Status.ToString() + : $"{status.Status}: {status.StatusInformation.Trim()}" + ) + ?? [], + ]; + return true; + } + ); + + await sslStream + .AuthenticateAsClientAsync( + new SslClientAuthenticationOptions + { + TargetHost = targetUri.Host, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + EnabledSslProtocols = SslProtocols.None, + }, + timeoutSource.Token + ) + .ConfigureAwait(false); + + certificate ??= sslStream.RemoteCertificate == null ? null : new X509Certificate2(sslStream.RemoteCertificate); + if (certificate == null) + { + throw new InvalidOperationException("The TLS handshake completed without a certificate."); + } + + return FromCertificate( + targetUri, + certificate, + policyErrors, + chainStatus, + sslStream.SslProtocol, + sslStream.NegotiatedCipherSuite, + includeCertificateRecords, + includeCertificateRecords + ? await GetCaaRecordsAsync(targetUri.Host, timeoutSource.Token).ConfigureAwait(false) + : [], + includeCertificateRecords ? GetCertificateTransparencyStatus(certificate) : "Not checked" + ); + } + + public static TlsCertificateSummary FromCertificate( + Uri uri, + X509Certificate2 certificate, + SslPolicyErrors policyErrors, + IReadOnlyList chainStatus, + SslProtocols protocol = SslProtocols.None, + TlsCipherSuite? cipherSuite = null, + bool certificateRecordsChecked = true, + IReadOnlyList? caaRecords = null, + string? certificateTransparencyStatus = null + ) + { + var names = GetSubjectAlternativeDnsNames(certificate); + var commonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + var matchNames = names.Count > 0 ? names : [commonName]; + + return new TlsCertificateSummary( + uri, + uri.Host, + GetPort(uri), + certificate.Subject, + certificate.Issuer, + new DateTimeOffset(certificate.NotBefore), + new DateTimeOffset(certificate.NotAfter), + string.IsNullOrWhiteSpace(commonName) ? null : commonName, + names, + matchNames.Any(name => CertificateNameMatchesHost(name, uri.Host)), + policyErrors, + chainStatus, + protocol, + cipherSuite, + certificateRecordsChecked, + caaRecords ?? [], + certificateRecordsChecked + ? certificateTransparencyStatus ?? GetCertificateTransparencyStatus(certificate) + : certificateTransparencyStatus ?? "Not checked" + ); + } + + public string ToDisplayText() + { + var builder = new StringBuilder() + .AppendLine($"Connection check for {Uri}") + .AppendLine() + .AppendLine( + CertificateRecordsChecked + ? $"Privacy: this check contacted the target host ({Host}:{Port}) and may have queried DNS for CAA records. The website, DNS resolver, and network proxies may see this activity." + : $"Privacy: this check contacted only the target host ({Host}:{Port}). The website and network proxies may see the connection." + ) + .AppendLine() + .AppendLine($"Validation: {FormatValidation()}") + .AppendLine($"Host match: {(HostNameMatchesCertificate ? "Yes" : "No")} ({Host})") + .AppendLine($"Subject: {Subject}") + .AppendLine($"Issuer: {Issuer}") + .AppendLine($"Valid from: {ValidFrom.LocalDateTime:g}") + .AppendLine($"Expires: {Expires.LocalDateTime:g}"); + + if (!string.IsNullOrWhiteSpace(CommonName)) + { + builder.AppendLine($"Common name: {CommonName}"); + } + + if (SubjectAlternativeNames.Count > 0) + { + builder.AppendLine($"SAN DNS names: {FormatNames(SubjectAlternativeNames)}"); + } + + if (Protocol != SslProtocols.None) + { + builder.AppendLine($"Protocol: {Protocol}"); + } + + if (CipherSuite != null) + { + builder.AppendLine($"Cipher strength: {FormatCipherStrength()}"); + builder.AppendLine($"Cipher suite: {CipherSuite}"); + } + + builder.AppendLine($"Chain: {FormatChainStatus()}"); + if (CertificateRecordsChecked) + { + builder.AppendLine($"CAA: {FormatCaaStatus()}"); + builder.AppendLine($"Certificate transparency: {CertificateTransparencyStatus}"); + } + else + { + builder.AppendLine("CAA / certificate transparency: Not checked"); + } + return builder.ToString().TrimEnd(); + } + + private string FormatValidation() + { + return PolicyErrors == SslPolicyErrors.None ? "Valid" : $"Problems found ({PolicyErrors})"; + } + + private string FormatChainStatus() + { + return ChainStatus.Count == 0 ? "OK" : string.Join("; ", ChainStatus); + } + + private string FormatCaaStatus() + { + if (CaaRecords.Count == 0) + { + return "None"; + } + + return CaaRecords.Any(record => CaaRecordMatchesIssuer(record, Issuer)) ? "Aligned" : "Unaligned"; + } + + private string FormatCipherStrength() + { + var protocolValue = (int)Protocol; + if (protocolValue is 12 or 48 or 192 or 768) + { + return "Insecure. This connection used an obsolete TLS protocol."; + } + + if (CipherSuite == null) + { + return "Unknown. BrowserPicker could not read the negotiated cipher suite."; + } + + var suite = CipherSuite.Value.ToString().ToUpperInvariant(); + if (ContainsAny(suite, "NULL", "EXPORT", "RC4", "3DES", "DES", "_CBC_", "_MD5")) + { + return "Weak. The negotiated cipher uses older cryptography."; + } + + if (ContainsAny(suite, "MLKEM", "KYBER", "HYBRID")) + { + return "Strong. Modern encryption with a post-quantum key exchange signal."; + } + + if (Protocol == SslProtocols.Tls13 || ContainsAny(suite, "_GCM_", "CHACHA20_POLY1305")) + { + return "Strong classical (not post-quantum). Modern TLS encryption; no post-quantum key exchange was reported."; + } + + return "Not post-quantum. No obvious weak cipher was reported, but this is not a modern post-quantum TLS signal."; + } + + private static bool ContainsAny(string value, params string[] tokens) + { + return tokens.Any(token => value.Contains(token, StringComparison.Ordinal)); + } + + private static int GetPort(Uri uri) + { + return uri.IsDefaultPort ? DefaultHttpsPort : uri.Port; + } + + private static string FormatNames(IReadOnlyList names) + { + const int maxNames = 12; + if (names.Count <= maxNames) + { + return string.Join(", ", names); + } + + return $"{string.Join(", ", names.Take(maxNames))}, and {names.Count - maxNames} more"; + } + + private static bool CertificateNameMatchesHost(string certificateName, string host) + { + if (string.IsNullOrWhiteSpace(certificateName) || string.IsNullOrWhiteSpace(host)) + { + return false; + } + + var normalizedName = certificateName.TrimEnd('.'); + var normalizedHost = host.TrimEnd('.'); + if (!normalizedName.StartsWith("*.", StringComparison.Ordinal)) + { + return string.Equals(normalizedName, normalizedHost, StringComparison.OrdinalIgnoreCase); + } + + var suffix = normalizedName[1..]; + return normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + && normalizedHost.Length > suffix.Length + && normalizedHost[..^suffix.Length].Count(c => c == '.') == 0; + } + + private static bool CaaRecordMatchesIssuer(string record, string issuer) + { + if (!TryGetCaaIssuer(record, out var caaIssuer)) + { + return false; + } + + var normalizedIssuer = NormalizeCertificateAuthorityName(issuer); + var normalizedCaaIssuer = NormalizeCertificateAuthorityName(caaIssuer); + return normalizedCaaIssuer.Length > 0 + && normalizedIssuer.Contains(normalizedCaaIssuer, StringComparison.Ordinal); + } + + private static bool TryGetCaaIssuer(string record, out string issuer) + { + issuer = string.Empty; + var fields = record.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 3 || !fields[1].StartsWith("issue", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + issuer = fields[2].Trim().Trim('"'); + var parameterStart = issuer.IndexOf(';', StringComparison.Ordinal); + if (parameterStart >= 0) + { + issuer = issuer[..parameterStart]; + } + + return !string.IsNullOrWhiteSpace(issuer); + } + + private static string NormalizeCertificateAuthorityName(string value) + { + var hostLabels = value.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (hostLabels.Length > 1) + { + value = hostLabels[^2]; + } + + return new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + } + + private static IReadOnlyList GetSubjectAlternativeDnsNames(X509Certificate2 certificate) + { + foreach (var extension in certificate.Extensions) + { + if (extension.Oid?.Value == "2.5.29.17") + { + return ReadSubjectAlternativeDnsNames(extension.RawData); + } + } + + return []; + } + + private static async Task> GetCaaRecordsAsync( + string host, + CancellationToken cancellationToken + ) + { + try + { + var client = new LookupClient(); + foreach (var name in EnumerateCaaLookupNames(host)) + { + var result = await client + .QueryAsync(name, QueryType.CAA, QueryClass.IN, cancellationToken) + .ConfigureAwait(false); + var records = result.Answers.CaaRecords().ToArray(); + if (records.Length > 0) + { + return + [ + .. records.Select(record => + string.IsNullOrWhiteSpace(record.Value) + ? $"{record.Flags} {record.Tag}" + : $"{record.Flags} {record.Tag} \"{record.Value}\"" + ), + ]; + } + } + + return []; + } + catch (DnsResponseException ex) + { + return [$"Lookup failed: {ex.Message}"]; + } + catch (SocketException ex) + { + return [$"Lookup failed: {ex.Message}"]; + } + } + + private static IEnumerable EnumerateCaaLookupNames(string host) + { + var labels = host.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries); + for (var start = 0; start < labels.Length - 1; start++) + { + yield return string.Join('.', labels.Skip(start)); + } + } + + private static string GetCertificateTransparencyStatus(X509Certificate2 certificate) + { + var extension = certificate.Extensions.FirstOrDefault(extension => extension.Oid?.Value == EmbeddedSctOid); + if (extension == null) + { + return "No embedded SCT extension found"; + } + + var scts = ReadEmbeddedSignedCertificateTimestamps(extension.RawData); + if (scts.Count == 0) + { + return "Inconclusive. The certificate has a transparency extension, but BrowserPicker could not read its timestamps."; + } + + var newest = scts.Max(sct => sct.Timestamp); + return $"Looks normal. The certificate includes {scts.Count} transparency timestamp{Pluralize(scts.Count)}, newest {newest:yyyy-MM-dd}."; + } + + private static IReadOnlyList ReadEmbeddedSignedCertificateTimestamps(byte[] rawData) + { + var data = UnwrapSctExtension(rawData); + if (data.Length < 2) + { + return []; + } + + var listLength = BinaryPrimitives.ReadUInt16BigEndian(data[..2]); + var end = Math.Min(data.Length, listLength + 2); + var offset = 2; + var scts = new List(); + while (offset + 2 <= end) + { + var sctLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + offset += 2; + if (offset + sctLength > end) + { + break; + } + + if (TryReadSignedCertificateTimestamp(data.Slice(offset, sctLength), out var sct)) + { + scts.Add(sct); + } + offset += sctLength; + } + + return scts; + } + + private static ReadOnlySpan UnwrapSctExtension(byte[] rawData) + { + try + { + var reader = new AsnReader(rawData, AsnEncodingRules.DER); + var octets = reader.ReadOctetString(); + return octets; + } + catch (AsnContentException) + { + return rawData; + } + } + + private static bool TryReadSignedCertificateTimestamp(ReadOnlySpan data, out SignedCertificateTimestamp sct) + { + sct = default; + const int minimumLength = 1 + 32 + 8 + 2 + 2 + 2; + if (data.Length < minimumLength) + { + return false; + } + + var offset = 0; + offset++; + offset += 32; + var timestampMilliseconds = BinaryPrimitives.ReadUInt64BigEndian(data.Slice(offset, 8)); + offset += 8; + var extensionsLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + offset += 2 + extensionsLength; + if (offset + 4 > data.Length) + { + return false; + } + + offset += 2; + var signatureLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + offset += 2; + if (offset + signatureLength > data.Length) + { + return false; + } + + sct = new SignedCertificateTimestamp(DateTimeOffset.FromUnixTimeMilliseconds((long)timestampMilliseconds)); + return true; + } + + private static string Pluralize(int count) => count == 1 ? string.Empty : "s"; + + private readonly record struct SignedCertificateTimestamp(DateTimeOffset Timestamp); + + private static IReadOnlyList ReadSubjectAlternativeDnsNames(byte[] rawData) + { + var names = new List(); + var reader = new AsnReader(rawData, AsnEncodingRules.DER); + var sequence = reader.ReadSequence(); + var dnsNameTag = new Asn1Tag(TagClass.ContextSpecific, 2); + + while (sequence.HasData) + { + if (sequence.PeekTag().HasSameClassAndValue(dnsNameTag)) + { + names.Add(sequence.ReadCharacterString(UniversalTagNumber.IA5String, dnsNameTag)); + continue; + } + + sequence.ReadEncodedValue(); + } + + return names; + } +} diff --git a/src/BrowserPicker.UI/ViewModels/ApplicationViewModel.cs b/src/BrowserPicker.UI/ViewModels/ApplicationViewModel.cs index 765dd33..9dd22cc 100644 --- a/src/BrowserPicker.UI/ViewModels/ApplicationViewModel.cs +++ b/src/BrowserPicker.UI/ViewModels/ApplicationViewModel.cs @@ -376,6 +376,35 @@ public bool Initialize() /// public ICommand Edit => new DelegateCommand(OpenURLEditor); + /// + /// Runs an explicit TLS/certificate check for the current HTTPS URL. + /// + public ICommand CheckConnection => + check_connection ??= new DelegateCommand(OpenConnectionCheckWindow, CanCheckConnection); + + public ConnectionCheckIndicatorState ConnectionCheckState + { + get => connection_check_state; + private set + { + if (SetProperty(ref connection_check_state, value)) + { + OnPropertyChanged(nameof(ConnectionCheckToolTip)); + } + } + } + + public string ConnectionCheckToolTip => + ConnectionCheckState switch + { + ConnectionCheckIndicatorState.Good => "Connection check passed", + ConnectionCheckIndicatorState.Warning => "Connection check found warnings", + ConnectionCheckIndicatorState.Error => "Connection check found problems", + ConnectionCheckIndicatorState.Unresolved => "Connection check could not resolve the host", + _ => + "Check the TLS certificate. This contacts the target host and may be visible to the website or proxies.", + }; + /// /// Gets the view model responsible for managing application configuration settings. /// Provides access to user preferences and saved browser configurations. @@ -498,6 +527,48 @@ private void OpenURLEditor() if (editor.ShowDialog() == true) { Url.UnderlyingTargetURL = editor.EditedUrl; + ConnectionCheckState = ConnectionCheckIndicatorState.NotScanned; + check_connection?.RaiseCanExecuteChanged(); + } + } + + private bool CanCheckConnection() + { + return !Configuration.Settings.HideManualConnectionCheck + && !string.IsNullOrWhiteSpace(Url.UnderlyingTargetURL ?? Url.TargetURL); + } + + private void OpenConnectionCheckWindow() + { + ConnectionCheckViewModel viewModel; + if (Configuration.Settings.DisableNetworkAccess) + { + viewModel = new ConnectionCheckViewModel("Network checks are disabled in settings."); + } + else if ( + !TlsCertificateSummary.TryCreateTarget( + Url.UnderlyingTargetURL ?? Url.TargetURL, + out var uri, + out var errorMessage + ) + ) + { + viewModel = new ConnectionCheckViewModel(errorMessage); + } + else + { + viewModel = new ConnectionCheckViewModel( + uri, + Configuration.Settings.CheckCertificateRecords, + Configuration.Settings.SkipConnectionCheckConfirmation + ); + } + + var window = new ConnectionCheckWindow(viewModel) { Owner = Application.Current?.MainWindow }; + window.ShowDialog(); + if (viewModel.ResultState != ConnectionCheckIndicatorState.NotScanned) + { + ConnectionCheckState = viewModel.ResultState; } } @@ -655,6 +726,11 @@ or nameof(IApplicationSettings.Defaults) { RebuildPickerChoices(); } + + if (e.PropertyName == nameof(IApplicationSettings.HideManualConnectionCheck)) + { + check_connection?.RaiseCanExecuteChanged(); + } } private async void RefreshCurrentUrlFavicon() @@ -675,4 +751,6 @@ private async void RefreshCurrentUrlFavicon() private readonly bool force_choice; private bool pinned; private bool copied; + private ConnectionCheckIndicatorState connection_check_state; + private DelegateCommand? check_connection; } diff --git a/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs b/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs index 70d6a65..bec753c 100644 --- a/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs +++ b/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs @@ -144,6 +144,9 @@ public bool UseAlphabeticalOrdering public bool RedirectsKnownOnly { get; set; } = true; public bool ProbeFavicons { get; set; } = true; public bool FaviconsForDefaults { get; set; } = true; + public bool CheckCertificateRecords { get; set; } = true; + public bool HideManualConnectionCheck { get; set; } + public bool SkipConnectionCheckConfirmation { get; set; } public bool AutoSizeWindow { get; set; } = true; public double WindowWidth { get; set; } @@ -794,6 +797,9 @@ private void Configuration_PropertyChanged(object? sender, PropertyChangedEventA case nameof(IApplicationSettings.RedirectsKnownOnly): case nameof(IApplicationSettings.ProbeFavicons): case nameof(IApplicationSettings.FaviconsForDefaults): + case nameof(IApplicationSettings.CheckCertificateRecords): + case nameof(IApplicationSettings.HideManualConnectionCheck): + case nameof(IApplicationSettings.SkipConnectionCheckConfirmation): OnPropertyChanged(nameof(SelectedSecurityProfile)); apply_security_profile?.RaiseCanExecuteChanged(); break; diff --git a/src/BrowserPicker.UI/ViewModels/ConnectionCheckViewModel.cs b/src/BrowserPicker.UI/ViewModels/ConnectionCheckViewModel.cs new file mode 100644 index 0000000..6bee72d --- /dev/null +++ b/src/BrowserPicker.UI/ViewModels/ConnectionCheckViewModel.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using BrowserPicker.Common; +using BrowserPicker.Common.Framework; + +namespace BrowserPicker.UI.ViewModels; + +public enum ConnectionCheckIndicatorState +{ + NotScanned, + Good, + Warning, + Error, + Unresolved, +} + +public sealed class ConnectionCheckViewModel : ModelBase +{ + private readonly Uri? uri; + private readonly bool include_certificate_records; + private readonly bool skip_confirmation; + private CancellationTokenSource? cancellation; + private string status = "Ready"; + private string? reportText; + private ConnectionCheckIndicatorState result_state; + private bool has_summary; + private bool is_running; + private bool has_started; + private bool can_start; + +#if DEBUG + public ConnectionCheckViewModel() + : this( + new Uri("https://github.com/mortenn/BrowserPicker"), + includeCertificateRecords: true, + skipConfirmation: false + ) { } +#endif + + public ConnectionCheckViewModel(Uri uri, bool includeCertificateRecords, bool skipConfirmation) + { + this.uri = uri; + include_certificate_records = includeCertificateRecords; + skip_confirmation = skipConfirmation; + can_start = true; + ConfirmText = includeCertificateRecords + ? $"Checking the TLS certificate will contact the target host ({uri.Host}) and may query DNS for CAA records. The website, DNS resolver, and network proxies may see this activity." + : $"Checking the TLS certificate will contact only the target host ({uri.Host}). The website and network proxies may see this connection."; + } + + public ConnectionCheckViewModel(string unavailableReason) + { + ConfirmText = unavailableReason; + Status = "Cannot check this connection"; + ReportText = unavailableReason; + HasStarted = true; + can_start = false; + } + + public event EventHandler? CloseRequested; + + public string ConfirmText { get; } + + public bool SkipConfirmation => skip_confirmation; + + public ConnectionCheckIndicatorState ResultState + { + get => result_state; + private set => SetProperty(ref result_state, value); + } + + public ObservableCollection Details { get; } = []; + + public ObservableCollection Sections { get; } = []; + + public ConnectionCheckSectionViewModel? OverviewSection => + Sections.FirstOrDefault(section => section.Title == "Overview"); + + public IEnumerable CollapsibleSections => + Sections.Where(section => section.Title != "Overview"); + + public string Status + { + get => status; + private set => SetProperty(ref status, value); + } + + public string? ReportText + { + get => reportText; + private set + { + if (SetProperty(ref reportText, value)) + { + OnPropertyChanged(nameof(HasReportText)); + } + } + } + + public bool HasReportText => !string.IsNullOrWhiteSpace(ReportText); + + public bool HasSummary + { + get => has_summary; + private set + { + if (SetProperty(ref has_summary, value)) + { + OnPropertyChanged(nameof(OverviewSection)); + OnPropertyChanged(nameof(CollapsibleSections)); + } + } + } + + public bool IsRunning + { + get => is_running; + private set + { + if (SetProperty(ref is_running, value)) + { + start?.RaiseCanExecuteChanged(); + cancel?.RaiseCanExecuteChanged(); + close?.RaiseCanExecuteChanged(); + } + } + } + + public bool HasStarted + { + get => has_started; + private set + { + if (SetProperty(ref has_started, value)) + { + start?.RaiseCanExecuteChanged(); + } + } + } + + public ICommand Start => start ??= new DelegateCommand(() => _ = StartAsync(), () => can_start && !HasStarted); + + public ICommand Cancel => cancel ??= new DelegateCommand(CancelCheck, () => IsRunning); + + public ICommand Close => close ??= new DelegateCommand(RequestClose, () => !IsRunning); + + public void StartIfRequested() + { + if (SkipConfirmation) + { + _ = StartAsync(); + } + } + + private async Task StartAsync() + { + if (uri == null || IsRunning) + { + return; + } + + HasStarted = true; + IsRunning = true; + Status = "Checking connection..."; + ReportText = null; + HasSummary = false; + Details.Clear(); + Sections.Clear(); + cancellation = new CancellationTokenSource(); + + try + { + var summary = await TlsCertificateSummary.InspectAsync( + uri, + include_certificate_records, + cancellation.Token + ); + Status = "Connection check complete"; + SetSummary(summary); + } + catch (OperationCanceledException) + { + Status = "Connection check canceled"; + ReportText = "The connection check was canceled or timed out."; + ResultState = ConnectionCheckIndicatorState.NotScanned; + } + catch (SocketException ex) + { + Status = "Connection check failed"; + ReportText = $"The connection check failed: {ex.Message}"; + ResultState = IsDnsResolutionFailure(ex) + ? ConnectionCheckIndicatorState.Unresolved + : ConnectionCheckIndicatorState.Error; + } + catch (Exception ex) when (ex is AuthenticationException or IOException or InvalidOperationException) + { + Status = "Connection check failed"; + ReportText = $"The connection check failed: {ex.Message}"; + ResultState = ConnectionCheckIndicatorState.Error; + } + finally + { + cancellation?.Dispose(); + cancellation = null; + IsRunning = false; + } + } + + private void CancelCheck() + { + cancellation?.Cancel(); + } + + private void RequestClose() + { + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void SetSummary(TlsCertificateSummary summary) + { + Details.Clear(); + Sections.Clear(); + AddSection( + "Overview", + string.Empty, + [ + new ConnectionCheckDetailViewModel("Target", summary.Uri.ToString()), + new ConnectionCheckDetailViewModel("Validation", summary.ValidationText), + new ConnectionCheckDetailViewModel("Host match", summary.HostMatchText), + new ConnectionCheckDetailViewModel("Chain", summary.ChainText), + ] + ); + AddSection( + "Certificate", + FormatCertificateSummary(summary), + [ + new ConnectionCheckDetailViewModel("Issuer", summary.Issuer), + new ConnectionCheckDetailViewModel("Subject", summary.Subject), + new ConnectionCheckDetailViewModel("Common name", summary.CommonName), + new ConnectionCheckDetailViewModel("SAN DNS names", summary.SubjectAlternativeNamesText), + new ConnectionCheckDetailViewModel("Valid from", summary.ValidFromText), + new ConnectionCheckDetailViewModel("Expires", summary.ExpiresText), + ] + ); + AddSection( + "Encryption", + FormatCipherSummary(summary.CipherStrengthText), + [ + new ConnectionCheckDetailViewModel("Protocol", summary.ProtocolText), + new ConnectionCheckDetailViewModel("Cipher strength", summary.CipherStrengthText), + new ConnectionCheckDetailViewModel("Cipher suite", summary.CipherSuiteText), + ] + ); + AddSection( + "Transparency", + FormatRecordsSummary(summary.CertificateAuthorityAuthorizationText, summary.CertificateTransparencyText), + [ + new ConnectionCheckDetailViewModel("CAA", summary.CertificateAuthorityAuthorizationText), + new ConnectionCheckDetailViewModel("Certificate transparency", summary.CertificateTransparencyText), + ] + ); + ReportText = null; + ResultState = ClassifySummary(summary); + HasSummary = true; + } + + private void AddSection(string title, string summary, IEnumerable details) + { + var rows = details.Where(detail => !string.IsNullOrWhiteSpace(detail.Value)).ToArray(); + if (rows.Length > 0) + { + Sections.Add(new ConnectionCheckSectionViewModel(title, summary, rows)); + foreach (var row in rows) + { + Details.Add(row); + } + } + } + + private static string FormatCertificateSummary(TlsCertificateSummary summary) + { + var now = DateTimeOffset.Now; + if (summary.ValidFrom > now) + { + return "Not valid yet"; + } + + return summary.Expires <= now.AddDays(30) ? "Expiring" : "Valid"; + } + + private static string FormatCipherSummary(string? cipherStrength) + { + if (string.IsNullOrWhiteSpace(cipherStrength)) + { + return "Unknown"; + } + + var end = cipherStrength.IndexOfAny(['.', '(']); + return end > 0 ? cipherStrength[..end].Trim() : cipherStrength; + } + + private static string FormatRecordsSummary(string caa, string ct) + { + if (caa == "Aligned" && ct.StartsWith("Looks normal", StringComparison.OrdinalIgnoreCase)) + { + return "Aligned"; + } + + if (caa == "Not checked" || ct == "Not checked") + { + return "Not checked"; + } + + return caa == "None" ? "No CAA" : "Review"; + } + + private static ConnectionCheckIndicatorState ClassifySummary(TlsCertificateSummary summary) + { + if ( + summary.PolicyErrors != System.Net.Security.SslPolicyErrors.None + || !summary.HostNameMatchesCertificate + || summary.ChainStatus.Count > 0 + || IsBadCipher(summary.CipherStrengthText) + || summary.CertificateAuthorityAuthorizationText == "Unaligned" + ) + { + return ConnectionCheckIndicatorState.Error; + } + + if ( + FormatCertificateSummary(summary) != "Valid" + || IsIncompleteTransparency( + summary.CertificateAuthorityAuthorizationText, + summary.CertificateTransparencyText + ) + || IsUnknownCipher(summary.CipherStrengthText) + ) + { + return ConnectionCheckIndicatorState.Warning; + } + + return ConnectionCheckIndicatorState.Good; + } + + private static bool IsBadCipher(string? cipherStrength) + { + return StartsWithAny(cipherStrength, "Insecure", "Weak"); + } + + private static bool IsUnknownCipher(string? cipherStrength) + { + return StartsWithAny(cipherStrength, "Unknown", "Not post-quantum"); + } + + private static bool IsIncompleteTransparency(string caa, string ct) + { + return caa is "None" or "Not checked" + || ct == "Not checked" + || ct.StartsWith("No embedded", StringComparison.OrdinalIgnoreCase) + || ct.StartsWith("Inconclusive", StringComparison.OrdinalIgnoreCase); + } + + private static bool StartsWithAny(string? value, params string[] prefixes) + { + return !string.IsNullOrWhiteSpace(value) + && prefixes.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsDnsResolutionFailure(SocketException ex) + { + return ex.SocketErrorCode is SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain; + } + + private DelegateCommand? start; + private DelegateCommand? cancel; + private DelegateCommand? close; +} + +public sealed record ConnectionCheckDetailViewModel(string Label, string? Value); + +public sealed record ConnectionCheckSectionViewModel( + string Title, + string Summary, + IReadOnlyList Details +); diff --git a/src/BrowserPicker.UI/ViewModels/FeedbackViewModel.cs b/src/BrowserPicker.UI/ViewModels/FeedbackViewModel.cs index 2024bf9..62e22b4 100644 --- a/src/BrowserPicker.UI/ViewModels/FeedbackViewModel.cs +++ b/src/BrowserPicker.UI/ViewModels/FeedbackViewModel.cs @@ -595,6 +595,9 @@ public bool UseAlphabeticalOrdering public bool RedirectsKnownOnly { get; set; } = true; public bool ProbeFavicons { get; set; } = true; public bool FaviconsForDefaults { get; set; } = true; + public bool CheckCertificateRecords { get; set; } = true; + public bool HideManualConnectionCheck { get; set; } + public bool SkipConnectionCheckConfirmation { get; set; } public string[] UrlShorteners { get; set; } = [.. UrlHandler.DefaultUrlShorteners, "example.com"]; public List BrowserList { get; } = [ diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index cacab54..21b929b 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -1,4 +1,4 @@ - ✏ + + + + + + + + + + + + diff --git a/src/BrowserPicker.UI/Views/Configuration.xaml b/src/BrowserPicker.UI/Views/Configuration.xaml index c4dbe6f..696155d 100644 --- a/src/BrowserPicker.UI/Views/Configuration.xaml +++ b/src/BrowserPicker.UI/Views/Configuration.xaml @@ -479,12 +479,12 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +