1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
|
import std/options
import std/os
import std/posix
import std/strutils
import lcgi_ssl
proc sdie(s: string) =
stdout.write("Cha-Control: ConnectionError 5 " & s & ": ")
ERR_print_errors_fp(stdout)
stdout.flushFile()
quit(1)
proc fopen(filename, mode: cstring): pointer {.importc, nodecl.}
proc openKnownHosts(os: PosixStream): (File, string) =
var path = getEnv("GMIFETCH_KNOWN_HOSTS")
if path == "":
let oldPath = getConfigDir() & "gmifetch/known_hosts"
let ourDir = getEnv("CHA_CONFIG_DIR")
if ourDir == "":
os.die("InternalError", "config dir missing")
path = ourDir & '/' & "gemini_known_hosts"
# for backwards compat, TODO eventually remove this
# (this has a race, but oh well.)
if fileExists(oldPath) and not fileExists(path):
moveFile(oldPath, path)
createDir(path.beforeLast('/'))
let f = cast[File](fopen(cstring(path), "a+"))
if f == nil:
os.die("InternalError", "error opening open known hosts file")
return (f, path)
proc readPost(os: PosixStream; query: var string; host, knownHostsPath: string;
knownHosts: var File; tmpEntry: var string) =
let s = newPosixStream(STDIN_FILENO).recvAll()
if (var i = s.find("input="); i != -1):
i += "input=".len
query = s.toOpenArray(i, s.high).percentDecode()
elif (var i = s.find("trust_cert="); i != -1):
i += "trust_cert=".len
let t = s.until('&', i)
if t in ["always", "yes", "no", "once"]:
i = s.find("entry=", i)
if i == -1:
os.die("InternalError", "missing entry field in POST")
i += "entry=".len
var buf = ""
for i in i ..< s.len:
if s[i] == '+':
buf &= ' '
else:
buf &= s[i]
buf = buf.percentDecode()
if t == "once" or t == "no":
tmpEntry = buf
else:
var knownHostsTmp: File
let knownHostsTmpPath = knownHostsPath & '~'
if not knownHostsTmp.open(knownHostsTmpPath, fmWrite):
os.die("InternalError", "failed to open temp file")
var line: string
while knownHosts.readLine(line):
let j = line.find(' ')
if host.len == j and line.startsWith(host):
continue # delete this entry
knownHostsTmp.writeLine(line)
knownHostsTmp.writeLine(buf)
knownHostsTmp.close()
knownHosts.close()
try:
moveFile(knownHostsTmpPath, knownHostsPath)
except IOError:
os.die("InternalError failed to move tmp file")
if not knownHosts.open(knownHostsPath, fmRead):
os.die("InternalError", "failed to reopen known_hosts")
else:
os.die("InternalError invalid POST: wrong trust_cert")
else:
os.die("InternalError invalid POST: no input or trust_cert")
type CheckCertResult = enum
ccrNotFound, ccrNewExpiration, ccrFoundInvalid, ccrFoundValid
proc checkCert(os: PosixStream; theirDigest, host: string;
storedDigest: var string; theirTime: var Time; knownHosts: File;
tmpEntry: string): CheckCertResult =
var line = tmpEntry
var found = line.until(' ') == host
while not found and knownHosts.readLine(line):
found = line.until(' ') == host
if not found:
return ccrNotFound
let ss = line.split(' ')
if ss.len < 3:
os.die("InternalError", "wrong line in known_hosts file")
if ss[1] != "sha256":
os.die("InternalError", "unsupported digest format in known_hosts file")
storedDigest = ss[2]
if storedDigest != theirDigest:
return ccrFoundInvalid
if ss.len > 3:
if (let x = parseUInt64(ss[3], allowSign = false); x.isSome):
if Time(x.get) == theirTime:
return ccrFoundValid
else:
os.die("InternalError", "invalid time in known_hosts file")
return ccrNewExpiration
proc hashBuf(ibuf: openArray[uint8]): string =
const HexTable = "0123456789ABCDEF"
var len2: cuint = 0
var buf = newSeq[char](EVP_MAX_MD_SIZE)
let mdctx = EVP_MD_CTX_new()
if mdctx == nil:
sdie("failed to initialize MD_CTX")
if EVP_DigestInit_ex(mdctx, EVP_sha256(), nil) == 0:
sdie("failed to initialize sha256")
if EVP_DigestUpdate(mdctx, unsafeAddr ibuf[0], cuint(ibuf.len)) == 0:
sdie("failed to update digest")
if EVP_DigestFinal_ex(mdctx, addr buf[0], len2) == 0:
sdie("failed to finalize digest")
EVP_MD_CTX_free(mdctx);
# hex encode buf
result = ""
for i in 0 ..< int(len2):
if i != 0:
result &= ':'
let u = uint8(buf[i])
result &= HexTable[(u shr 4) and 0xF]
result &= HexTable[u and 0xF]
proc connect(os: PosixStream; ssl: ptr SSL; host, port: string;
knownHosts: File; storedDigest, theirDigest: var string;
theirTime: var Time; tmpEntry: string): CheckCertResult =
let hostname = host & ':' & port
discard SSL_set1_host(ssl, cstring(hostname))
if SSL_connect(ssl) <= 0:
sdie("failed to connect")
if SSL_do_handshake(ssl) <= 0:
sdie("failed handshake")
let cert = SSL_get_peer_certificate(ssl)
if cert == nil:
sdie("failed to get peer certificate")
let pkey = X509_get0_pubkey(cert)
if pkey == nil:
sdie("failed to decode public key")
var pubkeyBuf: array[16384, uint8]
let len = i2d_PUBKEY(pkey, nil);
if len * 3 > pubkeyBuf.len:
os.die("InternalError", "pubkey too long")
var r = addr pubkeyBuf[0]
if i2d_PUBKEY(pkey, addr r) != len:
os.die("InternalError", "wat")
theirDigest = pubkeyBuf.toOpenArray(0, len - 1).hashBuf()
let notAfter = X509_get0_notAfter(cert)
var theirTm: Tm
if ASN1_TIME_to_tm(notAfter, addr theirTm) == 0:
sdie("Failed to parse time");
if getEnv("CHA_INSECURE_SSL_NO_VERIFY") != "1":
if X509_cmp_current_time(X509_get0_notBefore(cert)) >= 0 or
X509_cmp_current_time(notAfter) <= 0:
os.die("InvalidResponse", "received an expired certificate");
theirTime = mktime(theirTm)
X509_free(cert)
return os.checkCert(theirDigest, host, storedDigest, theirTime, knownHosts,
tmpEntry)
proc readResponse(os: PosixStream; ssl: ptr SSL; reqBuf: string) =
var buffer = newString(4096)
var n = 0
while n < buffer.len:
let m = SSL_read(ssl, addr buffer[n], cint(buffer.len - n))
if m == 0:
break
n += m
let status0 = buffer[0]
let status1 = buffer[1]
if status0 notin AsciiDigit or status1 notin AsciiDigit:
os.die("InvalidResponse", "invalid status code")
while n < 1024 + 3: # max meta len is 1024
let m = SSL_read(ssl, addr buffer[n], cint(buffer.len - n))
if m == 0:
break
n += m
let i = buffer.find("\r\n")
if i == -1:
os.die("InvalidResponse", "invalid status line")
var meta = buffer.substr(3, i - 1)
if '\n' in meta:
os.die("InvalidResponse", "invalid status line")
case status0
of '1': # input
# META is the prompt.
let it = if status1 == '1': "password" else: "search"
os.sendDataLoop("""Content-Type: text/html
<!DOCTYPE html>
<title>Input required</title>
<base href='""" & reqBuf.htmlEscape() & """'>
<h1>Input required</h1>
<p>
""" & meta.htmlEscape() & """
<p>
<form method=POST><input type='""" & it & """' name='input'></form>
""")
of '2': # success
# META is the content type.
if meta == "":
meta = "text/gemini"
os.sendDataLoop("Content-Type: " & meta & "\n\n")
os.sendDataLoop(buffer.toOpenArray(i + 2, n - 1))
while true:
let n = SSL_read(ssl, addr buffer[0], cint(buffer.len))
if n == 0:
break
os.sendDataLoop(buffer.toOpenArray(0, int(n) - 1))
of '3': # redirect
# META is the redirection URL.
let c = if status1 == '0':
'7' # temporary
else:
'1' # permanent
os.sendDataLoop("Status: 30" & c & "\nLocation: " & meta & "\n\n")
of '4': # temporary failure
# META is additional information.
let tmp = case status1
of '1': "Server unavailable"
of '2': "CGI error"
of '3': "Proxy error"
of '4': "Slow down!"
else: "Temporary failure" # no additional information provided in the code
os.sendDataLoop("""Content-Type: text/html
<!DOCTYPE html>
<title>Temporary failure</title>
<h1>""" & tmp & """</h1>
<p>
""" & meta.htmlEscape())
of '5': # permanent failure
# META is additional information.
let tmp = case status1
of '1': "Not found"
of '2': "Gone"
of '3': "Proxy request refused"
of '4': "Bad request"
else: "Permanent failure"
os.sendDataLoop("""Content-Type: text/html
<!DOCTYPE html>
<title>Permanent failure</title>
<h1>""" & tmp & """</h1>
<p>
""" & meta.htmlEscape())
of '6': # certificate failure
# META is additional information.
let tmp = case status1
of '1': "Certificate not authorized"
of '2': "Certificate not valid"
else: "Certificate failure"
os.sendDataLoop("""Content-Type: text/html
<!DOCTYPE html>
<title>Certificate failure</title>
<h1>""" & tmp & """</h1>
<p>
""" & meta.htmlEscape())
else:
os.die("InvalidResponse", "Wrong status code")
proc main() =
let os = newPosixStream(STDOUT_FILENO)
let host = getEnv("MAPPED_URI_HOST")
var (knownHosts, knownHostsPath) = os.openKnownHosts()
var port = getEnv("MAPPED_URI_PORT")
if port == "":
port = "1965"
var path = getEnv("MAPPED_URI_PATH")
if path == "":
path = "/"
var reqBuf = "gemini://" & host & path
var query = getEnv("MAPPED_URI_QUERY")
var tmpEntry = "" # for accepting a self signed cert "once"
if getEnv("REQUEST_METHOD") == "POST":
os.readPost(query, host, knownHostsPath, knownHosts, tmpEntry)
if query != "":
reqBuf &= '?' & query
reqBuf &= "\r\n"
let ssl = os.connectSSLSocket(host, port)
var storedDigest: string
var theirDigest: string
var theirTime: Time
case os.connect(ssl, host, port, knownHosts, storedDigest, theirDigest,
theirTime, tmpEntry)
of ccrFoundValid:
discard SSL_write(ssl, cstring(reqBuf), cint(reqBuf.len))
os.readResponse(ssl, reqBuf)
of ccrFoundInvalid:
os.sendDataLoop("""
Content-Type: text/html
<!DOCTYPE html>
<title>Invalid certificate</title>
<h1>Invalid certificate</h1>
<p>
The certificate received from the server does not match the
stored certificate (expected """ & storedDigest & """, but got
""" & theirDigest & """). Somebody may be tampering with your
connection.
<p>
If you are sure that this is not a man-in-the-middle attack,
please remove this host from """ & knownHostsPath & """.
""")
of ccrNotFound:
os.sendDataLoop("""
Content-Type: text/html
<!DOCTYPE html>
<title>Unknown certificate</title>
<h1>Unknown certificate</h1>
<p>
The hostname of the server you are visiting could not be found
in your list of known hosts (""" & knownHostsPath & """).
<p>
The server has sent us a certificate with the following
fingerprint:
<pre>""" & theirDigest & """</pre>
<p>
Trust it?
<form method=POST>
<input type=submit name=trust_cert value=always>
<input type=submit name=trust_cert value=once>
<input type=hidden name=entry value='""" &
host & " sha256 " & theirDigest & " " & $uint64(theirTime) & """'>
</form>
""")
of ccrNewExpiration:
os.sendDataLoop("""
Content-Type: text/html
<!DOCTYPE html>
<title>Certificated date changed</title>
<h1>Certificated date changed</h1>
<p>
The received certificate's date did not match the date in your
list of known hosts (""" & knownHostsPath & """).
<p>
The new expiration date is: """ & ($ctime(theirTime)).strip() & """.
<p>
Update it?
<form method=POST>
<input type=submit name=trust_cert value=yes>
<input type=submit name=trust_cert value=no>
<input type=hidden name=entry value='""" &
host & " sha256 " & theirDigest & " " & $uint64(theirTime) & """'>
</form>
""")
closeSSLSocket(ssl)
main()
|