v0.15.0 9006957

tls_handler.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close