v0.12.0 d035193

tls_handler.go RawHistoryFolder

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}

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