1 -- Implementation of the Gemini protocol, following version 0.24.0 of the spec
2 -- gemini://geminiprotocol.net/docs/protocol-specification.gmi
4 local socket
= require("socket")
5 local ssl
= require("ssl")
6 local url
= require("socket.url")
7 local sha256
= require("ffi/sha2").sha256
8 local _
= require("gettext")
9 local T
= require("ffi/util").template
11 -- trusted_times_step: consider connections this far apart as separate enough to
12 -- reinforce TOFU-trust
13 local trusted_times_step
= 3600
14 local default_port
= "1965"
18 -- print url, stripping default port and, optionally, "gemini://"
19 function gemini
.showUrl(u
, strip_gemini_scheme
)
22 if type(u
) == "string" then
28 for k
,v
in pairs(purl
) do
31 if copy
.port
== "1965" then
34 if strip_gemini_scheme
and copy
.scheme
== "gemini" then
37 return url
.build(copy
):gsub("^//","",1)
40 function gemini
.upUrl(u
)
41 local up
= url
.absolute(u
, ".")
43 up
= url
.absolute(u
, "..")
48 -- Does any necessary percent-escaping and stripping to make a
49 -- socket.url-parsed URL compliant with RFC 3986. '%' is encoded only where it
50 -- is not part of valid percent-encoding. Modifies purl in place.
51 function gemini
.escape(purl
)
52 local function escape(s
, unescaped
)
56 -- hairy encoding of '%' where not followed by two hex chars:
57 s
= s
:gsub("%%%%","%%25%%"):gsub("%%%%","%%25%%"):gsub("%%%f[^%%%x]","%%25"):gsub("%%(%x)%f[%X]","%%25%1")
58 return s
:gsub("[^"..unescaped
.."%%]", function(c
)
59 return string.format("%%%x",c
:byte(1))
62 local unreserved
= "%w%-._~"
63 local subdelims
= "!$&'()*+,;="
64 local pchar
= unreserved
..subdelims
..":@"
65 purl
.host
= escape(purl
.host
, unreserved
..subdelims
)
66 purl
.path
= escape(purl
.path
, pchar
.."/")
67 purl
.query
= escape(purl
.query
, pchar
.."/?")
68 purl
.port
= purl
.port
and purl
.port
:gsub("[^%d]","")
72 ["40"] = _("temporary failure"),
73 ["41"] = _("server unavailable"),
74 ["42"] = _("CGI error"),
75 ["43"] = _("proxy error"),
76 ["44"] = _("too many requests"),
77 ["50"] = _("permanent failure"),
78 ["51"] = _("resource not found"),
79 ["52"] = _("resource gone"),
80 ["53"] = _("proxy request refused"),
81 ["59"] = _("bad request"),
82 ["60"] = _("client certificate required"),
83 ["61"] = _("client certificate not authorised"),
84 ["62"] = _("client certificate not valid"),
87 local ipv6_available
= socket
.tcp6() ~= nil
89 -- @table trust_store Opaque table to be stored and passed to subsequent calls
91 -- @param cafile CA certificates to use for CA-based trust. nil to disable.
92 -- WARNING: as of luasec-1.3.2, CA-based trust checking seems to be thoroughly
93 -- broken and should not be used:
94 -- https://github.com/lunarmodules/luasec/issues/161
96 -- @func check_trust_cb Callback to check for trust of a public key when we've
97 -- previously TOFU-trusted another;
98 -- parameters: fullhost, digest, trusted_times, expiry, cb;
99 -- callback should call cb("always") if the new pubkey should be added to
100 -- the trust store (replacing the old one),
101 -- or cb("once") to proceed with the connection without trusting the pubkey,
102 -- or else cb() to abort connection.
104 -- @func trust_modified_cb Callback called without params when trust_store is
105 -- modified (e.g. so it can be updated on disk)
107 -- @func success_cb Callback called on 20 (success) response;
108 -- parameters: peer_qfile, mimetype, params, cert_info;
109 -- peer_qfile is a quasi-file representing the body,
110 -- supporting :close, :read("l"), :read("a"), and :read(n), with read
111 -- calls returning nil,"aborted" if the connection is aborted via info_cb.
113 -- @func error_cb Callback called on connection error or error codes other
115 -- parameters: errText, major, minor, meta;
116 -- errText describes the error; the rest are nil except on error code [13456],
117 -- then major and minor are the one-digit strings of the error code,
118 -- and meta is any text received in the response.
119 -- Should return nil.
121 -- @func info_cb Optional callback to present the progress message
122 -- given as its parameter. Return false to abort the connection.
124 -- @bool confirm_new_tofu Call check_trust_cb (with trusted_times = 0)
125 -- before trusting a key for a new host.
127 -- @string proxy Optional host to proxy request via.
129 -- @string titan_upload_body Optional body for upload via the Titan protocol,
130 -- following gemini://transjovian.org/titan/page/The%20Titan%20Specification
131 function gemini
.makeRequest(u
, key
, cert
, cafile
, trust_store
, check_trust_cb
, trust_modified_cb
, success_cb
, error_cb
, info_cb
, confirm_new_tofu
, proxy
, titan_upload_body
)
132 info_cb
= info_cb
or function() return true end
133 local purl
= url
.parse(u
, {scheme
= "gemini", port
= default_port
})
135 return error_cb(T(_("Failed to parse URL: %1"), u
))
137 if purl
.port
== default_port
then
140 if purl
.scheme
== "gemini" then
144 if not purl
.path
or purl
.path
== "" then
152 return error_cb(T(_("URL too long: %1"), u
))
155 local openssl_options
= {"all", "no_tlsv1", "no_tlsv1_1"}
157 -- Do not allow connecting with a client certificate using TLSv1.2,
158 -- which does not encrypt the certificate.
159 -- (The gemini spec only says that we should warn the user before
160 -- doing so, but that seems not to be possible with luasec.)
161 table.insert(openssl_options
, "no_tlsv1_2")
163 local context
, err
= ssl
.newcontext({
166 options
= openssl_options
,
172 return error_cb(T(_("Error initialising TLS context: %1"), err
))
176 if ipv6_available
then
177 peer
, err
= socket
.tcp()
179 peer
, err
= socket
.tcp4()
182 return error_cb(T(_("Error initialising TCP: %1"), err
))
185 local pretty_url
= gemini
.showUrl(u
, true)
186 local function info(stage
, fast
)
187 return info_cb(T("%1\n%2...", pretty_url
, stage
), fast
)
189 if not info("Connecting") then
193 local timeout
= 1 -- not too short, because each call to info_cb costs 100ms
194 peer
:settimeout(timeout
)
196 local function with_timeouts(cb
, stage
, errmsg_pat
, fast
)
202 if e
== "closed" then
204 elseif e
== "timeout" or e
== "wantread" or e
== "wantwrite" then
206 if not info(stage
, fast
) then
207 return false, "aborted"
210 local __
, ___
, sock_err
= socket
.select(
211 e
== "wantread" and {peer
} or nil,
212 (e
== "timeout" or e
== "wantwrite") and {peer
} or nil, 1)
213 if sock_err
== nil then
215 elseif sock_err
~= "timeout" then
216 return error_cb(T(error_cb
, sock_err
))
220 return error_cb(T(errmsg_pat
, e
))
224 local function get_send_cb(data
)
228 ret
, e
, i
= peer
:send(data
, i
+1)
232 local function get_recv_cb(p
)
235 local ret
, e
, partial
= peer
:receive(p
)
237 return (acc
or "") .. ret
240 acc
= (acc
or "") .. partial
242 if e
== "closed" and acc
then
252 host
, port
= proxy
:match("^([^:]*):(%d+)")
259 port
= tonumber(purl
.port
or default_port
)
261 local function get_connect_cb()
268 return peer
:connect(host
, port
)
272 if not with_timeouts(get_connect_cb(),
273 _("Connecting"), _("Error connecting to peer: %1"), true)
278 peer
, err
= ssl
.wrap(peer
, context
)
280 return error_cb(T(_("Error on ssl.wrap: %1"), err
))
282 peer
:settimeout(timeout
)
285 if not with_timeouts(function() return peer
:dohandshake() end,
286 _("Handshaking"), _("Error on handshake: %1"))
297 fullhost
= fullhost
.. ":" .. purl
.port
301 local peer_cert
= peer
:getpeercertificate()
302 if not peer_cert
then
304 return error_cb(_("Failed to obtain peer certificate"))
306 local pk_hash
= sha256(peer_cert
:pubkey())
307 local digest
= peer_cert
:digest("sha256")
308 local expiry
= peer_cert
:notafter()
310 local function do_connection(trusted
)
311 if not with_timeouts(get_send_cb(u
.."\r\n"),
312 _("Requesting"), _("Error sending request: %1"))
318 if titan_upload_body
then
319 -- XXX hack: give server a second to respond with an error to our
320 -- upload attempt. If we just start sending, the connection will
321 -- be closed and then it seems we can't receive the early
323 status
, err
= peer
:receive("*l")
324 if err
and err
~= "wantread" and err
~= "wantwrite" then
325 error_cb(T(_("Error receiving early response status: %1"), err
))
330 local __
, aborted
= with_timeouts(get_send_cb(titan_upload_body
),
331 _("Uploading"), _("Error during upload: %1"))
338 -- Note: "*l" reads a line terminated either with \n or \r\n or
339 -- EOF, whereas the Gemini header must be terminated with \r\n.
340 -- However, no \n is allowed before the \r\n, so the only effect
341 -- is to accept some invalid responses.
342 status
= status
or with_timeouts(get_recv_cb("*l"),
343 _("Receiving header"), _("Error receiving response status : %1"))
348 local major
, minor
, post
= status
:match("^([1-6])([0-9])(.*)")
349 if not major
or not minor
then
350 error_cb(T(_("Invalid response status line: %1"), status
:sub(1,64)))
353 local meta
= post
:match("^ (.*)") or ""
356 local token_chars
= "[a-zA-Z0-9!#$%%&'*+-.^_`{|}-]" -- from RFC 2045
357 local mimetype
, params_str
= meta
:match("^("..token_chars
.."+/"..token_chars
.."+)(.*)")
358 mimetype
= mimetype
or "text/gemini"
360 while params_str
and params_str
~= "" do
361 local param
, rest
= params_str
:match("^;%s*("..token_chars
.."+=\"[^\"]*\")(.*)")
363 param
, rest
= params_str
:match("^;%s*("..token_chars
.."+="..token_chars
.."+)(.*)")
366 -- ignore unparseable parameters
370 table.insert(params
, param
)
373 local peer_qfile
= {}
374 function peer_qfile
:read(x
)
375 x
= (type(x
) == "string" and x
:match("^[al]$") and "*"..x
)
376 or (type(x
) == "number" and x
)
377 return with_timeouts(get_recv_cb(x
),
378 _("Receiving body"), _("Error receiving body: %1"))
380 function peer_qfile
:close()
386 trusted_times
= trusted
and trusted
.trusted_times
or 0,
387 ca
= trusted
and trusted
.ca
389 success_cb(peer_qfile
, mimetype
, params
, cert_info
)
392 if tonumber(major
) < 1 or tonumber(major
) > 6 then
393 error_cb(_("Server returns invalid error code."))
395 local errText
= errTexts
[major
..minor
]
397 errText
= errTexts
[major
.."0"]
399 error_cb(errText
, major
, minor
, meta
)
404 local function set_trust(times
)
405 trust_store
[fullhost
] = {
410 last_trust_time
= os
.time(),
416 if peer
:getpeerverification() then
418 for _i
, c
in ipairs( peer
:getpeerchain() ) do
421 return do_connection({ ca
= ca_cert
:issuer() })
425 local function trust_cb_cb(user_trust
)
426 if user_trust
== "always" then
428 do_connection(trust_store
[fullhost
])
429 elseif user_trust
== "once" then
436 local trusted
= trust_store
[fullhost
]
438 -- Trust On First Use
439 if confirm_new_tofu
then
440 check_trust_cb(fullhost
, digest
, 0, "", trust_cb_cb
)
442 trust_cb_cb("always")
445 if trusted
.pk_hash
== pk_hash
then
446 local now
= os
.time()
447 if now
- trusted
.last_trust_time
> trusted_times_step
then
448 trusted
.trusted_times
= trusted
.trusted_times
+ 1
449 trusted
.last_trust_time
= now
452 if trusted
.digest
~= digest
then
453 -- Extend trust to update cert with same public key.
454 trusted
.digest
= digest
455 trusted
.expiry
= expiry
458 do_connection(trusted
)
460 check_trust_cb(fullhost
, digest
, trusted
.trusted_times
, trusted
.expiry
, trust_cb_cb
)