summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Cady <d@jerkface.net>2016-01-22 11:36:37 -0500
committerAndrew Cady <d@jerkface.net>2016-01-22 18:19:25 -0500
commit15d6572b9fa0ff6b0105eaa26583f496b18f78b4 (patch)
tree2a60a040495c9c8080bae50e6dd871a42446ad6b
parent3581adc163fd0b41485d822944efe6cdd4607aed (diff)
Factored out Network.ACME library
-rw-r--r--acme-encrypt.cabal30
-rw-r--r--acme.hs146
-rw-r--r--src/Network/ACME.hs198
3 files changed, 216 insertions, 158 deletions
diff --git a/acme-encrypt.cabal b/acme-encrypt.cabal
index 55b94ff..9d9e980 100644
--- a/acme-encrypt.cabal
+++ b/acme-encrypt.cabal
@@ -2,30 +2,32 @@ name: acme-encrypt
2version: 0.1.0.0 2version: 0.1.0.0
3synopsis: Get a certificate using Let's Encrypt ACME protocol 3synopsis: Get a certificate using Let's Encrypt ACME protocol
4description: Please see README.md 4description: Please see README.md
5homepage: https://github.com/noteed/acme 5homepage: https://github.com/afcady/acme
6author: Vo Minh Thu 6author: Vo Minh Thu, Andrew Cady
7maintainer: noteed@gmail.com 7maintainer: noteed@gmail.com
8copyright: 2016 Vo Minh Thu 8copyright: 2016 Vo Minh Thu, Andrew Cady
9category: Web 9category: Web
10build-type: Simple 10build-type: Simple
11-- extra-source-files: 11-- extra-source-files:
12cabal-version: >=1.10 12cabal-version: >=1.10
13 13
14-- library 14library
15-- hs-source-dirs: src 15 hs-source-dirs: src
16-- exposed-modules: Lib 16 exposed-modules: Network.ACME
17-- build-depends: base >= 4.7 && < 5 17 build-depends: base >= 4.7 && < 5,
18-- default-language: Haskell2010 18 cryptonite, aeson, bytestring, base64-bytestring, SHA,
19 text, HsOpenSSL, wreq, lens, lens-aeson,
20 mtl, time
21 default-language: Haskell2010
19 22
20executable acme-encrypt-exe 23executable letsencrypt
21 -- hs-source-dirs: app 24 -- hs-source-dirs: app
22 main-is: acme.hs 25 main-is: acme.hs
23 ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall 26 ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall
24 build-depends: base, 27 build-depends: base, acme-encrypt,
25 cryptonite, aeson, bytestring, base64-bytestring, SHA, 28 cryptonite, aeson, bytestring, base64-bytestring, SHA,
26 text, HsOpenSSL, wreq, lens, lens-aeson, 29 text, HsOpenSSL, wreq, lens, lens-aeson,
27 optparse-applicative, directory, mtl, time 30 optparse-applicative, directory, mtl, time
28 -- , acme-encrypt
29 default-language: Haskell2010 31 default-language: Haskell2010
30 32
31-- test-suite acme-encrypt-test 33-- test-suite acme-encrypt-test
@@ -37,6 +39,6 @@ executable acme-encrypt-exe
37-- ghc-options: -threaded -rtsopts -with-rtsopts=-N 39-- ghc-options: -threaded -rtsopts -with-rtsopts=-N
38-- default-language: Haskell2010 40-- default-language: Haskell2010
39 41
40-- source-repository head 42source-repository head
41-- type: git 43 type: git
42-- location: https://github.com/githubuser/acme-encrypt 44 location: https://github.com/afcady/acme
diff --git a/acme.hs b/acme.hs
index 5ea5eeb..8257390 100644
--- a/acme.hs
+++ b/acme.hs
@@ -49,6 +49,8 @@ import Options.Applicative hiding (header)
49import qualified Options.Applicative as Opt 49import qualified Options.Applicative as Opt
50import System.Directory 50import System.Directory
51 51
52import Network.ACME
53
52stagingDirectoryUrl, liveDirectoryUrl :: String 54stagingDirectoryUrl, liveDirectoryUrl :: String
53liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" 55liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory"
54stagingDirectoryUrl = "https://acme-staging.api.letsencrypt.org/directory" 56stagingDirectoryUrl = "https://acme-staging.api.letsencrypt.org/directory"
@@ -118,7 +120,6 @@ genReq domainKeyFile domain = withOpenSSL $ do
118 signX509Req req priv (Just dig) 120 signX509Req req priv (Just dig)
119 writeX509ReqDER req 121 writeX509ReqDER req
120 122
121data Keys = Keys SomeKeyPair RSAPubKey
122readKeys :: String -> IO Keys 123readKeys :: String -> IO Keys
123readKeys privKeyFile = do 124readKeys privKeyFile = do
124 priv <- readFile privKeyFile >>= flip readPrivateKey PwTTY 125 priv <- readFile privKeyFile >>= flip readPrivateKey PwTTY
@@ -288,146 +289,3 @@ post url payload = do
288 noStatusCheck = defaults & checkStatus .~ Just nullChecker 289 noStatusCheck = defaults & checkStatus .~ Just nullChecker
289 nullChecker _ _ _ = Nothing 290 nullChecker _ _ _ = Nothing
290 291
291--------------------------------------------------------------------------------
292-- | Sign return a payload with a nonce-protected header.
293signPayload :: Keys -> String -> ByteString -> IO LC.ByteString
294signPayload (Keys priv pub) nonce_ payload = withOpenSSL $ do
295 let protected = b64 (header pub nonce_)
296 Just dig <- getDigestByName "SHA256"
297 sig <- b64 <$> signBS dig priv (B.concat [protected, ".", payload])
298 return $ encode (Request (header' pub) protected payload sig)
299
300--------------------------------------------------------------------------------
301-- | Base64URL encoding of Integer with padding '=' removed.
302b64i :: Integer -> ByteString
303b64i = b64 . i2osp
304
305b64 :: ByteString -> ByteString
306b64 = B.takeWhile (/= 61) . Base64.encode
307
308toStrict :: LB.ByteString -> ByteString
309toStrict = B.concat . LB.toChunks
310
311header' :: RSAKey k => k -> Header
312header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing
313
314header :: RSAKey k => k -> String -> ByteString
315header key nonce = (toStrict . encode)
316 (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce))
317
318-- | Registration payload to sign with user key.
319registration :: String -> String -> ByteString
320registration emailAddr terms = (b64 . toStrict . encode) (Reg emailAddr terms)
321
322-- | Challenge request payload to sign with user key.
323authz :: String -> ByteString
324authz = b64. toStrict . encode . Authz
325
326-- | Challenge response payload to sign with user key.
327challenge :: ByteString -> ByteString
328challenge = b64 . toStrict . encode . Challenge . BC.unpack
329
330-- | CSR request payload to sign with user key.
331csr :: ByteString -> ByteString
332csr = b64 . toStrict . encode . CSR . b64
333
334thumbprint :: JWK -> ByteString
335thumbprint = b64 . toStrict . bytestringDigest . sha256 . encodeOrdered
336
337-- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here.
338encodeOrdered :: JWK -> LB.ByteString
339encodeOrdered JWK{..} = LC.pack $
340 "{\"e\":\"" ++ hE' ++ "\",\"kty\":\"" ++ hKty ++ "\",\"n\":\"" ++ hN' ++ "\"}"
341 where
342 hE' = BC.unpack (b64i hE)
343 hN' = BC.unpack (b64i hN)
344
345
346--------------------------------------------------------------------------------
347data Header = Header
348 { hAlg :: String
349 , hJwk :: JWK
350 , hNonce :: Maybe String
351 }
352 deriving Show
353
354data JWK = JWK
355 { hE :: Integer
356 , hKty :: String
357 , hN :: Integer
358 }
359 deriving Show
360
361instance ToJSON Header where
362 toJSON Header{..} = object $
363 [ "alg" .= hAlg
364 , "jwk" .= toJSON hJwk
365 ] ++ maybeToList (("nonce" .=) <$> hNonce)
366
367instance ToJSON JWK where
368 toJSON JWK{..} = object
369 [ "e" .= decodeUtf8 (b64i hE)
370 , "kty" .= hKty
371 , "n" .= decodeUtf8 (b64i hN)
372 ]
373
374data Reg = Reg
375 { rMail :: String
376 , rAgreement :: String
377 }
378 deriving Show
379
380instance ToJSON Reg where
381 toJSON Reg{..} = object
382 [ "resource" .= ("new-reg" :: String)
383 , "contact" .= ["mailto:" ++ rMail]
384 , "agreement" .= rAgreement
385 ]
386
387data Request = Request
388 { rHeader :: Header
389 , rProtected :: ByteString
390 , rPayload :: ByteString
391 , rSignature :: ByteString
392 }
393 deriving Show
394
395instance ToJSON Request where
396 toJSON Request{..} = object
397 [ "header" .= toJSON rHeader
398 , "protected" .= decodeUtf8 rProtected
399 , "payload" .= decodeUtf8 rPayload
400 , "signature" .= decodeUtf8 rSignature
401 ]
402
403data Authz = Authz
404 { aDomain :: String
405 }
406
407instance ToJSON Authz where
408 toJSON Authz{..} = object
409 [ "resource" .= ("new-authz" :: String)
410 , "identifier" .= object
411 [ "type" .= ("dns" :: String)
412 , "value" .= aDomain
413 ]
414 ]
415
416data Challenge = Challenge
417 { cKeyAuth :: String
418 }
419
420instance ToJSON Challenge where
421 toJSON Challenge{..} = object
422 [ "resource" .= ("challenge" :: String)
423 , "keyAuthorization" .= cKeyAuth
424 ]
425
426data CSR = CSR ByteString
427 deriving Show
428
429instance ToJSON CSR where
430 toJSON (CSR s) = object
431 [ "resource" .= ("new-cert" :: String)
432 , "csr" .= decodeUtf8 s
433 ]
diff --git a/src/Network/ACME.hs b/src/Network/ACME.hs
new file mode 100644
index 0000000..f8135e6
--- /dev/null
+++ b/src/Network/ACME.hs
@@ -0,0 +1,198 @@
1{-# LANGUAGE FlexibleContexts #-}
2{-# LANGUAGE MultiParamTypeClasses #-}
3{-# LANGUAGE OverloadedStrings #-}
4{-# LANGUAGE RecordWildCards #-}
5{-# LANGUAGE ScopedTypeVariables #-}
6
7module Network.ACME (
8 Keys(..),
9 thumbprint,
10 JWK(..),
11 toStrict,
12 csr,
13 challenge,
14 registration,
15 authz,
16 signPayload,
17 ) where
18
19import Control.Lens hiding ((.=))
20import Control.Monad
21import Control.Monad.RWS.Strict
22import Crypto.Number.Serialize (i2osp)
23import Data.Aeson (ToJSON (..), Value, encode, object,
24 (.=))
25import Data.Aeson.Lens hiding (key)
26import qualified Data.Aeson.Lens as JSON
27import Data.ByteString (ByteString)
28import qualified Data.ByteString as B
29import qualified Data.ByteString.Base64.URL as Base64
30import qualified Data.ByteString.Char8 as BC
31import qualified Data.ByteString.Lazy as LB
32import qualified Data.ByteString.Lazy.Char8 as LC
33import Data.Coerce
34import Data.Digest.Pure.SHA (bytestringDigest, sha256)
35import Data.Maybe
36import Data.String (fromString)
37import qualified Data.Text as T
38import Data.Text.Encoding (decodeUtf8, encodeUtf8)
39import Data.Time.Clock.POSIX (getPOSIXTime)
40import Network.Wreq (Response, checkStatus, defaults,
41 responseBody, responseHeader,
42 responseStatus, statusCode,
43 statusMessage)
44import qualified Network.Wreq as W
45import qualified Network.Wreq.Session as WS
46import OpenSSL
47import OpenSSL.EVP.Digest
48import OpenSSL.EVP.PKey
49import OpenSSL.EVP.Sign
50import OpenSSL.PEM
51import OpenSSL.RSA
52import OpenSSL.X509.Request
53
54data Keys = Keys SomeKeyPair RSAPubKey
55
56--------------------------------------------------------------------------------
57-- | Sign return a payload with a nonce-protected header.
58signPayload :: Keys -> String -> ByteString -> IO LC.ByteString
59signPayload (Keys priv pub) nonce_ payload = withOpenSSL $ do
60 let protected = b64 (header pub nonce_)
61 Just dig <- getDigestByName "SHA256"
62 sig <- b64 <$> signBS dig priv (B.concat [protected, ".", payload])
63 return $ encode (Request (header' pub) protected payload sig)
64
65--------------------------------------------------------------------------------
66-- | Base64URL encoding of Integer with padding '=' removed.
67b64i :: Integer -> ByteString
68b64i = b64 . i2osp
69
70b64 :: ByteString -> ByteString
71b64 = B.takeWhile (/= 61) . Base64.encode
72
73toStrict :: LB.ByteString -> ByteString
74toStrict = B.concat . LB.toChunks
75
76header' :: RSAKey k => k -> Header
77header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing
78
79header :: RSAKey k => k -> String -> ByteString
80header key nonce = (toStrict . encode)
81 (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce))
82
83-- | Registration payload to sign with user key.
84registration :: String -> String -> ByteString
85registration emailAddr terms = (b64 . toStrict . encode) (Reg emailAddr terms)
86
87-- | Challenge request payload to sign with user key.
88authz :: String -> ByteString
89authz = b64. toStrict . encode . Authz
90
91-- | Challenge response payload to sign with user key.
92challenge :: ByteString -> ByteString
93challenge = b64 . toStrict . encode . Challenge . BC.unpack
94
95-- | CSR request payload to sign with user key.
96csr :: ByteString -> ByteString
97csr = b64 . toStrict . encode . CSR . b64
98
99thumbprint :: JWK -> ByteString
100thumbprint = b64 . toStrict . bytestringDigest . sha256 . encodeOrdered
101
102-- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here.
103encodeOrdered :: JWK -> LB.ByteString
104encodeOrdered JWK{..} = LC.pack $
105 "{\"e\":\"" ++ hE' ++ "\",\"kty\":\"" ++ hKty ++ "\",\"n\":\"" ++ hN' ++ "\"}"
106 where
107 hE' = BC.unpack (b64i hE)
108 hN' = BC.unpack (b64i hN)
109
110
111--------------------------------------------------------------------------------
112data Header = Header
113 { hAlg :: String
114 , hJwk :: JWK
115 , hNonce :: Maybe String
116 }
117 deriving Show
118
119data JWK = JWK
120 { hE :: Integer
121 , hKty :: String
122 , hN :: Integer
123 }
124 deriving Show
125
126instance ToJSON Header where
127 toJSON Header{..} = object $
128 [ "alg" .= hAlg
129 , "jwk" .= toJSON hJwk
130 ] ++ maybeToList (("nonce" .=) <$> hNonce)
131
132instance ToJSON JWK where
133 toJSON JWK{..} = object
134 [ "e" .= decodeUtf8 (b64i hE)
135 , "kty" .= hKty
136 , "n" .= decodeUtf8 (b64i hN)
137 ]
138
139data Reg = Reg
140 { rMail :: String
141 , rAgreement :: String
142 }
143 deriving Show
144
145instance ToJSON Reg where
146 toJSON Reg{..} = object
147 [ "resource" .= ("new-reg" :: String)
148 , "contact" .= ["mailto:" ++ rMail]
149 , "agreement" .= rAgreement
150 ]
151
152data Request = Request
153 { rHeader :: Header
154 , rProtected :: ByteString
155 , rPayload :: ByteString
156 , rSignature :: ByteString
157 }
158 deriving Show
159
160instance ToJSON Request where
161 toJSON Request{..} = object
162 [ "header" .= toJSON rHeader
163 , "protected" .= decodeUtf8 rProtected
164 , "payload" .= decodeUtf8 rPayload
165 , "signature" .= decodeUtf8 rSignature
166 ]
167
168data Authz = Authz
169 { aDomain :: String
170 }
171
172instance ToJSON Authz where
173 toJSON Authz{..} = object
174 [ "resource" .= ("new-authz" :: String)
175 , "identifier" .= object
176 [ "type" .= ("dns" :: String)
177 , "value" .= aDomain
178 ]
179 ]
180
181data Challenge = Challenge
182 { cKeyAuth :: String
183 }
184
185instance ToJSON Challenge where
186 toJSON Challenge{..} = object
187 [ "resource" .= ("challenge" :: String)
188 , "keyAuthorization" .= cKeyAuth
189 ]
190
191data CSR = CSR ByteString
192 deriving Show
193
194instance ToJSON CSR where
195 toJSON (CSR s) = object
196 [ "resource" .= ("new-cert" :: String)
197 , "csr" .= decodeUtf8 s
198 ]