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.
@@ -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