factor out SchemeProxies
[gemini.koplugin.git] / gemini.lua
blobcbc0a818936b96f671dda298fac857ae9b0fb675
1 local socket = require("socket")
2 local ssl = require("ssl")
3 local url = require("socket.url")
4 local sha256 = require("ffi/sha2").sha256
5 local _ = require("gettext")
6 local T = require("ffi/util").template
8 -- trusted_times_step: consider connections this far apart as separate enough to
9 -- reinforce TOFU-trust
10 local trusted_times_step = 3600
11 local default_port = "1965"
13 local gemini = {}
15 -- print url, stripping default port and, optionally, "gemini://"
16 function gemini.showUrl(u, strip_gemini_scheme)
17 local copy = {}
18 local purl = u
19 if type(u) == "string" then
20 purl = url.parse(u)
21 if purl == nil then
22 return u
23 end
24 end
25 for k,v in pairs(purl) do
26 copy[k] = v
27 end
28 if copy.port == "1965" then
29 copy.port = nil
30 end
31 if strip_gemini_scheme and copy.scheme == "gemini" then
32 copy.scheme = nil
33 end
34 return url.build(copy):gsub("^//","",1)
35 end
37 function gemini.upUrl(u)
38 local up = url.absolute(u, ".")
39 if up == u then
40 up = url.absolute(u, "..")
41 end
42 return up
43 end
46 local errTexts = {
47 ["40"] = _("temporary failure"),
48 ["41"] = _("server unavailable"),
49 ["42"] = _("CGI error"),
50 ["43"] = _("proxy error"),
51 ["44"] = _("too many requests"),
52 ["50"] = _("permanent failure"),
53 ["51"] = _("resource not found"),
54 ["52"] = _("resource gone"),
55 ["53"] = _("proxy request refused"),
56 ["59"] = _("bad request"),
57 ["60"] = _("client certificate required"),
58 ["61"] = _("client certificate not authorised"),
59 ["62"] = _("client certificate not valid"),
62 -- @table trust_store Opaque table to be stored and passed to subsequent calls
64 -- @param cafile CA certificates to use for CA-based trust. nil to disable.
65 -- WARNING: as of luasec-1.3.2, CA-based trust checking seems to be thoroughly
66 -- broken and should not be used:
67 -- https://github.com/lunarmodules/luasec/issues/161
69 -- @func check_trust_cb Callback to check for trust of a public key when we've
70 -- previously TOFU-trusted another;
71 -- parameters: fullhost, digest, trusted_times, expiry, cb;
72 -- callback should call cb("always") if the new pubkey should be added to
73 -- the trust store (replacing the old one),
74 -- or cb("once") to proceed with the connection without trusting the pubkey,
75 -- or else cb() to abort connection.
77 -- @func trust_modified_cb Callback called without params when trust_store is
78 -- modified (e.g. so it can be updated on disk)
80 -- @func success_cb Callback called on 20 (success) response;
81 -- parameters: peer_qfile, purl, mimetype, params, cert_info;
82 -- peer_qfile is a quasi-file representing the body,
83 -- supporting :close, :read("l"), :read("a"), and :read(n), with read
84 -- calls returning nil,"aborted" if the connection is aborted via info_cb;
85 -- purl is the final url.parse parsed URI (after any redirects).
87 -- @func error_cb Callback called on connection error or error codes 1,4,5,6;
88 -- parameters: errText, purl, major, minor, meta;
89 -- errText describes the error; the rest are nil except on error code [1456],
90 -- then purl is the final url.parse parsed URI (after any redirects),
91 -- major and minor are the one-digit strings of the error code,
92 -- and meta is any text received in the response.
94 -- @func perm_redir_cb Optional callback called on permanent redirect (31)
95 -- parameters: old_uri, new_uri
97 -- @func info_cb Optional callback to present the progress message
98 -- given as its parameter. Return false to abort the connection.
100 -- @bool confirm_new_tofu Call check_trust_cb (with trusted_times = 0)
101 -- before trusting a key for a new host.
103 -- @string proxy Optional host to proxy request via.
104 function gemini.makeRequest(uri, key, cert, cafile, trust_store, check_trust_cb, trust_modified_cb, success_cb, error_cb, perm_redir_cb, info_cb, confirm_new_tofu, proxy)
105 info_cb = info_cb or function() return true end
106 local inner
107 inner = function(u, num_redirects)
108 local purl = url.parse(u, {scheme = "gemini", port = default_port})
109 if purl.port == default_port then
110 purl.port = nil
112 if purl.scheme == "gemini" then
113 purl.fragment = nil
114 purl.userinfo = nil
115 purl.password = nil
116 if not purl.path or purl.path == "" then
117 purl.path = "/"
120 u = url.build(purl)
122 if #u > 1024 then
123 return error_cb(T(_("URL too long: %1"), u))
126 local openssl_options = {"all", "no_tlsv1", "no_tlsv1_1"}
127 if key then
128 -- Do not allow connecting with a client certificate using TLSv1.2,
129 -- which does not encrypt the certificate.
130 -- (The gemini spec only says that we should warn the user before
131 -- doing so, but that seems not to be possible with luasec.)
132 table.insert(openssl_options, "no_tlsv1_2")
134 local context, err = ssl.newcontext({
135 mode = "client",
136 protocol = "any",
137 options = openssl_options,
138 cafile = cafile,
139 key = key,
140 certificate = cert,
142 if not context then
143 return error_cb(T(_("Error initialising TLS context: %1"), err))
146 local peer
147 peer, err = socket.tcp()
148 if not peer then
149 return error_cb(T(_("Error initialising TCP: %1"), err))
152 local pretty_url = gemini.showUrl(u, true)
153 local function info(stage, fast)
154 return info_cb(T("%1\n%2...", pretty_url, stage), fast)
156 if not info("Connecting") then return end
158 local timeout = 1 -- not too short, because each call to info_cb costs 100ms
159 peer:settimeout(timeout)
160 local function with_timeouts(cb, stage, errmsg_pat, fast, accumulate_partials)
161 local acc = accumulate_partials and ""
162 local do_select
163 do_select = function(e)
164 if not info(stage, fast) then return false end
165 fast = true
166 local __, sock_err = socket.select(
167 e == "wantread" and {peer} or nil,
168 (e == "timeout" or e == "wantwrite") and {peer} or nil, 1)
169 if sock_err == "timeout" then
170 return do_select(e)
172 return true
174 local ret, e, partial
175 while not ret do
176 ret, e, partial = cb()
177 if not ret and partial and acc then
178 acc = acc .. partial
180 if e == "timeout" or e == "wantread" or e == "wantwrite" then
181 if not do_select(e) then
182 return nil, "aborted"
184 elseif e == "closed" then
185 break
186 elseif not ret then
187 return error_cb(T(errmsg_pat, e))
190 if acc then
191 return acc .. (ret or "")
192 else
193 return ret
197 local host, port
198 if proxy then
199 host, port = proxy:match("^([^:]*):(%d+)")
200 if not host then
201 host = proxy
202 port = default_port
204 else
205 host = purl.host
206 port = tonumber(purl.port or default_port)
208 if not with_timeouts(function() return peer:connect(host, port) end,
209 _("Connecting"), _("Error connecting to peer: %1"), true)
210 then return end
212 peer, err = ssl.wrap(peer, context)
213 if not peer then
214 return error_cb(T(_("Error on ssl.wrap: %1"), err))
216 peer:settimeout(timeout)
217 peer:sni(host)
219 if not with_timeouts(function() return peer:dohandshake() end,
220 _("Handshaking"), _("Error on handshake: %1"))
221 then return end
223 local fullhost
224 if proxy then
225 fullhost = proxy
226 else
227 fullhost = purl.host
228 if purl.port then
229 fullhost = fullhost .. ":" .. purl.port
233 local peer_cert = peer:getpeercertificate()
234 if not peer_cert then
235 return error_cb(_("Failed to obtain peer certificate"))
237 local pk_hash = sha256(peer_cert:pubkey())
238 local digest = peer_cert:digest("sha256")
239 local expiry = peer_cert:notafter()
241 local function do_connection(trusted)
242 if not with_timeouts(function() return peer:send(u.."\r\n") end,
243 _("Requesting"), _("Error sending request: %1"))
244 then return end
246 local status = with_timeouts(function() return peer:receive("*l") end,
247 _("Receiving header"), _("Error receiving response status : %1"))
248 if not status then return end
250 local major, minor, post = status:match("^([1-6])([0-9])(.*)")
251 if not major or not minor then
252 return error_cb(T(_("Invalid response status line: %1", status:sub(1,64))))
254 local meta = post:match("^ (.*)") or ""
256 if major == "2" then
257 local token_chars = "[a-zA-Z0-9!#$%%&'*+-.^_`{|}-]" -- from RFC 2045
258 local mimetype, params_str = meta:match("^("..token_chars.."+/"..token_chars.."+)(.*)")
259 mimetype = mimetype or "text/gemini"
260 local params = {}
261 while params_str and params_str ~= "" do
262 local param, rest = params_str:match("^;%s*("..token_chars.."+=\"[^\"]*\")(.*)")
263 if not param then
264 param, rest = params_str:match("^;%s*("..token_chars.."+="..token_chars.."+)(.*)")
266 if not param then
267 -- ignore unparseable parameters
268 break
270 params_str = rest
271 table.insert(params, param)
274 local peer_qfile = {}
275 function peer_qfile:read(x)
276 x = (type(x) == "string" and x:match("^[al]$") and "*"..x)
277 or (type(x) == "number" and x)
278 return with_timeouts(function() return peer:receive(x) end,
279 _("Receiving body"), _("Error receiving body: %1"), false, true)
281 function peer_qfile:close()
282 peer:close()
284 local cert_info = {
285 fp = digest,
286 expiry = expiry,
287 trusted_times = trusted and trusted.trusted_times or 0,
288 ca = trusted and trusted.ca
290 success_cb(peer_qfile, purl, mimetype, params, cert_info)
291 else
292 peer:close()
293 if major == "3" then
294 if num_redirects >= 5 then
295 return error_cb(_("Too many redirects."))
297 local new_uri = url.absolute(purl, meta)
298 if minor == "1" and perm_redir_cb then
299 perm_redir_cb(u, new_uri)
301 inner(new_uri, num_redirects + 1)
302 elseif tonumber(major) < 1 or tonumber(major) > 6 then
303 error_cb(_("Server returns invalid error code."))
304 else
305 local errText = errTexts[major..minor]
306 if not errText then
307 errText = errTexts[major.."0"]
309 error_cb(errText, purl, major, minor, meta)
314 local function set_trust(times)
315 trust_store[fullhost] = {
316 pk_hash = pk_hash,
317 digest = digest,
318 expiry = expiry,
319 trusted_times = 1,
320 last_trust_time = os.time(),
322 trust_modified_cb()
325 if cafile then
326 if peer:getpeerverification() then
327 local ca_cert
328 for _i, c in ipairs( peer:getpeerchain() ) do
329 ca_cert = c
331 return do_connection({ ca = ca_cert:issuer() })
335 local function trust_cb_cb(user_trust)
336 if user_trust == "always" then
337 set_trust()
338 do_connection(trust_store[fullhost])
339 elseif user_trust == "once" then
340 do_connection()
341 else
342 peer:close()
346 local trusted = trust_store[fullhost]
347 if not trusted then
348 -- Trust On First Use
349 if confirm_new_tofu then
350 check_trust_cb(fullhost, digest, 0, "", trust_cb_cb)
351 else
352 trust_cb_cb("always")
354 else
355 if trusted.pk_hash == pk_hash then
356 local now = os.time()
357 if now - trusted.last_trust_time > trusted_times_step then
358 trusted.trusted_times = trusted.trusted_times + 1
359 trusted.last_trust_time = now
360 trust_modified_cb()
362 if trusted.digest ~= digest then
363 -- Extend trust to update cert with same public key.
364 trusted.digest = digest
365 trusted.expiry = expiry
366 trust_modified_cb()
368 do_connection(trusted)
369 else
370 check_trust_cb(fullhost, digest, trusted.trusted_times, trusted.expiry, trust_cb_cb)
374 return inner(uri, 0)
376 return gemini