code.pablomurad.com

skull-browser

Keyboard-driven browser for the small web, with native Gopher and Gemini

feat: certificate exceptions expire, and can be seen and revoked

Two of the certificate error strings were wrong, and they were the two
that matter. A revoked certificate had no entry at all, so it showed as
"Unknown error code revoked", while "insecure", which means the
certificate was signed with a weak algorithm, was labelled "The
certificate has been revoked."

An exception, meaning a decision to trust one certificate that failed
verification, used to last forever. It now lapses after 90 days, set by
error_page.cert_exception_lifetime, and expired rows are swept when the
store is read and before one is reapplied. The button says how long it
will last instead of "permanently".

skull://certs lists every stored exception with the date it was made
and the date it lapses, and revokes any of them. Bound to gC and to
:certs. WebKit keeps a certificate it has already been told to allow
until the browser restarts, and the page says so rather than implying
the revoke takes effect at once.

The status bar showed (trust) for a host with an exception, because
WebKit reports no certificate errors once the exception is in place. It
shows (exception) in the untrusted colour now.

Correcting an earlier note of mine: the stored certificate itself was
never the problem. luakit.allow_certificate pins one certificate to one
host through webkit_web_context_allow_tls_certificate_for_host, so a
different certificate on that host was already refused. What was
missing was the expiry, the listing and the indicator.
author
Pablo Murad
date
commit
8fc4eacd99c3540af292e8c6a33c35ce405c6168
parent
6dd826d254ec2bbf56a5d81520883af07b4f4779
changes
5 files +336 −4
@@ -177,6 +177,10 @@ require_web_module("referer_control_wm") 
 local error_page = require "error_page"
 
+-- Add skull://certs/ chrome page, which lists and revokes the certificates
+-- that were trusted after failing verification
+local certs_chrome = require "certs_chrome"
+
 -- Add userstyles loader
 local styles = require "styles"
 
@@ -0,0 +1,166 @@+--- Certificate exception manager.
+--
+-- This module provides <skull://certs/>, which lists every certificate the
+-- user chose to trust after it failed verification, and lets any of them be
+-- taken back.
+--
+-- An exception covers one certificate on one host. It lapses on its own after
+-- `error_page.cert_exception_lifetime`, and this page is where it can be ended
+-- sooner.
+--
+-- @module certs_chrome
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local chrome = require("chrome")
+local error_page = require("error_page")
+local lousy = require("lousy")
+local modes = require("modes")
+local add_binds, add_cmds = modes.add_binds, modes.add_cmds
+
+local _M = {}
+
+--- CSS applied to the certificate page.
+-- @type string
+-- @readwrite
+_M.stylesheet = [===[
+    .header {
+        display: flex;
+        align-items: baseline;
+        justify-content: space-between;
+        border-bottom: 1px solid #d8d8d8;
+        padding-bottom: 0.6em;
+        margin-bottom: 1.4em;
+    }
+    .header h1 { margin: 0; font-size: 1.4em; }
+    .header .count { color: #888; font-size: 0.9em; }
+
+    .note {
+        background: #fff8e1;
+        border-left: 3px solid #e0a800;
+        padding: 0.8em 1em;
+        margin-bottom: 1.6em;
+        font-size: 0.9em;
+        line-height: 1.5;
+    }
+
+    table { border-collapse: collapse; width: 100%; }
+    th {
+        text-align: left;
+        font-weight: normal;
+        color: #888;
+        font-size: 0.82em;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+        padding: 0 1em 0.5em 0;
+    }
+    td {
+        padding: 0.7em 1em 0.7em 0;
+        border-top: 1px solid #ececec;
+        vertical-align: baseline;
+    }
+    td.host { font-family: monospace; font-size: 1em; }
+    td.when { color: #666; white-space: nowrap; }
+    td.act { text-align: right; width: 1%; }
+
+    .revoke {
+        color: #b00020;
+        cursor: pointer;
+        text-decoration: none;
+        border-bottom: 1px solid transparent;
+    }
+    .revoke:hover { border-bottom-color: #b00020; }
+
+    .empty { color: #888; padding: 2em 0; }
+]===]
+
+local html_template = [==[
+<html>
+<head>
+    <title>Certificate exceptions</title>
+    <style type="text/css">{%stylesheet}</style>
+</head>
+<body>
+    <div class="header">
+        <h1>Certificate exceptions</h1>
+        <span class="count">{%count}</span>
+    </div>
+    <div class="note">
+        Each row is one certificate that failed verification and was trusted
+        anyway. Revoking takes effect for new connections; a certificate WebKit
+        has already been told to accept stays accepted until the browser is
+        restarted.
+    </div>
+    {%rows}
+    <script type="text/javascript">{%javascript}</script>
+</body>
+</html>
+]==]
+
+local function format_time (t)
+    return os.date("%Y-%m-%d %H:%M", t)
+end
+
+local function build_rows ()
+    local rows = error_page.certificate_exceptions()
+
+    if #rows == 0 then
+        return "<p class=\"empty\">No certificate exceptions are stored.</p>", "none"
+    end
+
+    local out = {
+        "<table><tr><th>Host</th><th>Trusted on</th><th>Lapses on</th><th></th></tr>",
+    }
+    for _, row in ipairs(rows) do
+        out[#out+1] = string.format(
+            "<tr><td class=\"host\">%s</td><td class=\"when\">%s</td>"
+            .. "<td class=\"when\">%s</td>"
+            .. "<td class=\"act\"><a class=\"revoke\" href=\"#\" data-host=\"%s\">revoke</a></td></tr>",
+            lousy.util.escape(row.host), format_time(row.created),
+            format_time(row.expires), lousy.util.escape(row.host))
+    end
+    out[#out+1] = "</table>"
+
+    local count = string.format("%d host%s", #rows, #rows == 1 and "" or "s")
+    return table.concat(out), count
+end
+
+local main_js = [=[
+    document.addEventListener("click", function (event) {
+        var link = event.target;
+        if (!link.classList || !link.classList.contains("revoke")) return;
+        event.preventDefault();
+        var host = link.dataset.host;
+        revoke_certificate(host).then(function () { window.location.reload(); });
+    });
+]=]
+
+local export_funcs = {
+    revoke_certificate = function (host)
+        assert(type(host) == "string", "expected a host name")
+        return error_page.remove_certificate_exception(host)
+    end,
+}
+
+chrome.add("certs", function ()
+    local rows, count = build_rows()
+    return (string.gsub(html_template, "{%%(%w+)}", {
+        stylesheet = chrome.stylesheet .. _M.stylesheet,
+        javascript = main_js,
+        rows = rows,
+        count = count,
+    }))
+end, nil, export_funcs)
+
+add_binds("normal", {
+    { "^gC$", "Open the certificate exception manager.",
+        function (w) w:navigate("skull://certs/") end },
+})
+
+add_cmds({
+    { ":certs", "Open the certificate exception manager.",
+        function (w) w:navigate("skull://certs/") end },
+})
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -34,6 +34,17 @@ -- @type string -- @readwrite
 _M.cert_db_path = luakit.data_dir .. "/allowed_certificates.db"
 
+--- How long a stored certificate exception stays valid, in seconds.
+--
+-- An exception is a decision to trust one certificate that failed
+-- verification. Keeping it forever means a certificate the user waved through
+-- once is still trusted long after the reason for it is gone, so it lapses and
+-- has to be made again.
+--
+-- @type number
+-- @readwrite
+_M.cert_exception_lifetime = 90 * 24 * 60 * 60
+
 --- Connect to and initialize the bookmarks database.
 local function init_cert_db()
     _M.cert_db = sqlite3{ filename = _M.cert_db_path }
@@ -54,6 +65,64 @@ ]] end
 init_cert_db()
 _M.cert_db:exec("UPDATE allowed_certificates SET allowed = 0")
+
+--- Drop stored exceptions older than `cert_exception_lifetime`.
+-- @treturn number The number of exceptions removed.
+_M.expire_certificate_exceptions = function ()
+    local cutoff = os.time() - _M.cert_exception_lifetime
+    local stale = _M.cert_db:exec(
+        "SELECT COUNT(*) AS n FROM allowed_certificates WHERE created < ?", {cutoff})
+    local n = tonumber(stale and stale[1] and stale[1].n) or 0
+    if n > 0 then
+        _M.cert_db:exec("DELETE FROM allowed_certificates WHERE created < ?", {cutoff})
+        msg.info("dropped %d expired certificate exception%s", n, n == 1 and "" or "s")
+    end
+    return n
+end
+
+--- List every stored certificate exception.
+-- @treturn table Rows with `host`, `created` and `expires` fields.
+_M.certificate_exceptions = function ()
+    _M.expire_certificate_exceptions()
+    local rows = _M.cert_db:exec(
+        "SELECT host, created FROM allowed_certificates ORDER BY created DESC") or {}
+    for _, row in ipairs(rows) do
+        row.created = tonumber(row.created) or 0
+        row.expires = row.created + _M.cert_exception_lifetime
+    end
+    return rows
+end
+
+--- Whether a host is being trusted because of a stored exception rather than
+-- because its certificate verified.
+-- @tparam string host The host to look up.
+-- @treturn boolean
+_M.has_certificate_exception = function (host)
+    if not host or host == "" then return false end
+    local rows = _M.cert_db:exec(
+        "SELECT 1 AS present FROM allowed_certificates WHERE host=? AND created >= ?",
+        {host, os.time() - _M.cert_exception_lifetime})
+    return rows ~= nil and #rows > 0
+end
+
+--- Remove a stored certificate exception.
+--
+-- WebKit keeps the certificate it was told to allow until the browser is
+-- restarted, so the warning comes back on the next run rather than at once.
+--
+-- @tparam string host The host whose exception should be dropped.
+-- @treturn boolean Whether a row was removed.
+_M.remove_certificate_exception = function (host)
+    assert(type(host) == "string" and host ~= "", "expected a host name")
+    local before = _M.cert_db:exec(
+        "SELECT COUNT(*) AS n FROM allowed_certificates WHERE host=?", {host})
+    local n = tonumber(before and before[1] and before[1].n) or 0
+    if n == 0 then return false end
+    _M.cert_db:exec("DELETE FROM allowed_certificates WHERE host=?", {host})
+    return true
+end
+
+_M.expire_certificate_exceptions()
 
 --- HTML template for error page content.
 -- @type string
@@ -312,7 +381,9 @@ ["bad-identity"] = "The certificate does not match the expected identity of the"             .. " site that it was retrieved from.",
         ["not-activated"] = "The certificate's activation time is still in the future.",
         expired = "The certificate has expired.",
-        insecure = "The certificate has been revoked.",
+        revoked = "The certificate has been revoked.",
+        insecure = "The certificate was signed with an algorithm that is"
+            .. " considered insecure.",
         ["generic-error"] = "Error not specified.",
     }
 
@@ -357,7 +428,9 @@ -- allow any certificates for this host that are not allowed         -- yet. if there's no such certificates in db -- show error
         -- page
         local host = lousy.uri.parse(v.uri).host
-        local certs = _M.cert_db:exec("SELECT cert AS cert FROM allowed_certificates WHERE host=? and allowed=0",
+        _M.expire_certificate_exceptions()
+        local certs = _M.cert_db:exec(
+            "SELECT cert AS cert FROM allowed_certificates WHERE host=? AND allowed=0",
             {host})
         if certs and #certs > 0 then
             luakit.allow_certificate(host, certs[1].cert)
@@ -383,7 +456,8 @@ vv:reload()                 end,
             },
             {
-                label = "Ignore danger permanently",
+                label = string.format("Trust this certificate for %d days",
+                    math.floor(_M.cert_exception_lifetime / 86400)),
                 callback = function(vv)
                     luakit.allow_certificate(host, cert)
                     -- save certificate to trusted store
@@ -14,10 +14,26 @@ local wc = require("lousy.widget.common") 
 local _M = {}
 
+-- Loaded lazily: error_page pulls in chrome and webview, and this widget is
+-- built while those are still being set up.
+local function has_exception (uri)
+    local error_page = package.loaded.error_page
+    if not error_page or not error_page.has_certificate_exception then return false end
+    local ok, host = pcall(function () return lousy.uri.parse(uri).host end)
+    return ok and error_page.has_certificate_exception(host) or false
+end
+
 local widgets = {
     update = function (w, ssl)
         local trusted = w.view:ssl_trusted()
-        if trusted == true then
+        if trusted == true and has_exception(w.view.uri) then
+            -- WebKit reports no certificate errors once it has been told to
+            -- allow one, so a certificate the user waved through looks exactly
+            -- like one that verified. It is not the same thing.
+            ssl.fg = theme.notrust_fg
+            ssl.text = "(exception)"
+            ssl:show()
+        elseif trusted == true then
             ssl.fg = theme.trust_fg
             ssl.text = "(trust)"
             ssl:show()
@@ -0,0 +1,72 @@+--- Test the store of certificate exceptions.
+--
+-- An exception is a decision to trust one certificate that failed
+-- verification. It used to last forever and there was no way to see or undo
+-- it, so these tests pin the three things that changed: exceptions lapse, they
+-- can be listed, and they can be revoked.
+
+local assert = require "luassert"
+local error_page = require "error_page"
+
+local T = {}
+
+local function store (host, created)
+    error_page.cert_db:exec(
+        "INSERT OR REPLACE INTO allowed_certificates VALUES (NULL, ?, ?, ?, ?)",
+        {host, "-----BEGIN CERTIFICATE-----\nnot a real one\n-----END CERTIFICATE-----",
+         created, 0})
+end
+
+local function clear ()
+    error_page.cert_db:exec("DELETE FROM allowed_certificates")
+end
+
+T.test_exceptions_are_listed_newest_first = function ()
+    clear()
+    store("old.example", os.time() - 100)
+    store("new.example", os.time())
+
+    local rows = error_page.certificate_exceptions()
+    assert.equal(2, #rows)
+    assert.equal("new.example", rows[1].host)
+    assert.equal("old.example", rows[2].host)
+    assert.equal(rows[1].created + error_page.cert_exception_lifetime, rows[1].expires)
+    clear()
+end
+
+T.test_exceptions_lapse_once_they_are_old_enough = function ()
+    clear()
+    store("stale.example", os.time() - error_page.cert_exception_lifetime - 1)
+    store("fresh.example", os.time())
+
+    assert.is_false(error_page.has_certificate_exception("stale.example"))
+    assert.is_true(error_page.has_certificate_exception("fresh.example"))
+
+    -- Listing sweeps the expired rows rather than just hiding them.
+    local rows = error_page.certificate_exceptions()
+    assert.equal(1, #rows)
+    assert.equal("fresh.example", rows[1].host)
+    clear()
+end
+
+T.test_an_exception_can_be_revoked = function ()
+    clear()
+    store("revoke.example", os.time())
+    assert.is_true(error_page.has_certificate_exception("revoke.example"))
+
+    assert.is_true(error_page.remove_certificate_exception("revoke.example"))
+    assert.is_false(error_page.has_certificate_exception("revoke.example"))
+
+    -- Revoking something that is not there says so instead of pretending.
+    assert.is_false(error_page.remove_certificate_exception("revoke.example"))
+    clear()
+end
+
+T.test_a_missing_host_is_not_trusted = function ()
+    assert.is_false(error_page.has_certificate_exception(nil))
+    assert.is_false(error_page.has_certificate_exception(""))
+end
+
+return T
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80