summaryrefslogtreecommitdiff
path: root/acme-certify.hs
diff options
context:
space:
mode:
Diffstat (limited to 'acme-certify.hs')
-rw-r--r--acme-certify.hs176
1 files changed, 7 insertions, 169 deletions
diff --git a/acme-certify.hs b/acme-certify.hs
index fecd632..07c2942 100644
--- a/acme-certify.hs
+++ b/acme-certify.hs
@@ -10,31 +10,15 @@
10 10
11module Main where 11module Main where
12 12
13import Control.Lens hiding (each, (.=))
14import Control.Monad 13import Control.Monad
15import Control.Monad.RWS.Strict
16import Data.Aeson (Value)
17import Data.Aeson.Lens hiding (key)
18import qualified Data.Aeson.Lens as JSON
19import Data.ByteString (ByteString)
20import qualified Data.ByteString as B 14import qualified Data.ByteString as B
21import qualified Data.ByteString.Char8 as BC
22import qualified Data.ByteString.Lazy as LB
23import qualified Data.ByteString.Lazy.Char8 as LC 15import qualified Data.ByteString.Lazy.Char8 as LC
24import Data.Coerce 16import Data.Coerce
25import Data.List 17import Data.List
26import Data.Maybe 18import Data.Maybe
27import Data.String (fromString) 19import Data.String (fromString)
28import qualified Data.Text as T 20import Network.ACME (certify, readKeyFile, (</>), ensureWritableDir, canProvision, CSR(..))
29import Data.Text.Encoding (decodeUtf8, encodeUtf8) 21import Network.ACME.Encoding (Keys(..), toStrict)
30import Data.Time.Clock.POSIX (getPOSIXTime)
31import Network.ACME.Encoding
32import Network.Wreq (Response, checkStatus, defaults,
33 responseBody, responseHeader,
34 responseStatus, statusCode,
35 statusMessage)
36import qualified Network.Wreq as W
37import qualified Network.Wreq.Session as WS
38import OpenSSL 22import OpenSSL
39import OpenSSL.EVP.Digest 23import OpenSSL.EVP.Digest
40import OpenSSL.PEM 24import OpenSSL.PEM
@@ -42,7 +26,6 @@ import OpenSSL.RSA
42import OpenSSL.X509.Request 26import OpenSSL.X509.Request
43import Options.Applicative hiding (header) 27import Options.Applicative hiding (header)
44import qualified Options.Applicative as Opt 28import qualified Options.Applicative as Opt
45import Pipes
46import System.Directory 29import System.Directory
47import Text.Email.Validate 30import Text.Email.Validate
48import Text.Domain.Validate hiding (validate) 31import Text.Domain.Validate hiding (validate)
@@ -115,10 +98,9 @@ genKey privKeyFile = withOpenSSL $ do
115 pem <- writePKCS8PrivateKey kp Nothing 98 pem <- writePKCS8PrivateKey kp Nothing
116 writeFile privKeyFile pem 99 writeFile privKeyFile pem
117 100
118genReq :: FilePath -> [DomainName] -> IO LC.ByteString 101genReq :: Keys -> [DomainName] -> IO CSR
119genReq _ [] = error "genReq called with zero domains" 102genReq _ [] = error "genReq called with zero domains"
120genReq domainKeyFile domains@(domain:_) = withOpenSSL $ do 103genReq (Keys priv pub) domains@(domain:_) = withOpenSSL $ do
121 Just (Keys priv pub) <- readKeyFile domainKeyFile
122 Just dig <- getDigestByName "SHA256" 104 Just dig <- getDigestByName "SHA256"
123 req <- newX509Req 105 req <- newX509Req
124 setSubjectName req [("CN", show domain)] 106 setSubjectName req [("CN", show domain)]
@@ -126,15 +108,10 @@ genReq domainKeyFile domains@(domain:_) = withOpenSSL $ do
126 setPublicKey req pub 108 setPublicKey req pub
127 void $ addExtensions req [(nidSubjectAltName, intercalate ", " (map (("DNS:" ++) . show) domains))] 109 void $ addExtensions req [(nidSubjectAltName, intercalate ", " (map (("DNS:" ++) . show) domains))]
128 signX509Req req priv (Just dig) 110 signX509Req req priv (Just dig)
129 writeX509ReqDER req 111 CSR . toStrict <$> writeX509ReqDER req
130 where 112 where
131 nidSubjectAltName = 85 113 nidSubjectAltName = 85
132 114
133readKeyFile :: FilePath -> IO (Maybe Keys)
134readKeyFile = readFile >=> readKeys
135
136data ChallengeRequest = ChallengeRequest { crUri :: String, crToken :: ByteString, crThumbToken :: ByteString }
137
138otherwiseM :: Monad m => m Bool -> m () -> m () 115otherwiseM :: Monad m => m Bool -> m () -> m ()
139a `otherwiseM` b = a >>= flip unless b 116a `otherwiseM` b = a >>= flip unless b
140infixl 0 `otherwiseM` 117infixl 0 `otherwiseM`
@@ -162,7 +139,8 @@ go CmdOpts { .. } = do
162 139
163 forM_ requestDomains $ canProvision challengeDir >=> (`unless` error "Error: cannot provision files to web server via challenge directory") 140 forM_ requestDomains $ canProvision challengeDir >=> (`unless` error "Error: cannot provision files to web server via challenge directory")
164 141
165 csrData <- CSR . toStrict <$> genReq domainKeyFile requestDomains 142 csrData <- fromMaybe (error "Error: failed to read domain key file") <$>
143 readKeyFile domainKeyFile >>= flip genReq requestDomains
166 B.writeFile domainCSRFile (coerce csrData) 144 B.writeFile domainCSRFile (coerce csrData)
167 145
168 let email = either (error . ("Error: invalid email address: " ++)) id . validate . fromString <$> optEmail 146 let email = either (error . ("Error: invalid email address: " ++)) id . validate . fromString <$> optEmail
@@ -171,143 +149,3 @@ go CmdOpts { .. } = do
171 149
172 either (error . ("Error: " ++)) (LC.writeFile domainCertFile) certificate 150 either (error . ("Error: " ++)) (LC.writeFile domainCertFile) certificate
173 151
174certify :: URI -> Keys -> Maybe EmailAddress -> URI -> [DomainName] -> WritableDir -> CSR -> IO (Either String LC.ByteString)
175certify directoryUrl keys optEmail terms requestDomains optChallengeDir csrData =
176
177 runACME directoryUrl keys $ do
178 forM_ optEmail $ register terms >=> statusReport
179
180 let producer :: Producer ChallengeRequest ACME ()
181 producer = for (each requestDomains) $ challengeRequest >=> statusReport >=> extractCR >=> yield
182 consumer :: Consumer ChallengeRequest ACME ()
183 consumer = forever $ await >>= consume1
184 consume1 (ChallengeRequest nextUri token thumbtoken) = do
185 lift $ liftIO $ BC.writeFile (coerce optChallengeDir </> BC.unpack token) thumbtoken
186 notifyChallenge nextUri thumbtoken >>= statusReport >>= ncErrorReport
187
188 runEffect $ producer >-> consumer
189
190 retrieveCert csrData >>= statusReport <&> checkCertResponse
191
192newtype CSR = CSR ByteString
193
194(</>) :: String -> String -> String
195a </> b = a ++ "/" ++ b
196infixr 5 </>
197
198canProvision :: WritableDir -> DomainName -> IO Bool
199canProvision challengeDir domain = do
200 randomish <- fromString . show <$> getPOSIXTime
201
202 let absFile = coerce challengeDir </> relFile
203 relFile = ".test." ++ show randomish
204
205 LC.writeFile absFile randomish
206 r <- W.get $ "http://" ++ show domain </> ".well-known/acme-challenge" </> relFile
207 removeFile absFile
208 return $ r ^. responseBody == randomish
209
210
211newtype WritableDir = WritableDir String
212ensureWritableDir :: FilePath -> String -> IO WritableDir
213ensureWritableDir file name = do
214 (writable <$> getPermissions file) >>= flip unless (err name)
215 return $ WritableDir file
216 where err n = error $ "Error: " ++ n ++ " is not writable"
217
218extractCR :: MonadReader Env m => Response LC.ByteString -> m ChallengeRequest
219extractCR r = do
220 Keys _ pub <- asks getKeys
221 let httpChallenge :: (Value -> Const (Endo s) Value) -> Response LC.ByteString -> Const (Endo s) (Response LC.ByteString)
222 httpChallenge = responseBody .
223 JSON.key "challenges" .
224 to universe .
225 traverse .
226 (filtered . has $ ix "type" . only "http-01")
227
228 token = r ^?! httpChallenge . JSON.key "token" . _String . to encodeUtf8
229 crUri = r ^?! httpChallenge . JSON.key "uri" . _String . to T.unpack
230
231 thumb = thumbprint (JWK (rsaE pub) "RSA" (rsaN pub))
232 thumbtoken = toStrict (LB.fromChunks [token, ".", thumb])
233
234 return $ ChallengeRequest crUri token thumbtoken
235
236ncErrorReport :: (Show body, AsValue body, MonadIO m) => Response body -> m ()
237ncErrorReport r =
238 when (Just "pending" /= r ^? responseBody . JSON.key "status" . _String) $ liftIO $ do
239 putStrLn "Unexpected response to challenge-response request:"
240 print r
241
242checkCertResponse :: Response LC.ByteString -> Either String LC.ByteString
243checkCertResponse r =
244 if isSuccess $ r ^. responseStatus . statusCode
245 then Right $ r ^. responseBody
246 else
247 let (summary, details) = (k "type", k "detail")
248 k x = r ^?! responseBody . JSON.key x . _String . to T.unpack
249 in Left $ summary ++ " ---- " ++ details
250 where
251 isSuccess n = n >= 200 && n <= 300
252
253retrieveCert :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => CSR -> m (Response LC.ByteString)
254retrieveCert input = sendPayload _newCert (csr $ coerce input)
255
256notifyChallenge :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> ByteString -> m (Response LC.ByteString)
257notifyChallenge crUri thumbtoken = sendPayload (const crUri) (challenge thumbtoken)
258
259data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session }
260
261type ACME = RWST Env () Nonce IO
262runACME :: URI -> Keys -> ACME a -> IO a
263runACME url keys f = WS.withSession $ \sess -> do
264 Just (dir, nonce) <- getDirectory sess (show url)
265 fst <$> evalRWST f (Env dir keys sess) nonce
266
267data Directory = Directory {
268 _newCert :: String,
269 _newAuthz :: String,
270 _revokeCert :: String,
271 _newReg :: String
272}
273newtype Nonce = Nonce String
274
275getDirectory :: WS.Session -> String -> IO (Maybe (Directory, Nonce))
276getDirectory sess url = do
277 r <- WS.get sess url
278 let nonce = r ^? responseHeader "Replay-Nonce" . to (Nonce . T.unpack . decodeUtf8)
279 k x = r ^? responseBody . JSON.key x . _String . to T.unpack
280 return $ (,) <$> (Directory <$> k "new-cert" <*> k "new-authz" <*> k "revoke-cert" <*> k "new-reg") <*> nonce
281
282register :: URI -> EmailAddress -> ACME (Response LC.ByteString)
283register terms email = sendPayload _newReg (registration email (show terms))
284
285challengeRequest :: (MonadIO m, MonadState Nonce m, MonadReader Env m) => DomainName -> m (Response LC.ByteString)
286challengeRequest = sendPayload _newAuthz . authz . show
287
288statusLine :: Response body -> String
289statusLine r = (r ^. responseStatus . statusCode . to show) ++ " " ++ r ^. responseStatus . statusMessage . to (T.unpack . decodeUtf8)
290
291statusReport :: MonadIO m => Response body -> m (Response body)
292statusReport r = do
293 liftIO $ putStrLn $ statusLine r
294 return r
295
296sendPayload :: (MonadIO m, MonadState Nonce m, MonadReader Env m) => (Directory -> String) -> ByteString -> m (Response LC.ByteString)
297sendPayload reqType payload = do
298 keys <- asks getKeys
299 dir <- asks getDir
300 nonce <- gets coerce
301 signed <- liftIO $ signPayload keys nonce payload
302 post (reqType dir) signed
303
304post :: (MonadReader Env m, MonadState Nonce m, MonadIO m) => String -> LC.ByteString -> m (Response LC.ByteString)
305post url payload = do
306 sess <- asks getSession
307 r <- liftIO $ WS.postWith noStatusCheck sess url payload
308 put $ r ^?! responseHeader "Replay-Nonce" . to (Nonce . T.unpack . decodeUtf8)
309 return r
310 where
311 noStatusCheck = defaults & checkStatus .~ Just nullChecker
312 nullChecker _ _ _ = Nothing
313