detached 43577dd

fix(tls): use hostname-only matching instead of unstable cert fingerprinting

commit
43577dd5c10fe78a9234f72f4016f6e91e11cee6
author
bamen · · 2025-10-15 16:28 UTC
parent
7928d62
signature
unsigned
5 files · +52 -22
M CHANGELOG.md +1 -1
@@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file.
2222 ### Fixed
2323 - **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)
2424 - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
25- - SQLite-based certificate decision storage with SHA256 certificate fingerprinting
25+ - SQLite-based hostname decision storage (hostname-only matching - GIO certificate properties are unstable)
2626 - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days
2727 - Automatic cleanup of expired validations on application startup
2828 - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again
@@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file.
22 ### Fixed22 ### Fixed
23 - **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)23 - **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)
24 - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"24 - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
25- - SQLite-based certificate decision storage with SHA256 certificate fingerprinting25+ - SQLite-based hostname decision storage (hostname-only matching - GIO certificate properties are unstable)
26 - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days26 - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days
27 - Automatic cleanup of expired validations on application startup27 - Automatic cleanup of expired validations on application startup
28 - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again28 - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again
M internal/db/certificate_validations.sql.go +24 -2
@@ -31,8 +31,8 @@ func (q *Queries) DeleteExpiredCertificateValidations(ctx context.Context) error
3131 }
3232
3333 const GetCertificateValidation = `-- name: GetCertificateValidation :one
34-SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35-WHERE hostname = ? AND certificate_hash = ?
34+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35+WHERE hostname = ? AND certificate_hash = ?
3636 AND (expires_at IS NULL OR expires_at > datetime('now'))
3737 LIMIT 1
3838 `
@@ -51,6 +51,28 @@ func (q *Queries) GetCertificateValidation(ctx context.Context, hostname string,
5151 return i, err
5252 }
5353
54+const GetCertificateValidationByHostname = `-- name: GetCertificateValidationByHostname :one
55+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
56+WHERE hostname = ?
57+AND (expires_at IS NULL OR expires_at > datetime('now'))
58+ORDER BY created_at DESC
59+LIMIT 1
60+`
61+
62+func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) {
63+ row := q.db.QueryRowContext(ctx, GetCertificateValidationByHostname, hostname)
64+ var i CertificateValidation
65+ err := row.Scan(
66+ &i.ID,
67+ &i.Hostname,
68+ &i.CertificateHash,
69+ &i.UserDecision,
70+ &i.CreatedAt,
71+ &i.ExpiresAt,
72+ )
73+ return i, err
74+}
75+
5476 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
5577 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
5678 VALUES (?, ?, ?, ?)
@@ -31,8 +31,8 @@ func (q *Queries) DeleteExpiredCertificateValidations(ctx context.Context) error
31 }31 }
32 32
33 const GetCertificateValidation = `-- name: GetCertificateValidation :one33 const GetCertificateValidation = `-- name: GetCertificateValidation :one
34-SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations 34+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35-WHERE hostname = ? AND certificate_hash = ? 35+WHERE hostname = ? AND certificate_hash = ?
36 AND (expires_at IS NULL OR expires_at > datetime('now'))36 AND (expires_at IS NULL OR expires_at > datetime('now'))
37 LIMIT 137 LIMIT 1
38 `38 `
@@ -51,6 +51,28 @@ func (q *Queries) GetCertificateValidation(ctx context.Context, hostname string,
51 return i, err51 return i, err
52 }52 }
53 53
54+const GetCertificateValidationByHostname = `-- name: GetCertificateValidationByHostname :one
55+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
56+WHERE hostname = ?
57+AND (expires_at IS NULL OR expires_at > datetime('now'))
58+ORDER BY created_at DESC
59+LIMIT 1
60+`
61+
62+func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) {
63+ row := q.db.QueryRowContext(ctx, GetCertificateValidationByHostname, hostname)
64+ var i CertificateValidation
65+ err := row.Scan(
66+ &i.ID,
67+ &i.Hostname,
68+ &i.CertificateHash,
69+ &i.UserDecision,
70+ &i.CreatedAt,
71+ &i.ExpiresAt,
72+ )
73+ return i, err
74+}
75+
54 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec76 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
55 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)77 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
56 VALUES (?, ?, ?, ?)78 VALUES (?, ?, ?, ?)
M internal/db/querier.go +1 -0
@@ -19,6 +19,7 @@ type Querier interface {
1919 // Delete zoom level setting for a domain
2020 DeleteZoomLevel(ctx context.Context, domain string) error
2121 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
2223 GetHistory(ctx context.Context, limit int64) ([]History, error)
2324 GetHistoryEntry(ctx context.Context, url string) (History, error)
2425 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
@@ -19,6 +19,7 @@ type Querier interface {
19 // Delete zoom level setting for a domain19 // Delete zoom level setting for a domain
20 DeleteZoomLevel(ctx context.Context, domain string) error20 DeleteZoomLevel(ctx context.Context, domain string) error
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
22 GetHistory(ctx context.Context, limit int64) ([]History, error)23 GetHistory(ctx context.Context, limit int64) ([]History, error)
23 GetHistoryEntry(ctx context.Context, url string) (History, error)24 GetHistoryEntry(ctx context.Context, url string) (History, error)
24 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
M internal/db/queries/certificate_validations.sql +9 -2
@@ -1,9 +1,16 @@
11 -- name: GetCertificateValidation :one
2-SELECT * FROM certificate_validations
3-WHERE hostname = ? AND certificate_hash = ?
2+SELECT * FROM certificate_validations
3+WHERE hostname = ? AND certificate_hash = ?
44 AND (expires_at IS NULL OR expires_at > datetime('now'))
55 LIMIT 1;
66
7+-- name: GetCertificateValidationByHostname :one
8+SELECT * FROM certificate_validations
9+WHERE hostname = ?
10+AND (expires_at IS NULL OR expires_at > datetime('now'))
11+ORDER BY created_at DESC
12+LIMIT 1;
13+
714 -- name: StoreCertificateValidation :exec
815 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
916 VALUES (?, ?, ?, ?);
@@ -1,9 +1,16 @@
1 -- name: GetCertificateValidation :one1 -- name: GetCertificateValidation :one
2-SELECT * FROM certificate_validations 2+SELECT * FROM certificate_validations
3-WHERE hostname = ? AND certificate_hash = ? 3+WHERE hostname = ? AND certificate_hash = ?
4 AND (expires_at IS NULL OR expires_at > datetime('now'))4 AND (expires_at IS NULL OR expires_at > datetime('now'))
5 LIMIT 1;5 LIMIT 1;
6 6
7+-- name: GetCertificateValidationByHostname :one
8+SELECT * FROM certificate_validations
9+WHERE hostname = ?
10+AND (expires_at IS NULL OR expires_at > datetime('now'))
11+ORDER BY created_at DESC
12+LIMIT 1;
13+
7 -- name: StoreCertificateValidation :exec14 -- name: StoreCertificateValidation :exec
8 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)15 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
9 VALUES (?, ?, ?, ?);16 VALUES (?, ?, ?, ?);
M pkg/webkit/tls_handler.go +17 -17
@@ -38,12 +38,10 @@ func (wv *WebView) setupTLSErrorHandler() {
3838 return false // Let WebKit handle it
3939 }
4040
41- // Get certificate info string for hashing
42- certInfo := formatCertificateInfo(certificate)
43- certHash := getCertificateHash(certInfo)
44-
45- // Check if user has previously made a decision about this certificate
46- decision, exists := checkStoredCertificateDecision(hostname, certHash)
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)
4745 if exists {
4846 if decision == "accepted" {
4947 log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
@@ -57,7 +55,7 @@ func (wv *WebView) setupTLSErrorHandler() {
5755
5856 // No stored decision - show dialog to user
5957 log.Printf("[tls] No stored decision for %s, showing dialog", hostname)
60- wv.showTLSErrorDialog(failingUri, hostname, certificate, certHash, errors)
58+ wv.showTLSErrorDialog(failingUri, hostname, certificate, errors)
6159
6260 return true // Signal handled - we're managing the error
6361 })
@@ -116,8 +114,8 @@ func getCertificateHash(certificateInfo string) string {
116114 return fmt.Sprintf("%x", hash)
117115 }
118116
119-// checkStoredCertificateDecision checks if user has previously decided on this certificate
120-func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
117+// checkStoredCertificateDecision checks if user has previously decided on this hostname
118+func checkStoredCertificateDecision(hostname string) (string, bool) {
121119 cfg := config.Get()
122120 if cfg == nil {
123121 log.Printf("[tls] Config not available, cannot check stored decisions")
@@ -140,7 +138,8 @@ func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
140138 queries := db.New(database)
141139 ctx := context.Background()
142140
143- validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)
141+ // Get any validation for this hostname (we don't verify cert hash anymore)
142+ validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
144143 if err != nil {
145144 if err == sql.ErrNoRows {
146145 return "", false
@@ -152,8 +151,8 @@ func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
152151 return validation.UserDecision, true
153152 }
154153
155-// storeCertificateDecision stores the user's decision about a certificate
156-func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql.NullTime) error {
154+// storeCertificateDecision stores the user's decision about a hostname
155+func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) error {
157156 cfg := config.Get()
158157 if cfg == nil {
159158 return fmt.Errorf("config not available")
@@ -173,10 +172,11 @@ func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql
173172 queries := db.New(database)
174173 ctx := context.Background()
175174
176- log.Printf("[tls] Saving certificate decision to database: hostname=%s, certHash=%s, decision=%s, expiresAt=%v",
177- hostname, certHash[:16]+"...", decision, expiresAt)
175+ log.Printf("[tls] Saving certificate decision to database: hostname=%s, decision=%s, expiresAt=%v",
176+ hostname, decision, expiresAt)
178177
179- if err := queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt); err != nil {
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 {
180180 log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181181 return err
182182 }
@@ -231,7 +231,7 @@ func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
231231 }
232232
233233 // showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234-func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, certHash string, errors gio.TLSCertificateFlags) {
234+func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) {
235235 // This must run on the main thread
236236 wv.RunOnMainThread(func() {
237237 // Get error messages
@@ -290,7 +290,7 @@ func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate g
290290 Time: time.Now().Add(30 * 24 * time.Hour),
291291 Valid: true,
292292 }
293- if err := storeCertificateDecision(hostname, certHash, "accepted", expiresAt); err != nil {
293+ if err := storeCertificateDecision(hostname, "accepted", expiresAt); err != nil {
294294 log.Printf("[tls] Failed to store certificate decision: %v", err)
295295 } else {
296296 log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)
@@ -38,12 +38,10 @@ func (wv *WebView) setupTLSErrorHandler() {
38 return false // Let WebKit handle it38 return false // Let WebKit handle it
39 }39 }
40 40
41- // Get certificate info string for hashing41+ // Check if user has previously made a decision for this hostname
42- certInfo := formatCertificateInfo(certificate)42+ // Note: We use hostname-only matching because certificate properties
43- certHash := getCertificateHash(certInfo)43+ // from GIO are unstable (GDateTime objects change on each access)
44-44+ decision, exists := checkStoredCertificateDecision(hostname)
45- // Check if user has previously made a decision about this certificate
46- decision, exists := checkStoredCertificateDecision(hostname, certHash)
47 if exists {45 if exists {
48 if decision == "accepted" {46 if decision == "accepted" {
49 log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)47 log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
@@ -57,7 +55,7 @@ func (wv *WebView) setupTLSErrorHandler() {
57 55
58 // No stored decision - show dialog to user56 // No stored decision - show dialog to user
59 log.Printf("[tls] No stored decision for %s, showing dialog", hostname)57 log.Printf("[tls] No stored decision for %s, showing dialog", hostname)
60- wv.showTLSErrorDialog(failingUri, hostname, certificate, certHash, errors)58+ wv.showTLSErrorDialog(failingUri, hostname, certificate, errors)
61 59
62 return true // Signal handled - we're managing the error60 return true // Signal handled - we're managing the error
63 })61 })
@@ -116,8 +114,8 @@ func getCertificateHash(certificateInfo string) string {
116 return fmt.Sprintf("%x", hash)114 return fmt.Sprintf("%x", hash)
117 }115 }
118 116
119-// checkStoredCertificateDecision checks if user has previously decided on this certificate117+// checkStoredCertificateDecision checks if user has previously decided on this hostname
120-func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {118+func checkStoredCertificateDecision(hostname string) (string, bool) {
121 cfg := config.Get()119 cfg := config.Get()
122 if cfg == nil {120 if cfg == nil {
123 log.Printf("[tls] Config not available, cannot check stored decisions")121 log.Printf("[tls] Config not available, cannot check stored decisions")
@@ -140,7 +138,8 @@ func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
140 queries := db.New(database)138 queries := db.New(database)
141 ctx := context.Background()139 ctx := context.Background()
142 140
143- validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)141+ // Get any validation for this hostname (we don't verify cert hash anymore)
142+ validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
144 if err != nil {143 if err != nil {
145 if err == sql.ErrNoRows {144 if err == sql.ErrNoRows {
146 return "", false145 return "", false
@@ -152,8 +151,8 @@ func checkStoredCertificateDecision(hostname, certHash string) (string, bool) {
152 return validation.UserDecision, true151 return validation.UserDecision, true
153 }152 }
154 153
155-// storeCertificateDecision stores the user's decision about a certificate154+// storeCertificateDecision stores the user's decision about a hostname
156-func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql.NullTime) error {155+func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) error {
157 cfg := config.Get()156 cfg := config.Get()
158 if cfg == nil {157 if cfg == nil {
159 return fmt.Errorf("config not available")158 return fmt.Errorf("config not available")
@@ -173,10 +172,11 @@ func storeCertificateDecision(hostname, certHash, decision string, expiresAt sql
173 queries := db.New(database)172 queries := db.New(database)
174 ctx := context.Background()173 ctx := context.Background()
175 174
176- log.Printf("[tls] Saving certificate decision to database: hostname=%s, certHash=%s, decision=%s, expiresAt=%v",175+ log.Printf("[tls] Saving certificate decision to database: hostname=%s, decision=%s, expiresAt=%v",
177- hostname, certHash[:16]+"...", decision, expiresAt)176+ hostname, decision, expiresAt)
178 177
179- if err := queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt); err != nil {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)180 log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181 return err181 return err
182 }182 }
@@ -231,7 +231,7 @@ func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
231 }231 }
232 232
233 // showTLSErrorDialog shows a dialog asking the user how to handle the TLS error233 // showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234-func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, certHash string, errors gio.TLSCertificateFlags) {234+func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) {
235 // This must run on the main thread235 // This must run on the main thread
236 wv.RunOnMainThread(func() {236 wv.RunOnMainThread(func() {
237 // Get error messages237 // Get error messages
@@ -290,7 +290,7 @@ func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate g
290 Time: time.Now().Add(30 * 24 * time.Hour),290 Time: time.Now().Add(30 * 24 * time.Hour),
291 Valid: true,291 Valid: true,
292 }292 }
293- if err := storeCertificateDecision(hostname, certHash, "accepted", expiresAt); err != nil {293+ if err := storeCertificateDecision(hostname, "accepted", expiresAt); err != nil {
294 log.Printf("[tls] Failed to store certificate decision: %v", err)294 log.Printf("[tls] Failed to store certificate decision: %v", err)
295 } else {295 } else {
296 log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)296 log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)

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