1package webkit
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "net/url"
9 "strings"
10 "time"
11
12 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/db"
14 "github.com/bnema/dumber/internal/logging"
15 "github.com/diamondburned/gotk4/pkg/gio/v2"
16 "github.com/diamondburned/gotk4/pkg/gtk/v4"
17)
18
19// TLSCertificateInfo holds information about a TLS certificate for display
20type TLSCertificateInfo struct {
21 Subject string
22 Issuer string
23 NotBefore string
24 NotAfter string
25 IsExpired bool
26 Errors []string
27}
28
29// setupTLSErrorHandler sets up the TLS error handler for the WebView
30func (wv *WebView) setupTLSErrorHandler() {
31 wv.view.ConnectLoadFailedWithTLSErrors(func(failingUri string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) bool {
32 logging.Warn(fmt.Sprintf("[tls] Load failed with TLS errors for: %s, errors: %v", failingUri, errors))
33
34 // Extract hostname from URI
35 hostname := extractHostname(failingUri)
36 if hostname == "" {
37 logging.Error(fmt.Sprintf("[tls] Failed to extract hostname from URI: %s", failingUri))
38 return false // Let WebKit handle it
39 }
40
41 // Check if user has previously made a decision for this hostname
42 // Note: We use hostname-only matching because certificate properties
43 // from GIO are unstable (GDateTime objects change on each access)
44 decision, exists := checkStoredCertificateDecision(hostname)
45 if exists {
46 if decision == "accepted" {
47 logging.Debug(fmt.Sprintf("[tls] Certificate previously accepted for %s, allowing", hostname))
48 wv.allowCertificateAndReload(certificate, hostname, failingUri)
49 return true // Signal handled
50 } else {
51 logging.Debug(fmt.Sprintf("[tls] Certificate previously rejected for %s, blocking", hostname))
52 return false // Let WebKit show error
53 }
54 }
55
56 // No stored decision - show dialog to user
57 logging.Debug(fmt.Sprintf("[tls] No stored decision for %s, showing dialog", hostname))
58 wv.showTLSErrorDialog(failingUri, hostname, certificate, errors)
59
60 return true // Signal handled - we're managing the error
61 })
62}
63
64// extractHostname extracts the hostname from a URI
65func extractHostname(uri string) string {
66 parsed, err := url.Parse(uri)
67 if err != nil {
68 // Try to extract manually
69 if strings.Contains(uri, "://") {
70 parts := strings.SplitN(uri, "://", 2)
71 if len(parts) == 2 {
72 host := strings.Split(parts[1], "/")[0]
73 // Remove port if present
74 host = strings.Split(host, ":")[0]
75 return host
76 }
77 }
78 return ""
79 }
80
81 // Remove port if present
82 host := parsed.Hostname()
83 return host
84}
85
86// checkStoredCertificateDecision checks if user has previously decided on this hostname
87func checkStoredCertificateDecision(hostname string) (string, bool) {
88 cfg := config.Get()
89 if cfg == nil {
90 logging.Error(fmt.Sprintf("[tls] Config not available, cannot check stored decisions"))
91 return "", false
92 }
93
94 dbPath := cfg.Database.Path
95 if dbPath == "" {
96 logging.Error(fmt.Sprintf("[tls] Database path not configured"))
97 return "", false
98 }
99
100 database, err := sql.Open("sqlite3", dbPath)
101 if err != nil {
102 logging.Error(fmt.Sprintf("[tls] Failed to open database: %v", err))
103 return "", false
104 }
105 defer func() {
106 if err := database.Close(); err != nil {
107 logging.Warn(fmt.Sprintf("[tls] warning: failed to close database: %v", err))
108 }
109 }()
110
111 queries := db.New(database)
112 ctx := context.Background()
113
114 // Get any validation for this hostname (we don't verify cert hash anymore)
115 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
116 if err != nil {
117 if errors.Is(err, sql.ErrNoRows) {
118 return "", false
119 }
120 logging.Error(fmt.Sprintf("[tls] Error checking certificate validation: %v", err))
121 return "", false
122 }
123
124 return validation.UserDecision, true
125}
126
127// storeCertificateDecision stores the user's decision about a hostname
128func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) error {
129 cfg := config.Get()
130 if cfg == nil {
131 return fmt.Errorf("config not available")
132 }
133
134 dbPath := cfg.Database.Path
135 if dbPath == "" {
136 return fmt.Errorf("database path not configured")
137 }
138
139 database, err := sql.Open("sqlite3", dbPath)
140 if err != nil {
141 return fmt.Errorf("failed to open database: %w", err)
142 }
143 defer func() {
144 if err := database.Close(); err != nil {
145 logging.Warn(fmt.Sprintf("[tls] warning: failed to close database: %v", err))
146 }
147 }()
148
149 queries := db.New(database)
150 ctx := context.Background()
151
152 logging.Debug(fmt.Sprintf("[tls] Saving certificate decision to database: hostname=%s, decision=%s, expiresAt=%v",
153 hostname, decision, expiresAt))
154
155 // Use empty string for cert hash since we're doing hostname-only matching
156 if err := queries.StoreCertificateValidation(ctx, hostname, "", decision, expiresAt); err != nil {
157 logging.Error(fmt.Sprintf("[tls] Failed to save certificate decision to database: %v", err))
158 return err
159 }
160
161 logging.Debug(fmt.Sprintf("[tls] Certificate decision saved successfully in database"))
162 return nil
163}
164
165// TLS Certificate Flag constants from GIO
166// These match the C enum values from gioenums.h
167const (
168 tlsCertificateUnknownCA gio.TLSCertificateFlags = 1 << 0 // G_TLS_CERTIFICATE_UNKNOWN_CA
169 tlsCertificateBadIdentity gio.TLSCertificateFlags = 1 << 1 // G_TLS_CERTIFICATE_BAD_IDENTITY
170 tlsCertificateNotActivated gio.TLSCertificateFlags = 1 << 2 // G_TLS_CERTIFICATE_NOT_ACTIVATED
171 tlsCertificateExpired gio.TLSCertificateFlags = 1 << 3 // G_TLS_CERTIFICATE_EXPIRED
172 tlsCertificateRevoked gio.TLSCertificateFlags = 1 << 4 // G_TLS_CERTIFICATE_REVOKED
173 tlsCertificateInsecure gio.TLSCertificateFlags = 1 << 5 // G_TLS_CERTIFICATE_INSECURE
174 tlsCertificateGenericError gio.TLSCertificateFlags = 1 << 6 // G_TLS_CERTIFICATE_GENERIC_ERROR
175)
176
177// getTLSErrorMessages converts TLS certificate flags to human-readable messages
178func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
179 var messages []string
180
181 if errors&tlsCertificateUnknownCA != 0 {
182 messages = append(messages, "• The certificate authority is not trusted")
183 }
184 if errors&tlsCertificateBadIdentity != 0 {
185 messages = append(messages, "• The certificate does not match the site identity")
186 }
187 if errors&tlsCertificateNotActivated != 0 {
188 messages = append(messages, "• The certificate is not yet valid")
189 }
190 if errors&tlsCertificateExpired != 0 {
191 messages = append(messages, "• The certificate has expired")
192 }
193 if errors&tlsCertificateRevoked != 0 {
194 messages = append(messages, "• The certificate has been revoked")
195 }
196 if errors&tlsCertificateInsecure != 0 {
197 messages = append(messages, "• The certificate uses an insecure algorithm")
198 }
199 if errors&tlsCertificateGenericError != 0 {
200 messages = append(messages, "• A generic error occurred validating the certificate")
201 }
202
203 if len(messages) == 0 {
204 messages = append(messages, "• Unknown certificate error")
205 }
206
207 return messages
208}
209
210// showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
211func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) {
212 // This must run on the main thread
213 wv.RunOnMainThread(func() {
214 // Get error messages
215 errorMessages := getTLSErrorMessages(errors)
216
217 // Build detailed message
218 detail := fmt.Sprintf("The site %s has a certificate error:\n\n%s\n\nProceeding is unsafe and may expose your data to attackers.",
219 hostname,
220 strings.Join(errorMessages, "\n"))
221
222 // Create AlertDialog using CGO wrapper
223 dialog := newAlertDialog(fmt.Sprintf("Certificate Error for %s", hostname))
224 if dialog == nil {
225 logging.Error(fmt.Sprintf("[tls] Failed to create alert dialog"))
226 return
227 }
228
229 // Set dialog properties
230 dialog.SetDetail(detail)
231 dialog.SetModal(true)
232
233 // Set buttons: Go Back (0), Proceed Once (1), Always Accept (2)
234 dialog.SetButtons([]string{"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site"})
235 dialog.SetDefaultButton(0) // Default to "Go Back"
236 dialog.SetCancelButton(0) // Cancel = "Go Back"
237
238 // Get parent window
239 var parentWindow *gtk.Window
240 if wv.window != nil {
241 parentWindow = wv.window.AsWindow()
242 }
243
244 // Show dialog and handle response
245 dialog.Choose(context.Background(), parentWindow, func(result gio.AsyncResulter) {
246 buttonIndex, err := dialog.ChooseFinish(result)
247 if err != nil {
248 logging.Error(fmt.Sprintf("[tls] Dialog error: %v", err))
249 return
250 }
251
252 logging.Debug(fmt.Sprintf("[tls] User chose button %d for %s", buttonIndex, hostname))
253
254 switch buttonIndex {
255 case 0: // Go Back
256 logging.Debug(fmt.Sprintf("[tls] User chose to go back"))
257 // Do nothing - let the load fail
258
259 case 1: // Proceed Once
260 logging.Debug(fmt.Sprintf("[tls] User chose to proceed once"))
261 wv.allowCertificateAndReload(certificate, hostname, failingUri)
262
263 case 2: // Always Accept This Site
264 logging.Debug(fmt.Sprintf("[tls] User chose to always accept for %s", hostname))
265 // Store decision in database (expires in 30 days)
266 expiresAt := sql.NullTime{
267 Time: time.Now().Add(30 * 24 * time.Hour),
268 Valid: true,
269 }
270 if err := storeCertificateDecision(hostname, "accepted", expiresAt); err != nil {
271 logging.Error(fmt.Sprintf("[tls] Failed to store certificate decision: %v", err))
272 } else {
273 logging.Debug(fmt.Sprintf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname))
274 }
275 wv.allowCertificateAndReload(certificate, hostname, failingUri)
276 }
277 })
278 })
279}
280
281// allowCertificateAndReload allows the certificate for the host and reloads the page
282func (wv *WebView) allowCertificateAndReload(certificate gio.TLSCertificater, hostname, uri string) {
283 wv.RunOnMainThread(func() {
284 // Get the network session
285 session := wv.view.NetworkSession()
286 if session == nil {
287 logging.Error(fmt.Sprintf("[tls] Failed to get network session"))
288 return
289 }
290
291 // Allow the certificate for this host
292 session.AllowTLSCertificateForHost(certificate, hostname)
293 logging.Debug(fmt.Sprintf("[tls] Certificate exception added for host: %s", hostname))
294
295 // Reload the page
296 wv.view.LoadURI(uri)
297 logging.Debug(fmt.Sprintf("[tls] Reloading %s with certificate exception", uri))
298 })
299}
300
301// CleanupExpiredCertificateValidations removes expired certificate validations from the database
302// This should be called on application startup
303func CleanupExpiredCertificateValidations() error {
304 cfg := config.Get()
305 if cfg == nil {
306 return fmt.Errorf("config not available")
307 }
308
309 dbPath := cfg.Database.Path
310 if dbPath == "" {
311 return fmt.Errorf("database path not configured")
312 }
313
314 database, err := sql.Open("sqlite3", dbPath)
315 if err != nil {
316 return fmt.Errorf("failed to open database: %w", err)
317 }
318 defer func() {
319 if err := database.Close(); err != nil {
320 logging.Warn(fmt.Sprintf("[tls] warning: failed to close database: %v", err))
321 }
322 }()
323
324 queries := db.New(database)
325 ctx := context.Background()
326
327 if err := queries.DeleteExpiredCertificateValidations(ctx); err != nil {
328 return fmt.Errorf("failed to delete expired validations: %w", err)
329 }
330
331 logging.Debug(fmt.Sprintf("[tls] Cleaned up expired certificate validations"))
332 return nil
333}