1 -- This file is part of Diohsc
2 -- Copyright (C) 2020 Martin Bays <mbays@sdf.org>
4 -- This program is free software: you can redistribute it and/or modify
5 -- it under the terms of version 3 of the GNU General Public License as
6 -- published by the Free Software Foundation, or any later version.
8 -- You should have received a copy of the GNU General Public License
9 -- along with this program. If not, see http://www.gnu.org/licenses/.
11 {-# LANGUAGE LambdaCase #-}
12 {-# LANGUAGE OverloadedStrings #-}
13 {-# LANGUAGE TupleSections #-}
15 module GeminiProtocol
where
17 import Control
.Concurrent
18 import Control
.Exception
19 import Control
.Monad
(guard, mplus
, msum, unless, void
,
21 import Control
.Monad
.Trans
(lift
)
22 import Control
.Monad
.Trans
.Maybe (MaybeT
(..), runMaybeT
)
23 import Data
.Default
.Class
(def
)
25 import Data
.List
(intercalate
, intersperse,
26 isPrefixOf, stripPrefix
, transpose)
27 import Data
.Maybe (fromMaybe, isJust)
29 import Data
.X509
.CertificateStore
30 import Data
.X509
.Validation
hiding (Fingerprint
(..),
32 import Network
.Simple
.TCP
(closeSock
, connectSock
,
34 import Network
.Socket
(Socket
)
35 import Network
.TLS
as TLS
36 import Network
.TLS
.Extra
.Cipher
37 import Network
.URI
(isIPv4address
, isIPv6address
)
39 import System
.FilePath
40 import System
.IO.Error
(catchIOError
)
43 import qualified Data
.ByteString
as BS
44 import qualified Data
.ByteString
.Lazy
as BL
45 import qualified Data
.ByteString
.Lazy
.Char8
as BLC
47 import qualified Codec
.MIME
.Parse
as MIME
48 import qualified Codec
.MIME
.Type
as MIME
49 import qualified Data
.Map
as M
50 import qualified Data
.Set
as Set
51 import qualified Data
.Text
as TS
52 import qualified Data
.Text
.Encoding
as TS
53 import qualified Data
.Text
.Lazy
as T
54 import qualified Data
.Text
.Lazy
.Encoding
as T
59 import ClientSessionManager
69 import Data
.Digest
.DrunkenBishop
71 defaultGeminiPort
:: Int
72 defaultGeminiPort
= 1965
74 data MimedData
= MimedData
{mimedMimetype
:: MIME
.Type
, mimedBody
:: BL
.ByteString
}
75 deriving (Eq
,Ord
,Show)
77 showMimeType
:: MimedData
-> String
78 showMimeType
= TS
.unpack
. MIME
.showMIMEType
. MIME
.mimeType
. mimedMimetype
80 data ResponseMalformation
81 = BadHeaderTermination
87 deriving (Eq
,Ord
,Show)
90 = Input
{ inputHidden
:: Bool, inputPrompt
:: String }
91 | Success
{ successData
:: MimedData
}
92 | Redirect
{ permanent
:: Bool, redirectTo
:: URIRef
}
93 | Failure
{ failureCode
:: Int, failureInfo
:: String }
94 | MalformedResponse
{ responseMalformation
:: ResponseMalformation
}
95 deriving (Eq
,Ord
,Show)
97 data InteractionCallbacks
= InteractionCallbacks
98 { icbDisplayInfo
:: [String] -> IO ()
99 , icbDisplayWarning
:: [String] -> IO ()
100 , icbWaitKey
:: String -> IO Bool -- ^return False on interrupt, else True
101 , icbPromptYN
:: Bool -- ^default answer
108 | Socks5Proxy
String String
110 -- Note: we're forced to resort to mvars because the tls library (tls-1.5.4 at
111 -- least) uses IO rather than MonadIO in the onServerCertificate callback.
112 data RequestContext
= RequestContext
115 (MVar
(Set
.Set
(Fingerprint
, ServiceID
)))
116 (MVar
(Set
.Set Fingerprint
))
117 (MVar
(Set
.Set Fingerprint
))
118 (MVar
(Set
.Set
String))
124 initRequestContext
:: InteractionCallbacks
-> FilePath -> Bool -> SocksProxy
-> IO RequestContext
125 initRequestContext callbacks path readOnly socksProxy
=
126 let certPath
= path
</> "trusted_certs"
127 serviceCertsPath
= path
</> "known_hosts"
131 mkdirhier serviceCertsPath
132 certStore
<- fromMaybe (makeCertificateStore
[]) <$> readCertificateStore certPath
133 mTrusted
<- newMVar Set
.empty
134 mIgnoredCertErrors
<- newMVar Set
.empty
135 mWarnedCA
<- newMVar Set
.empty
136 mIgnoredCCertWarnings
<- newMVar Set
.empty
137 RequestContext callbacks certStore mTrusted mIgnoredCertErrors mWarnedCA mIgnoredCCertWarnings serviceCertsPath readOnly socksProxy
<$> newClientSessions
139 requestOfProxiesAndUri
:: M
.Map
String Host
-> URI
-> Maybe Request
140 requestOfProxiesAndUri proxies uri
=
141 let scheme
= uriScheme uri
142 in if scheme
== "file"
143 then let filePath path
144 |
('/':_
) <- path
= Just path
145 | Just path
' <- stripPrefix
"localhost" path
, ('/':_
) <- path
' = Just path
'
146 |
otherwise = Nothing
147 in LocalFileRequest
. unescapeUriString
<$> filePath
(uriPath uri
)
149 host
<- M
.lookup scheme proxies `mplus`
do
150 guard $ scheme
== "gemini" ||
"gemini+" `
isPrefixOf` scheme
151 -- ^people keep suggesting "gemini+foo" schemes for variations
152 -- on gemini. On the basis that this naming convention should
153 -- indicate that the scheme is backwards-compatible with
154 -- actual gemini, we handle them the same as gemini.
155 hostname
<- decodeIPv6
<$> uriRegName uri
156 let port
= fromMaybe defaultGeminiPort
$ uriPort uri
157 return $ Host hostname port
158 return . NetworkRequest host
. stripUriForGemini
$ uri
160 decodeIPv6
:: String -> String
161 decodeIPv6
('[':rest
) |
last rest
== ']', addr
<- init rest
, isIPv6address addr
= addr
165 newtype RequestException
= ExcessivelyLongUri
Int
167 instance Exception RequestException
169 -- |On success, returns `Right (lazyResp,terminate)`. `lazyResp` is a `Response`
170 -- with lazy IO, so attempts to read it may block while data is received. If
171 -- the full response is not needed, for example because of an error, the IO
172 -- action `terminate` should be called to close the connection.
173 makeRequest
:: RequestContext
174 -> Maybe Identity
-- ^client certificate to offer
175 -> Int -- ^bound in bytes for response stream buffering
176 -> Bool -- ^whether to display extra information about connection
177 -> Request
-> IO (Either SomeException
(Response
, IO ()))
178 makeRequest
(RequestContext
(InteractionCallbacks displayInfo displayWarning _ promptYN
)
179 certStore mTrusted mIgnoredCertErrors mWarnedCA mIgnoredCCertWarnings serviceCertsPath readOnly socksProxy clientSessions
) mIdent bound verboseConnection
(NetworkRequest
(Host hostname port
) uri
) =
180 let requestBytes
= TS
.encodeUtf8
. TS
.pack
$ show uri
++ "\r\n"
181 uriLength
= BS
.length requestBytes
- 2
182 ccfp
= clientCertFingerprint
. identityCert
<$> mIdent
183 in if uriLength
> 1024 then return . Left
. toException
$ ExcessivelyLongUri uriLength
184 else handle handleAll
$ do
185 session
<- lookupClientSession hostname ccfp clientSessions
186 let serverId
= if port
== defaultGeminiPort
then BS
.empty else TS
.encodeUtf8
. TS
.pack
. (':':) $ show port
187 sessionManager
= clientSessionManager
3600 clientSessions ccfp
188 params
= (TLS
.defaultParamsClient hostname serverId
)
189 { clientSupported
= def
{ supportedCiphers
= gemini_ciphersuite
}
190 -- |RFC6066 disallows SNI with literal IP addresses
191 , clientUseServerNameIndication
= not $ isIPv4address hostname || isIPv6address hostname
193 { onServerCertificate
= checkServerCert
194 , onCertificateRequest
= \(_
,pairs
,_
) -> case mIdent
of
195 Nothing
-> return Nothing
196 Just ident
@(Identity idName
(ClientCert chain key
)) -> do
197 -- Note: I have once seen this way of detecting
198 -- pre-tls1.3 give a false positive.
199 let is13
= maybe False ((HashIntrinsic
,SignatureEd25519
) `
elem`
) pairs
200 allow
<- if isTemporary ident || is13
then return True else do
201 ignored
<- (idName `Set
.member`
) <$> readMVar mIgnoredCCertWarnings
202 if ignored
then return True else do
203 displayWarning
["This may be a pre-TLS1.3 server: identity "
204 <> idName
<> " might be revealed to eavesdroppers!"]
205 conf
<- promptYN
False "Identify anyway?"
206 when conf
$ modifyMVar_ mIgnoredCCertWarnings
207 (return . Set
.insert idName
)
209 return $ if allow
then Just
(chain
,key
) else Nothing
212 { sharedCAStore
= certStore
213 , sharedSessionManager
= sessionManager
}
214 , clientEarlyData
= Just requestBytes
-- ^Send early data (RTT0) if server session allows it
215 , clientWantSessionResume
= session
218 let retryNoResume
(HandshakeFailed
(Error_Protocol
(_
,_
,HandshakeFailure
)))
219 |
isJust session
= do
220 -- Work around a mysterious problem seen with dezhemini+libssl:
221 displayWarning
[ "Handshake failure when resuming TLS session; retrying with full handshake." ]
223 c
<- TLS
.contextNew sock
$ params
{ clientWantSessionResume
= Nothing
}
224 handshake c
>> return (sock
,c
)
225 retryNoResume e
= throw e
227 c
<- TLS
.contextNew sock params
228 handle retryNoResume
$ handshake c
>> return (sock
,c
)
229 sentEarly
<- (== Just
True) . (infoIsEarlyDataAccepted
<$>) <$> contextGetInformation context
230 unless sentEarly
. sendData context
$ BL
.fromStrict requestBytes
231 when verboseConnection
. void
. runMaybeT
$ do
232 info
<- MaybeT
$ contextGetInformation context
233 lift
. displayInfo
$ [ "TLS version " ++ show (infoVersion info
) ++
234 ", cipher " ++ cipherName
(infoCipher info
) ]
235 mode
<- MaybeT
. return $ infoTLS13HandshakeMode info
236 lift
. displayInfo
$ [ "Handshake mode " ++ show mode
]
237 chan
<- newBSChan bound
238 let recvAllLazily
= do
239 r
<- recvData context
240 unless (BS
.null r
) $ writeBSChan chan r
>> recvAllLazily
241 ignoreIOError
= (`catchIOError`
(const $ return ()))
242 recvThread
<- forkFinally recvAllLazily
$ \_
->
243 -- |XXX: note that writeBSChan can't block when writing BS.empty
244 writeBSChan chan BS
.empty
245 >> ignoreIOError
(bye context
)
247 lazyResp
<- parseResponse
. BL
.fromChunks
. takeWhile (not . BS
.null) <$> getBSChanContents chan
248 return $ Right
(lazyResp
, killThread recvThread
)
250 handleAll
:: SomeException
-> IO (Either SomeException a
)
251 handleAll
= return . Left
253 openSocket
:: IO Socket
254 openSocket
= case socksProxy
of
255 NoSocksProxy
-> fst <$> connectSock hostname
(show port
)
256 Socks5Proxy socksHostname socksPort
-> do
257 sock
<- fst <$> connectSock socksHostname socksPort
258 _
<- connectSockSOCKS5 sock hostname
(show port
)
261 checkServerCert store cache service chain
@(CertificateChain signedCerts
) = do
262 errors
<- doTofu
=<< validate Data
.X509
.HashSHA256 defaultHooks
263 (defaultChecks
{ checkExhaustive
= True , checkLeafV3
= False }) store cache service chain
264 if null errors ||
any isTrustError errors ||
null signedCerts
267 ignored
<- (tailFingerprint `Set
.member`
) <$> readMVar mIgnoredCertErrors
268 if ignored
then return [] else do
270 "Certificate chain has trusted root, but validation errors: "
272 displayWarning
$ showChain signedCerts
273 ignore
<- promptYN
False "Ignore errors?"
275 then modifyMVar_ mIgnoredCertErrors
(return . Set
.insert tailFingerprint
) >> return []
278 isTrustError
= (`
elem`
[UnknownCA
, SelfSigned
, NotAnAuthority
])
280 -- |error pertaining to the tail certificate, to be ignored if the
281 -- user explicitly trusts the certificate for this service.
282 -- These don't actually affect the TOFU-trustworthiness of a
283 -- certificate, but we warn the user about them anyway.
284 isTrustableError LeafNotV3
= True
285 isTrustableError
(NameMismatch _
) = True
286 isTrustableError _
= False
288 tailSigned
= head signedCerts
289 tailFingerprint
= fingerprint tailSigned
291 chainSigsFail
:: Maybe SignatureFailure
293 let verify
(signed
:signing
:rest
) = msum [
294 case verifySignedSignature signed
. certPubKey
$ getCertificate signing
of
295 SignaturePass
-> Nothing
296 SignatureFailed failure
-> Just failure
297 , verify
(signing
:rest
) ]
299 in verify signedCerts
301 doTofu errors
= if not . any isTrustError
$ errors
303 (tailFingerprint `Set
.member`
) <$> readMVar mWarnedCA
>>! do
304 displayInfo
[ "Accepting valid certificate chain with trusted root CA: " <>
305 showIssuerDN signedCerts
]
306 when verboseConnection
. displayInfo
$ showChain signedCerts
307 modifyMVar_ mWarnedCA
(return . Set
.insert tailFingerprint
)
310 trust
<- checkTrust
$ filter isTrustableError errors
312 then filter (\e
-> not $ isTrustError e || isTrustableError e
) errors
315 checkTrust
:: [FailedReason
] -> IO Bool
316 checkTrust errors
= do
317 trusted
<- ((tailFingerprint
, service
) `Set
.member`
) <$> readMVar mTrusted
318 if trusted
then return True else do
319 trust
<- checkTrust
' errors
320 when trust
$ modifyMVar_ mTrusted
(return . Set
.insert (tailFingerprint
, service
))
322 checkTrust
' :: [FailedReason
] -> IO Bool
323 checkTrust
' _ | Just sigFail
<- chainSigsFail
= do
324 displayWarning
[ "Invalid signature in certificate chain: " ++ show sigFail
]
326 checkTrust
' errors
= do
327 let certs
= map getCertificate signedCerts
328 tailCert
= head certs
329 tailHex
= "SHA256:" <> fingerprintHex tailFingerprint
330 serviceString
= serviceToString service
331 warnErrors
= unless (null errors
) . displayWarning
$
332 [ "WARNING: tail certificate has verification errors: " <> show errors
]
333 known
<- loadServiceCert serviceCertsPath service
334 if known
== Just tailSigned
then do
335 displayInfo
[ "Accepting previously trusted certificate " ++ take 8 (fingerprintHex tailFingerprint
) ++ "; expires " ++ printExpiry tailCert
++ "." ]
336 when verboseConnection
. displayInfo
$ fingerprintPicture tailFingerprint
339 displayInfo
$ showChain signedCerts
340 let promptTrust df pprompt tprompt
= do
341 p
<- promptYN df pprompt
342 if p
then return (True,True) else
343 (False,) <$> promptYN df tprompt
344 tempTimes
<- loadTempServiceInfo serviceCertsPath service
>>= \case
345 Just
(n
,tempHex
) | tempHex
== tailHex
-> pure n
347 (saveCert
,trust
) <- case known
of
349 displayInfo
[ "No certificate previously seen for " ++ serviceString
++ "." ]
351 when (tempTimes
> 0) $ displayInfo
[
352 "This certificate has been temporarily trusted " <>
353 show tempTimes
<> " times." ]
354 let prompt
= "provided certificate (" ++
355 take 8 (fingerprintHex tailFingerprint
) ++ ")?"
356 promptTrust
True ("Permanently trust " ++ prompt
)
357 ("Temporarily trust " ++ prompt
)
358 Just trustedSignedCert
-> do
359 currentTime
<- timeConvert
<$> timeCurrent
360 let trustedCert
= getCertificate trustedSignedCert
361 expired
= currentTime
> (snd . certValidity
) trustedCert
362 samePubKey
= certPubKey trustedCert
== certPubKey tailCert
363 oldFingerprint
= fingerprint trustedSignedCert
364 oldHex
= "SHA256:" <> fingerprintHex oldFingerprint
365 oldInfo
= [ "Fingerprint of old certificate: " <> oldHex
]
366 ++ fingerprintPicture oldFingerprint
367 ++ [ "Old certificate " ++ (if expired
then "expired" else "expires") ++
368 ": " ++ printExpiry trustedCert
]
369 signedByOld
= SignaturePass `
elem`
370 ((`verifySignedSignature` certPubKey trustedCert
) <$> signedCerts
)
373 ("The new certificate chain is signed by " ++
374 (if expired
then "an EXPIRED" else "a") ++
375 " key previously trusted for this host.") : oldInfo
376 else if expired || samePubKey
378 ("A different " ++ (if expired
then "expired " else "non-expired ") ++
379 "certificate " ++ (if samePubKey
then "with the same public key " else "") ++
380 "for " ++ serviceString
++ " was previously explicitly trusted.") : oldInfo
381 else displayWarning
$
382 ("CAUTION: A certificate with a different public key for " ++ serviceString
++
383 " was previously explicitly trusted and has not expired!") : oldInfo
384 when (tempTimes
> 0) $ displayInfo
[
385 "The new certificate has been temporarily trusted " <>
386 show tempTimes
<> " times." ]
388 promptTrust
(signedByOld || expired || samePubKey
)
389 ("Permanently trust new certificate" <>
391 else " (replacing old certificate (which will be backed up))") <> "?")
392 ("Temporarily trust new certificate" <>
394 else " (but keep old certificate)") <> "?")
395 when (saveCert
&& not readOnly
) $
396 saveServiceCert serviceCertsPath service tailSigned `
catch` printIOErr
397 when (trust
&& not saveCert
&& not readOnly
) $
398 saveTempServiceInfo serviceCertsPath service
(tempTimes
+ 1, tailHex
) `
catch` printIOErr
401 printExpiry
:: Certificate
-> String
402 printExpiry
= timePrint ISO8601_Date
. snd . certValidity
404 showCN
:: DistinguishedName
-> String
405 showCN
= maybe "[Unspecified CN]" (TS
.unpack
. TS
.decodeUtf8
. getCharacterStringRawData
) . getDnElement DnCommonName
407 showIssuerDN
:: [SignedCertificate
] -> String
408 showIssuerDN signed
= case lastMay signed
of
410 Just headSigned
-> showCN
. certIssuerDN
$ getCertificate headSigned
412 showChain
:: [SignedCertificate
] -> [String]
414 showChain signed
= let
415 sigChain
= reverse signed
416 certs
= getCertificate
<$> sigChain
417 issuerCN
= showCN
. certIssuerDN
$ head certs
418 subjectCNs
= showCN
. certSubjectDN
<$> certs
419 hexes
= ("SHA256:" <>) . fingerprintHex
. fingerprint
<$> sigChain
420 pics
= fingerprintPicture
. fingerprint
<$> sigChain
421 expStrs
= ("Expires " ++) . printExpiry
<$> certs
422 picsWithInfo
= ((centre
23 <$>) <$>) $ zipWith (++) pics
$ transpose [subjectCNs
, expStrs
]
423 centre n s
= take n
$ replicate ((n
- length s
) `
div`
2) ' ' ++ s
++ repeat ' '
424 tweenCol
= replicate 6 " " ++ [" >>> "] ++ replicate 6 " "
425 sideBySide
= (concat <$>) . transpose
426 in [ "Certificate chain: " ++ intercalate
" >>> " (issuerCN
:subjectCNs
) ]
427 ++ (sideBySide
. intersperse tweenCol
$ picsWithInfo
)
428 ++ zipWith (++) ("": repeat ">>> ") hexes
430 printIOErr
:: IOError -> IO ()
431 printIOErr
= displayWarning
. (:[]) . show
433 fingerprintHex
:: Fingerprint
-> String
434 fingerprintHex
(Fingerprint fp
) = concatMap hexWord8
$ BS
.unpack fp
436 let (a
,b
) = quotRem w
16
437 hex
= ("0123456789abcdef" !!) . fromIntegral
438 in hex a
: hex b
: ""
439 fingerprintPicture
:: Fingerprint
-> [String]
440 fingerprintPicture
(Fingerprint fp
) = boxedDrunkenBishop fp
where
441 boxedDrunkenBishop
:: BS
.ByteString
-> [String]
442 boxedDrunkenBishop s
= ["+-----[X509]------+"]
443 ++ (map (('|
':) . (++"|")) . lines $ drunkenBishopPreHashed s
)
444 ++ ["+----[SHA256]-----+"]
445 drunkenBishopPreHashed
:: BS
.ByteString
-> String
446 drunkenBishopPreHashed
= drunkenBishopWithOptions
$
447 drunkenBishopDefaultOptions
{ drunkenBishopHash
= id }
449 -- |those ciphers from ciphersuite_default fitting the requirements
450 -- recommended by the gemini "best practices" document:
451 -- require ECDHE/DHE (for PFS), and >=SHA2, and AES/CHACHA20.
452 gemini_ciphersuite
:: [Cipher
]
454 [ -- First the PFS + GCM + SHA2 ciphers
455 cipher_ECDHE_ECDSA_AES128GCM_SHA256
, cipher_ECDHE_ECDSA_AES256GCM_SHA384
456 , cipher_ECDHE_ECDSA_CHACHA20POLY1305_SHA256
457 , cipher_ECDHE_RSA_AES128GCM_SHA256
, cipher_ECDHE_RSA_AES256GCM_SHA384
458 , cipher_ECDHE_RSA_CHACHA20POLY1305_SHA256
459 , cipher_DHE_RSA_AES128GCM_SHA256
, cipher_DHE_RSA_AES256GCM_SHA384
460 , cipher_DHE_RSA_CHACHA20POLY1305_SHA256
461 , -- Next the PFS + CCM + SHA2 ciphers
462 cipher_ECDHE_ECDSA_AES128CCM_SHA256
, cipher_ECDHE_ECDSA_AES256CCM_SHA256
463 , cipher_DHE_RSA_AES128CCM_SHA256
, cipher_DHE_RSA_AES256CCM_SHA256
464 -- Next the PFS + CBC + SHA2 ciphers
465 , cipher_ECDHE_ECDSA_AES128CBC_SHA256
, cipher_ECDHE_ECDSA_AES256CBC_SHA384
466 , cipher_ECDHE_RSA_AES128CBC_SHA256
, cipher_ECDHE_RSA_AES256CBC_SHA384
467 , cipher_DHE_RSA_AES128_SHA256
, cipher_DHE_RSA_AES256_SHA256
468 -- TLS13 (listed at the end but version is negotiated first)
469 , cipher_TLS13_AES128GCM_SHA256
470 , cipher_TLS13_AES256GCM_SHA384
471 , cipher_TLS13_CHACHA20POLY1305_SHA256
472 , cipher_TLS13_AES128CCM_SHA256
475 parseResponse
:: BL
.ByteString
-> Response
477 let (header
, rest
) = BLC
.break (== '\r') resp
478 body
= BL
.drop 2 rest
479 statusString
= T
.unpack
. stripControl
. T
.decodeUtf8
. BL
.take 2 $ header
480 separator
= BL
.take 1 . BL
.drop 2 $ header
481 meta
= T
.unpack
. stripControl
. T
.decodeUtf8
. BL
.drop 3 $ header
483 if BL
.take 2 rest
/= "\r\n" then MalformedResponse BadHeaderTermination
484 else if separator `
notElem`
[""," ","\t"] -- ^allow \t for now, though it's against latest spec
485 then MalformedResponse BadMetaSeparator
486 else if BL
.length header
> 1024+3 then MalformedResponse BadMetaLength
487 else case readMay statusString
of
488 Just status | status
>= 10 && status
< 80 ->
489 let (status1
,status2
) = divMod status
10
491 1 -> Input
(status2
== 1) meta
492 2 -> maybe (MalformedResponse
(BadMime meta
))
493 (\mime
-> Success
$ MimedData mime body
) $
494 MIME
.parseMIMEType
(TS
.pack
$
495 if null meta
then "text/gemini; charset=utf-8" else meta
)
496 3 -> maybe (MalformedResponse
(BadUri meta
))
497 (Redirect
(status2
== 1)) $ parseUriReference meta
498 _
-> Failure status meta
499 _
-> MalformedResponse
(BadStatus statusString
)
501 makeRequest _ _ _ _
(LocalFileRequest _
) = error "File requests not handled by makeRequest"