diff options
author | Andrew Cady <d@jerkface.net> | 2016-01-22 11:36:37 -0500 |
---|---|---|
committer | Andrew Cady <d@jerkface.net> | 2016-01-22 18:19:25 -0500 |
commit | 15d6572b9fa0ff6b0105eaa26583f496b18f78b4 (patch) | |
tree | 2a60a040495c9c8080bae50e6dd871a42446ad6b | |
parent | 3581adc163fd0b41485d822944efe6cdd4607aed (diff) |
Factored out Network.ACME library
-rw-r--r-- | acme-encrypt.cabal | 30 | ||||
-rw-r--r-- | acme.hs | 146 | ||||
-rw-r--r-- | src/Network/ACME.hs | 198 |
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 | |||
2 | version: 0.1.0.0 | 2 | version: 0.1.0.0 |
3 | synopsis: Get a certificate using Let's Encrypt ACME protocol | 3 | synopsis: Get a certificate using Let's Encrypt ACME protocol |
4 | description: Please see README.md | 4 | description: Please see README.md |
5 | homepage: https://github.com/noteed/acme | 5 | homepage: https://github.com/afcady/acme |
6 | author: Vo Minh Thu | 6 | author: Vo Minh Thu, Andrew Cady |
7 | maintainer: noteed@gmail.com | 7 | maintainer: noteed@gmail.com |
8 | copyright: 2016 Vo Minh Thu | 8 | copyright: 2016 Vo Minh Thu, Andrew Cady |
9 | category: Web | 9 | category: Web |
10 | build-type: Simple | 10 | build-type: Simple |
11 | -- extra-source-files: | 11 | -- extra-source-files: |
12 | cabal-version: >=1.10 | 12 | cabal-version: >=1.10 |
13 | 13 | ||
14 | -- library | 14 | library |
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 | ||
20 | executable acme-encrypt-exe | 23 | executable 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 | 42 | source-repository head |
41 | -- type: git | 43 | type: git |
42 | -- location: https://github.com/githubuser/acme-encrypt | 44 | location: https://github.com/afcady/acme |
@@ -49,6 +49,8 @@ import Options.Applicative hiding (header) | |||
49 | import qualified Options.Applicative as Opt | 49 | import qualified Options.Applicative as Opt |
50 | import System.Directory | 50 | import System.Directory |
51 | 51 | ||
52 | import Network.ACME | ||
53 | |||
52 | stagingDirectoryUrl, liveDirectoryUrl :: String | 54 | stagingDirectoryUrl, liveDirectoryUrl :: String |
53 | liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" | 55 | liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" |
54 | stagingDirectoryUrl = "https://acme-staging.api.letsencrypt.org/directory" | 56 | stagingDirectoryUrl = "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 | ||
121 | data Keys = Keys SomeKeyPair RSAPubKey | ||
122 | readKeys :: String -> IO Keys | 123 | readKeys :: String -> IO Keys |
123 | readKeys privKeyFile = do | 124 | readKeys 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. | ||
293 | signPayload :: Keys -> String -> ByteString -> IO LC.ByteString | ||
294 | signPayload (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. | ||
302 | b64i :: Integer -> ByteString | ||
303 | b64i = b64 . i2osp | ||
304 | |||
305 | b64 :: ByteString -> ByteString | ||
306 | b64 = B.takeWhile (/= 61) . Base64.encode | ||
307 | |||
308 | toStrict :: LB.ByteString -> ByteString | ||
309 | toStrict = B.concat . LB.toChunks | ||
310 | |||
311 | header' :: RSAKey k => k -> Header | ||
312 | header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing | ||
313 | |||
314 | header :: RSAKey k => k -> String -> ByteString | ||
315 | header key nonce = (toStrict . encode) | ||
316 | (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce)) | ||
317 | |||
318 | -- | Registration payload to sign with user key. | ||
319 | registration :: String -> String -> ByteString | ||
320 | registration emailAddr terms = (b64 . toStrict . encode) (Reg emailAddr terms) | ||
321 | |||
322 | -- | Challenge request payload to sign with user key. | ||
323 | authz :: String -> ByteString | ||
324 | authz = b64. toStrict . encode . Authz | ||
325 | |||
326 | -- | Challenge response payload to sign with user key. | ||
327 | challenge :: ByteString -> ByteString | ||
328 | challenge = b64 . toStrict . encode . Challenge . BC.unpack | ||
329 | |||
330 | -- | CSR request payload to sign with user key. | ||
331 | csr :: ByteString -> ByteString | ||
332 | csr = b64 . toStrict . encode . CSR . b64 | ||
333 | |||
334 | thumbprint :: JWK -> ByteString | ||
335 | thumbprint = b64 . toStrict . bytestringDigest . sha256 . encodeOrdered | ||
336 | |||
337 | -- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here. | ||
338 | encodeOrdered :: JWK -> LB.ByteString | ||
339 | encodeOrdered 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 | -------------------------------------------------------------------------------- | ||
347 | data Header = Header | ||
348 | { hAlg :: String | ||
349 | , hJwk :: JWK | ||
350 | , hNonce :: Maybe String | ||
351 | } | ||
352 | deriving Show | ||
353 | |||
354 | data JWK = JWK | ||
355 | { hE :: Integer | ||
356 | , hKty :: String | ||
357 | , hN :: Integer | ||
358 | } | ||
359 | deriving Show | ||
360 | |||
361 | instance ToJSON Header where | ||
362 | toJSON Header{..} = object $ | ||
363 | [ "alg" .= hAlg | ||
364 | , "jwk" .= toJSON hJwk | ||
365 | ] ++ maybeToList (("nonce" .=) <$> hNonce) | ||
366 | |||
367 | instance ToJSON JWK where | ||
368 | toJSON JWK{..} = object | ||
369 | [ "e" .= decodeUtf8 (b64i hE) | ||
370 | , "kty" .= hKty | ||
371 | , "n" .= decodeUtf8 (b64i hN) | ||
372 | ] | ||
373 | |||
374 | data Reg = Reg | ||
375 | { rMail :: String | ||
376 | , rAgreement :: String | ||
377 | } | ||
378 | deriving Show | ||
379 | |||
380 | instance ToJSON Reg where | ||
381 | toJSON Reg{..} = object | ||
382 | [ "resource" .= ("new-reg" :: String) | ||
383 | , "contact" .= ["mailto:" ++ rMail] | ||
384 | , "agreement" .= rAgreement | ||
385 | ] | ||
386 | |||
387 | data Request = Request | ||
388 | { rHeader :: Header | ||
389 | , rProtected :: ByteString | ||
390 | , rPayload :: ByteString | ||
391 | , rSignature :: ByteString | ||
392 | } | ||
393 | deriving Show | ||
394 | |||
395 | instance 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 | |||
403 | data Authz = Authz | ||
404 | { aDomain :: String | ||
405 | } | ||
406 | |||
407 | instance 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 | |||
416 | data Challenge = Challenge | ||
417 | { cKeyAuth :: String | ||
418 | } | ||
419 | |||
420 | instance ToJSON Challenge where | ||
421 | toJSON Challenge{..} = object | ||
422 | [ "resource" .= ("challenge" :: String) | ||
423 | , "keyAuthorization" .= cKeyAuth | ||
424 | ] | ||
425 | |||
426 | data CSR = CSR ByteString | ||
427 | deriving Show | ||
428 | |||
429 | instance 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 | |||
7 | module Network.ACME ( | ||
8 | Keys(..), | ||
9 | thumbprint, | ||
10 | JWK(..), | ||
11 | toStrict, | ||
12 | csr, | ||
13 | challenge, | ||
14 | registration, | ||
15 | authz, | ||
16 | signPayload, | ||
17 | ) where | ||
18 | |||
19 | import Control.Lens hiding ((.=)) | ||
20 | import Control.Monad | ||
21 | import Control.Monad.RWS.Strict | ||
22 | import Crypto.Number.Serialize (i2osp) | ||
23 | import Data.Aeson (ToJSON (..), Value, encode, object, | ||
24 | (.=)) | ||
25 | import Data.Aeson.Lens hiding (key) | ||
26 | import qualified Data.Aeson.Lens as JSON | ||
27 | import Data.ByteString (ByteString) | ||
28 | import qualified Data.ByteString as B | ||
29 | import qualified Data.ByteString.Base64.URL as Base64 | ||
30 | import qualified Data.ByteString.Char8 as BC | ||
31 | import qualified Data.ByteString.Lazy as LB | ||
32 | import qualified Data.ByteString.Lazy.Char8 as LC | ||
33 | import Data.Coerce | ||
34 | import Data.Digest.Pure.SHA (bytestringDigest, sha256) | ||
35 | import Data.Maybe | ||
36 | import Data.String (fromString) | ||
37 | import qualified Data.Text as T | ||
38 | import Data.Text.Encoding (decodeUtf8, encodeUtf8) | ||
39 | import Data.Time.Clock.POSIX (getPOSIXTime) | ||
40 | import Network.Wreq (Response, checkStatus, defaults, | ||
41 | responseBody, responseHeader, | ||
42 | responseStatus, statusCode, | ||
43 | statusMessage) | ||
44 | import qualified Network.Wreq as W | ||
45 | import qualified Network.Wreq.Session as WS | ||
46 | import OpenSSL | ||
47 | import OpenSSL.EVP.Digest | ||
48 | import OpenSSL.EVP.PKey | ||
49 | import OpenSSL.EVP.Sign | ||
50 | import OpenSSL.PEM | ||
51 | import OpenSSL.RSA | ||
52 | import OpenSSL.X509.Request | ||
53 | |||
54 | data Keys = Keys SomeKeyPair RSAPubKey | ||
55 | |||
56 | -------------------------------------------------------------------------------- | ||
57 | -- | Sign return a payload with a nonce-protected header. | ||
58 | signPayload :: Keys -> String -> ByteString -> IO LC.ByteString | ||
59 | signPayload (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. | ||
67 | b64i :: Integer -> ByteString | ||
68 | b64i = b64 . i2osp | ||
69 | |||
70 | b64 :: ByteString -> ByteString | ||
71 | b64 = B.takeWhile (/= 61) . Base64.encode | ||
72 | |||
73 | toStrict :: LB.ByteString -> ByteString | ||
74 | toStrict = B.concat . LB.toChunks | ||
75 | |||
76 | header' :: RSAKey k => k -> Header | ||
77 | header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing | ||
78 | |||
79 | header :: RSAKey k => k -> String -> ByteString | ||
80 | header key nonce = (toStrict . encode) | ||
81 | (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce)) | ||
82 | |||
83 | -- | Registration payload to sign with user key. | ||
84 | registration :: String -> String -> ByteString | ||
85 | registration emailAddr terms = (b64 . toStrict . encode) (Reg emailAddr terms) | ||
86 | |||
87 | -- | Challenge request payload to sign with user key. | ||
88 | authz :: String -> ByteString | ||
89 | authz = b64. toStrict . encode . Authz | ||
90 | |||
91 | -- | Challenge response payload to sign with user key. | ||
92 | challenge :: ByteString -> ByteString | ||
93 | challenge = b64 . toStrict . encode . Challenge . BC.unpack | ||
94 | |||
95 | -- | CSR request payload to sign with user key. | ||
96 | csr :: ByteString -> ByteString | ||
97 | csr = b64 . toStrict . encode . CSR . b64 | ||
98 | |||
99 | thumbprint :: JWK -> ByteString | ||
100 | thumbprint = b64 . toStrict . bytestringDigest . sha256 . encodeOrdered | ||
101 | |||
102 | -- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here. | ||
103 | encodeOrdered :: JWK -> LB.ByteString | ||
104 | encodeOrdered 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 | -------------------------------------------------------------------------------- | ||
112 | data Header = Header | ||
113 | { hAlg :: String | ||
114 | , hJwk :: JWK | ||
115 | , hNonce :: Maybe String | ||
116 | } | ||
117 | deriving Show | ||
118 | |||
119 | data JWK = JWK | ||
120 | { hE :: Integer | ||
121 | , hKty :: String | ||
122 | , hN :: Integer | ||
123 | } | ||
124 | deriving Show | ||
125 | |||
126 | instance ToJSON Header where | ||
127 | toJSON Header{..} = object $ | ||
128 | [ "alg" .= hAlg | ||
129 | , "jwk" .= toJSON hJwk | ||
130 | ] ++ maybeToList (("nonce" .=) <$> hNonce) | ||
131 | |||
132 | instance ToJSON JWK where | ||
133 | toJSON JWK{..} = object | ||
134 | [ "e" .= decodeUtf8 (b64i hE) | ||
135 | , "kty" .= hKty | ||
136 | , "n" .= decodeUtf8 (b64i hN) | ||
137 | ] | ||
138 | |||
139 | data Reg = Reg | ||
140 | { rMail :: String | ||
141 | , rAgreement :: String | ||
142 | } | ||
143 | deriving Show | ||
144 | |||
145 | instance ToJSON Reg where | ||
146 | toJSON Reg{..} = object | ||
147 | [ "resource" .= ("new-reg" :: String) | ||
148 | , "contact" .= ["mailto:" ++ rMail] | ||
149 | , "agreement" .= rAgreement | ||
150 | ] | ||
151 | |||
152 | data Request = Request | ||
153 | { rHeader :: Header | ||
154 | , rProtected :: ByteString | ||
155 | , rPayload :: ByteString | ||
156 | , rSignature :: ByteString | ||
157 | } | ||
158 | deriving Show | ||
159 | |||
160 | instance 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 | |||
168 | data Authz = Authz | ||
169 | { aDomain :: String | ||
170 | } | ||
171 | |||
172 | instance 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 | |||
181 | data Challenge = Challenge | ||
182 | { cKeyAuth :: String | ||
183 | } | ||
184 | |||
185 | instance ToJSON Challenge where | ||
186 | toJSON Challenge{..} = object | ||
187 | [ "resource" .= ("challenge" :: String) | ||
188 | , "keyAuthorization" .= cKeyAuth | ||
189 | ] | ||
190 | |||
191 | data CSR = CSR ByteString | ||
192 | deriving Show | ||
193 | |||
194 | instance ToJSON CSR where | ||
195 | toJSON (CSR s) = object | ||
196 | [ "resource" .= ("new-cert" :: String) | ||
197 | , "csr" .= decodeUtf8 s | ||
198 | ] | ||