summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAndrew Cady <d@jerkface.net>2016-01-24 14:15:02 -0500
committerAndrew Cady <d@jerkface.net>2016-01-24 15:22:39 -0500
commitc78e210dc38c5c5df7134d74b137cdc3827f806a (patch)
tree158d673e723ebb0e25dec4d94e139d7847e011d0 /src
parentddfd92cdfaf5e8e77961fbf63589a9be4109fc64 (diff)
split out another module
Diffstat (limited to 'src')
-rw-r--r--src/Network/ACME.hs181
1 files changed, 181 insertions, 0 deletions
diff --git a/src/Network/ACME.hs b/src/Network/ACME.hs
new file mode 100644
index 0000000..116a291
--- /dev/null
+++ b/src/Network/ACME.hs
@@ -0,0 +1,181 @@
1{-# LANGUAGE FlexibleContexts #-}
2{-# LANGUAGE OverloadedStrings #-}
3{-# LANGUAGE ScopedTypeVariables #-}
4
5--------------------------------------------------------------------------------
6-- | Get a certificate from Let's Encrypt using the ACME protocol.
7--
8-- https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md
9
10module Network.ACME where
11
12import Control.Lens hiding (each, (.=))
13import Control.Monad
14import Control.Monad.RWS.Strict
15import Data.Aeson (Value)
16import Data.Aeson.Lens hiding (key)
17import qualified Data.Aeson.Lens as JSON
18import Data.ByteString (ByteString)
19import qualified Data.ByteString.Char8 as BC
20import qualified Data.ByteString.Lazy as LB
21import qualified Data.ByteString.Lazy.Char8 as LC
22import Data.Coerce
23import Data.String (fromString)
24import qualified Data.Text as T
25import Data.Text.Encoding (decodeUtf8, encodeUtf8)
26import Data.Time.Clock.POSIX (getPOSIXTime)
27import Network.ACME.Encoding
28import Network.Wreq (Response, checkStatus, defaults,
29 responseBody, responseHeader,
30 responseStatus, statusCode,
31 statusMessage)
32import qualified Network.Wreq as W
33import qualified Network.Wreq.Session as WS
34import OpenSSL.RSA
35import Pipes
36import System.Directory
37import Text.Email.Validate
38import Text.Domain.Validate hiding (validate)
39import Network.URI
40
41certify :: URI -> Keys -> Maybe EmailAddress -> URI -> [DomainName] -> WritableDir -> CSR -> IO (Either String LC.ByteString)
42certify directoryUrl keys optEmail terms requestDomains optChallengeDir csrData =
43
44 runACME directoryUrl keys $ do
45 forM_ optEmail $ register terms >=> statusReport
46
47 let producer :: Producer ChallengeRequest ACME ()
48 producer = for (each requestDomains) $ challengeRequest >=> statusReport >=> extractCR >=> yield
49 consumer :: Consumer ChallengeRequest ACME ()
50 consumer = forever $ await >>= consume1
51 consume1 (ChallengeRequest nextUri token thumbtoken) = do
52 lift $ liftIO $ BC.writeFile (coerce optChallengeDir </> BC.unpack token) thumbtoken
53 notifyChallenge nextUri thumbtoken >>= statusReport >>= ncErrorReport
54
55 runEffect $ producer >-> consumer
56
57 retrieveCert csrData >>= statusReport <&> checkCertResponse
58
59data ChallengeRequest = ChallengeRequest { crUri :: String, crToken :: ByteString, crThumbToken :: ByteString }
60
61newtype CSR = CSR ByteString
62
63newtype WritableDir = WritableDir String
64ensureWritableDir :: FilePath -> String -> IO WritableDir
65ensureWritableDir file name = do
66 (writable <$> getPermissions file) >>= flip unless (err name)
67 return $ WritableDir file
68 where err n = error $ "Error: " ++ n ++ " is not writable"
69
70(</>) :: String -> String -> String
71a </> b = a ++ "/" ++ b
72infixr 5 </>
73
74canProvision :: WritableDir -> DomainName -> IO Bool
75canProvision challengeDir domain = do
76 randomish <- fromString . show <$> getPOSIXTime
77
78 let absFile = coerce challengeDir </> relFile
79 relFile = ".test." ++ show randomish
80
81 LC.writeFile absFile randomish
82 r <- W.get $ "http://" ++ show domain </> ".well-known/acme-challenge" </> relFile
83 removeFile absFile
84 return $ r ^. responseBody == randomish
85
86
87extractCR :: MonadReader Env m => Response LC.ByteString -> m ChallengeRequest
88extractCR r = do
89 Keys _ pub <- asks getKeys
90 let httpChallenge :: (Value -> Const (Endo s) Value) -> Response LC.ByteString -> Const (Endo s) (Response LC.ByteString)
91 httpChallenge = responseBody .
92 JSON.key "challenges" .
93 to universe .
94 traverse .
95 (filtered . has $ ix "type" . only "http-01")
96
97 token = r ^?! httpChallenge . JSON.key "token" . _String . to encodeUtf8
98 crUri = r ^?! httpChallenge . JSON.key "uri" . _String . to T.unpack
99
100 thumb = thumbprint (JWK (rsaE pub) "RSA" (rsaN pub))
101 thumbtoken = toStrict (LB.fromChunks [token, ".", thumb])
102
103 return $ ChallengeRequest crUri token thumbtoken
104
105ncErrorReport :: (Show body, AsValue body, MonadIO m) => Response body -> m ()
106ncErrorReport r =
107 when (Just "pending" /= r ^? responseBody . JSON.key "status" . _String) $ liftIO $ do
108 putStrLn "Unexpected response to challenge-response request:"
109 print r
110
111checkCertResponse :: Response LC.ByteString -> Either String LC.ByteString
112checkCertResponse r =
113 if isSuccess $ r ^. responseStatus . statusCode
114 then Right $ r ^. responseBody
115 else
116 let (summary, details) = (k "type", k "detail")
117 k x = r ^?! responseBody . JSON.key x . _String . to T.unpack
118 in Left $ summary ++ " ---- " ++ details
119 where
120 isSuccess n = n >= 200 && n <= 300
121
122retrieveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => CSR -> m (Response LC.ByteString)
123retrieveCert input = sendPayload _newCert (csr $ coerce input)
124
125notifyChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString)
126notifyChallenge crUri thumbtoken = sendPayload (const crUri) (challenge thumbtoken)
127
128data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session }
129
130type ACME = RWST Env () Nonce IO
131runACME :: URI -> Keys -> ACME a -> IO a
132runACME url keys f = WS.withSession $ \sess -> do
133 Just (dir, nonce) <- getDirectory sess (show url)
134 fst <$> evalRWST f (Env dir keys sess) nonce
135
136data Directory = Directory {
137 _newCert :: String,
138 _newAuthz :: String,
139 _revokeCert :: String,
140 _newReg :: String
141}
142newtype Nonce = Nonce String
143
144getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce))
145getDirectory sess url = do
146 r <- WS.get sess url
147 let nonce = r ^? responseHeader "Replay-Nonce" . to (Nonce . T.unpack . decodeUtf8)
148 k x = r ^? responseBody . JSON.key x . _String . to T.unpack
149 return $ (,) <$> (Directory <$> k "new-cert" <*> k "new-authz" <*> k "revoke-cert" <*> k "new-reg") <*> nonce
150
151register :: URI -> EmailAddress -> ACME (Response LC.ByteString)
152register terms email = sendPayload _newReg (registration email (show terms))
153
154challengeRequest :: (MonadIO m, MonadState Nonce m, MonadReader Env m) => DomainName -> m (Response LC.ByteString)
155challengeRequest = sendPayload _newAuthz . authz . show
156
157statusLine :: Response body -> String
158statusLine r = (r ^. responseStatus . statusCode . to show) ++ " " ++ r ^. responseStatus . statusMessage . to (T.unpack . decodeUtf8)
159
160statusReport :: MonadIO m => Response body -> m (Response body)
161statusReport r = do
162 liftIO $ putStrLn $ statusLine r
163 return r
164
165sendPayload :: (MonadIO m, MonadState Nonce m, MonadReader Env m) => (Directory -> String) -> ByteString -> m (Response LC.ByteString)
166sendPayload reqType payload = do
167 keys <- asks getKeys
168 dir <- asks getDir
169 nonce <- gets coerce
170 signed <- liftIO $ signPayload keys nonce payload
171 post (reqType dir) signed
172
173post :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> LC.ByteString -> m (Response LC.ByteString)
174post url payload = do
175 sess <- asks getSession
176 r <- liftIO $ WS.postWith noStatusCheck sess url payload
177 put $ r ^?! responseHeader "Replay-Nonce" . to (Nonce . T.unpack . decodeUtf8)
178 return r
179 where
180 noStatusCheck = defaults & checkStatus .~ Just nullChecker
181 nullChecker _ _ _ = Nothing