1package webkit
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "fmt"
8 "log"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/bnema/dumber/internal/config"
14 "github.com/bnema/dumber/internal/db"
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 log.Printf("[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 log.Printf("[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 log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
48 wv.allowCertificateAndReload(certificate, hostname, failingUri)
49 return true // Signal handled
50 } else {
51 log.Printf("[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 log.Printf("[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// formatCertificateInfo formats certificate information for display and hashing
87func formatCertificateInfo(certificate gio.TLSCertificater) string {
88 // Convert to TLSCertificate to access methods
89 tlsCert := gio.BaseTLSCertificate(certificate)
90 if tlsCert == nil {
91 // Fallback: use the object pointer address as a weak identifier
92 return fmt.Sprintf("%p", certificate)
93 }
94
95 // Build a unique string from certificate properties
96 // Use subject, issuer, and validity period
97 subject := tlsCert.SubjectName()
98 issuer := ""
99 if issuerCert := tlsCert.Issuer(); issuerCert != nil {
100 issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
101 }
102
103 notBefore := tlsCert.NotValidBefore()
104 notAfter := tlsCert.NotValidAfter()
105
106 // Combine properties into a unique string
107 return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
108 subject, issuer, notBefore, notAfter)
109}
110
111// getCertificateHash generates a SHA256 hash of the certificate info
112func getCertificateHash(certificateInfo string) string {
113 hash := sha256.Sum256([]byte(certificateInfo))
114 return fmt.Sprintf("%x", hash)
115}
116
117// checkStoredCertificateDecision checks if user has previously decided on this hostname
118func checkStoredCertificateDecision(hostname string) (string, bool) {
119 cfg := config.Get()
120 if cfg == nil {
121 log.Printf("[tls] Config not available, cannot check stored decisions")
122 return "", false
123 }
124
125 dbPath := cfg.Database.Path
126 if dbPath == "" {
127 log.Printf("[tls] Database path not configured")
128 return "", false
129 }
130
131 database, err := sql.Open("sqlite3", dbPath)
132 if err != nil {
133 log.Printf("[tls] Failed to open database: %v", err)
134 return "", false
135 }
136 defer database.Close()
137
138 queries := db.New(database)
139 ctx := context.Background()
140
141 // Get any validation for this hostname (we don't verify cert hash anymore)
142 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
143 if err != nil {
144 if err == sql.ErrNoRows {
145 return "", false
146 }
147 log.Printf("[tls] Error checking certificate validation: %v", err)
148 return "", false
149 }
150
151 return validation.UserDecision, true
152}
153
154// storeCertificateDecision stores the user's decision about a hostname
155func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) error {
156 cfg := config.Get()
157 if cfg == nil {
158 return fmt.Errorf("config not available")
159 }
160
161 dbPath := cfg.Database.Path
162 if dbPath == "" {
163 return fmt.Errorf("database path not configured")
164 }
165
166 database, err := sql.Open("sqlite3", dbPath)
167 if err != nil {
168 return fmt.Errorf("failed to open database: %w", err)
169 }
170 defer database.Close()
171
172 queries := db.New(database)
173 ctx := context.Background()
174
175 log.Printf("[tls] Saving certificate decision to database: hostname=%s, decision=%s, expiresAt=%v",
176 hostname, decision, expiresAt)
177
178 // Use empty string for cert hash since we're doing hostname-only matching
179 if err := queries.StoreCertificateValidation(ctx, hostname, "", decision, expiresAt); err != nil {
180 log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181 return err
182 }
183
184 log.Printf("[tls] Certificate decision saved successfully in database")
185 return nil
186}
187
188// TLS Certificate Flag constants from GIO
189// These match the C enum values from gioenums.h
190const (
191 tlsCertificateUnknownCA gio.TLSCertificateFlags = 1 << 0 // G_TLS_CERTIFICATE_UNKNOWN_CA
192 tlsCertificateBadIdentity gio.TLSCertificateFlags = 1 << 1 // G_TLS_CERTIFICATE_BAD_IDENTITY
193 tlsCertificateNotActivated gio.TLSCertificateFlags = 1 << 2 // G_TLS_CERTIFICATE_NOT_ACTIVATED
194 tlsCertificateExpired gio.TLSCertificateFlags = 1 << 3 // G_TLS_CERTIFICATE_EXPIRED
195 tlsCertificateRevoked gio.TLSCertificateFlags = 1 << 4 // G_TLS_CERTIFICATE_REVOKED
196 tlsCertificateInsecure gio.TLSCertificateFlags = 1 << 5 // G_TLS_CERTIFICATE_INSECURE
197 tlsCertificateGenericError gio.TLSCertificateFlags = 1 << 6 // G_TLS_CERTIFICATE_GENERIC_ERROR
198)
199
200// getTLSErrorMessages converts TLS certificate flags to human-readable messages
201func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
202 var messages []string
203
204 if errors&tlsCertificateUnknownCA != 0 {
205 messages = append(messages, "• The certificate authority is not trusted")
206 }
207 if errors&tlsCertificateBadIdentity != 0 {
208 messages = append(messages, "• The certificate does not match the site identity")
209 }
210 if errors&tlsCertificateNotActivated != 0 {
211 messages = append(messages, "• The certificate is not yet valid")
212 }
213 if errors&tlsCertificateExpired != 0 {
214 messages = append(messages, "• The certificate has expired")
215 }
216 if errors&tlsCertificateRevoked != 0 {
217 messages = append(messages, "• The certificate has been revoked")
218 }
219 if errors&tlsCertificateInsecure != 0 {
220 messages = append(messages, "• The certificate uses an insecure algorithm")
221 }
222 if errors&tlsCertificateGenericError != 0 {
223 messages = append(messages, "• A generic error occurred validating the certificate")
224 }
225
226 if len(messages) == 0 {
227 messages = append(messages, "• Unknown certificate error")
228 }
229
230 return messages
231}
232
233// showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) {
235 // This must run on the main thread
236 wv.RunOnMainThread(func() {
237 // Get error messages
238 errorMessages := getTLSErrorMessages(errors)
239
240 // Build detailed message
241 detail := fmt.Sprintf("The site %s has a certificate error:\n\n%s\n\nProceeding is unsafe and may expose your data to attackers.",
242 hostname,
243 strings.Join(errorMessages, "\n"))
244
245 // Create AlertDialog using CGO wrapper
246 dialog := newAlertDialog(fmt.Sprintf("Certificate Error for %s", hostname))
247 if dialog == nil {
248 log.Printf("[tls] Failed to create alert dialog")
249 return
250 }
251
252 // Set dialog properties
253 dialog.SetDetail(detail)
254 dialog.SetModal(true)
255
256 // Set buttons: Go Back (0), Proceed Once (1), Always Accept (2)
257 dialog.SetButtons([]string{"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site"})
258 dialog.SetDefaultButton(0) // Default to "Go Back"
259 dialog.SetCancelButton(0) // Cancel = "Go Back"
260
261 // Get parent window
262 var parentWindow *gtk.Window
263 if wv.window != nil {
264 parentWindow = wv.window.AsWindow()
265 }
266
267 // Show dialog and handle response
268 dialog.Choose(context.Background(), parentWindow, func(result gio.AsyncResulter) {
269 buttonIndex, err := dialog.ChooseFinish(result)
270 if err != nil {
271 log.Printf("[tls] Dialog error: %v", err)
272 return
273 }
274
275 log.Printf("[tls] User chose button %d for %s", buttonIndex, hostname)
276
277 switch buttonIndex {
278 case 0: // Go Back
279 log.Printf("[tls] User chose to go back")
280 // Do nothing - let the load fail
281
282 case 1: // Proceed Once
283 log.Printf("[tls] User chose to proceed once")
284 wv.allowCertificateAndReload(certificate, hostname, failingUri)
285
286 case 2: // Always Accept This Site
287 log.Printf("[tls] User chose to always accept for %s", hostname)
288 // Store decision in database (expires in 30 days)
289 expiresAt := sql.NullTime{
290 Time: time.Now().Add(30 * 24 * time.Hour),
291 Valid: true,
292 }
293 if err := storeCertificateDecision(hostname, "accepted", expiresAt); err != nil {
294 log.Printf("[tls] Failed to store certificate decision: %v", err)
295 } else {
296 log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)
297 }
298 wv.allowCertificateAndReload(certificate, hostname, failingUri)
299 }
300 })
301 })
302}
303
304// allowCertificateAndReload allows the certificate for the host and reloads the page
305func (wv *WebView) allowCertificateAndReload(certificate gio.TLSCertificater, hostname, uri string) {
306 wv.RunOnMainThread(func() {
307 // Get the network session
308 session := wv.view.NetworkSession()
309 if session == nil {
310 log.Printf("[tls] Failed to get network session")
311 return
312 }
313
314 // Allow the certificate for this host
315 session.AllowTLSCertificateForHost(certificate, hostname)
316 log.Printf("[tls] Certificate exception added for host: %s", hostname)
317
318 // Reload the page
319 wv.view.LoadURI(uri)
320 log.Printf("[tls] Reloading %s with certificate exception", uri)
321 })
322}
323
324// CleanupExpiredCertificateValidations removes expired certificate validations from the database
325// This should be called on application startup
326func CleanupExpiredCertificateValidations() error {
327 cfg := config.Get()
328 if cfg == nil {
329 return fmt.Errorf("config not available")
330 }
331
332 dbPath := cfg.Database.Path
333 if dbPath == "" {
334 return fmt.Errorf("database path not configured")
335 }
336
337 database, err := sql.Open("sqlite3", dbPath)
338 if err != nil {
339 return fmt.Errorf("failed to open database: %w", err)
340 }
341 defer database.Close()
342
343 queries := db.New(database)
344 ctx := context.Background()
345
346 if err := queries.DeleteExpiredCertificateValidations(ctx); err != nil {
347 return fmt.Errorf("failed to delete expired validations: %w", err)
348 }
349
350 log.Printf("[tls] Cleaned up expired certificate validations")
351 return nil
352}