summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVo Minh Thu <thu@hypered.io>2015-12-18 07:46:48 +0100
committerVo Minh Thu <thu@hypered.io>2015-12-18 07:46:48 +0100
commitc26b53ba48e5b01c817cf2219cfecb62ee16559b (patch)
tree09ed1d6519e43d4966856f39ef383d70f0880e76
Initial commit.
-rw-r--r--README.md173
-rw-r--r--acme.hs265
-rwxr-xr-xgenerate-nonce.sh4
-rw-r--r--lets-encrypt-x1-cross-signed.pem27
4 files changed, 469 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c101c43
--- /dev/null
+++ b/README.md
@@ -0,0 +1,173 @@
1# Let's Encrypt ACME protocol
2
3This is a simple Haskell script to obtain a certificate from [Let's
4Encrypt](https://letsencrypt.org/) using their ACME protocol.
5
6
7- The main source of information to write this was
8 https://github.com/diafygi/letsencrypt-nosudo
9
10- The ACME spec: https://letsencrypt.github.io/acme-spec/
11
12Most values are still hard-coded for my initial attempt (i.e. my email address
13and a domain of mine).
14
15
16## Discover the URL for letsencrypt ACME endpoints
17
18API endpoints are listed at https://acme-v01.api.letsencrypt.org/directory and
19are currently hard-coded in the script.
20
21```
22> curl -s https://acme-v01.api.letsencrypt.org/directory | json_pp
23{
24 "new-cert" : "https://acme-v01.api.letsencrypt.org/acme/new-cert",
25 "new-authz" : "https://acme-v01.api.letsencrypt.org/acme/new-authz",
26 "revoke-cert" : "https://acme-v01.api.letsencrypt.org/acme/revoke-cert",
27 "new-reg" : "https://acme-v01.api.letsencrypt.org/acme/new-reg"
28}
29```
30
31
32## Generate user account keys
33
34You need an account with Let's Encrypt to ask and receive certificates for your
35domains. The account is controlled by a public/private key pair:
36
37```
38openssl genrsa 4096 > user.key
39openssl rsa -in user.key -pubout > user.pub
40```
41
42
43## Generate nonces
44
45
46Each request to the API have a nonce to prevent replays. The nonce is currently
47hard-coded in the script. New nonces can be obtained from letsencrypt with
48
49```
50> generate-nonce.sh
51```
52
53
54## Create user account
55
56Generate `registration.body` by using the `acme.hs` script then POST it to
57letsencrypt (note it assumes you agree to their subscriber agreement):
58
59```
60> curl -s -X POST --data-binary "@registration.body" \
61 https://acme-v01.api.letsencrypt.org/acme/new-reg | json_pp
62{
63 "agreement" : "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf",
64 "contact" : [
65 "mailto:noteed@gmail.com"
66 ],
67 "key" : {
68 "e" : "...",
69 "kty" : "RSA",
70 "n" : "..."
71 },
72 "id" : 36009,
73 "createdAt" : "2015-12-04T14:22:08.321951547Z",
74 "initialIp" : "80.236.245.73"
75}
76```
77
78
79## Request a challenge
80
81
82Let's Encrypt needs a proof that you control the claimed domain. You can
83request a challenge with `challenge-request.body`.
84
85```
86> curl -s -X POST --data-binary "@challenge-request.body" \
87 https://acme-v01.api.letsencrypt.org/acme/new-authz | json_pp
88{
89 "expires" : "2015-12-21T18:44:52.331487674Z",
90 "challenges" : [
91 {
92 "status" : "pending",
93 "uri" : "https://acme-v01.api.letsencrypt.org/acme/challenge/vXZ1UnZ-y1q7sntnf6NdOfbPAwetJFBqOtvp7FHCjaU/1844047",
94 "type" : "tls-sni-01",
95 "token" : "oielAbB7MdyCl29mqjzlqGdrCQSB8SyJaxHbAgQBA7Q"
96 },
97 {
98 "uri" : "https://acme-v01.api.letsencrypt.org/acme/challenge/vXZ1UnZ-y1q7sntnf6NdOfbPAwetJFBqOtvp7FHCjaU/1844048",
99 "status" : "pending",
100 "type" : "http-01",
101 "token" : "DjyJpI3HVWAmsAwMT5ZFpW8dj19cel6ml6qaBUeGpCg"
102 }
103 ],
104 "identifier" : {
105 "type" : "dns",
106 "value" : "aaa.reesd.com"
107 },
108 "combinations" : [
109 [
110 0
111 ],
112 [
113 1
114 ]
115 ],
116 "status" : "pending"
117}
118```
119
120The script assumes you'll answer the challenge by hosting a file at a location
121chosen by Let's Encrypt. Extract the token for the `http-01` challenge and run
122the script again. Now you have to host the file at the location reported by the
123script.
124
125Once this is done, you can ask Let's Encrypt to check the file.
126
127```
128> curl -s -X POST --data-binary "@challenge-response.body" \
129 https://acme-v01.api.letsencrypt.org/acme/challenge/vXZ1UnZ-y1q7sntnf6NdOfbPAwetJFBqOtvp7FHCjaU/1844048 | json_pp
130{
131 "token" : "DjyJpI3HVWAmsAwMT5ZFpW8dj19cel6ml6qaBUeGpCg",
132 "keyAuthorization" : "DjyJpI3HVWAmsAwMT5ZFpW8dj19cel6ml6qaBUeGpCg.EJe0KReqzCUq6leNOerMC9naZSHxP9TJzGxCcsGkNrw",
133 "type" : "http-01",
134 "uri" : "https://acme-v01.api.letsencrypt.org/acme/challenge/vXZ1UnZ-y1q7sntnf6NdOfbPAwetJFBqOtvp7FHCjaU/1844048",
135 "status" : "pending"
136}
137```
138
139The same URL can then be polled until the status becomes valid.
140
141
142## Send CSR / Receive certificate
143
144The CSR is created with:
145
146```
147> openssl genrsa 4096 > domain.key
148> openssl req -new -sha256 -key domain.key -subj "/CN=aaa.reesd.com" > aaa.reesd.com.csr
149> openssl req -in aaa.reesd.com.csr -outform DER > aaa.reesd.com.csr.der
150```
151
152And the signed certificate can be obtained from Let's Encrypt:
153
154```
155> curl -s -X POST --data-binary "@csr-request.body" \
156 https://acme-v01.api.letsencrypt.org/acme/new-cert > aaa.reesd.com.cert.der
157```
158
159
160## Create a certificate for HAProxy
161
162Including explicit DH key exchange parameters to prevent Logjam attack
163(https://weakdh.org/).
164
165```
166> openssl x509 -inform der -in aaa.reesd.com.cert.der \
167 -out aaa.reesd.com.cert.pem
168> openssl dhparam -out aaa.reesd.com-dhparams.pem 2048
169> cat aaa.reesd.com.cert.pem \
170 lets-encrypt-x1-cross-signed.pem \
171 aaa.reesd.com.key \
172 aaa.reesd.com-dhparams.pem > aaa.reesd.com-combined.pem
173```
diff --git a/acme.hs b/acme.hs
new file mode 100644
index 0000000..c1419a3
--- /dev/null
+++ b/acme.hs
@@ -0,0 +1,265 @@
1{-# LANGUAGE OverloadedStrings #-}
2{-# LANGUAGE RecordWildCards #-}
3{-# LANGUAGE ScopedTypeVariables #-}
4
5--------------------------------------------------------------------------------
6-- | Get a certificate from Let's Encrypt using the ACME protocol.
7
8module Main where
9
10import Crypto.Number.Serialize (i2osp)
11import Data.Aeson (encode, object, ToJSON(..), (.=))
12import Data.ByteString (ByteString)
13import qualified Data.ByteString as B
14import qualified Data.ByteString.Char8 as BC
15import qualified Data.ByteString.Lazy.Char8 as LC
16import qualified Data.ByteString.Lazy as LB
17import qualified Data.ByteString.Base64.URL as Base64
18import Data.Digest.Pure.SHA (bytestringDigest, sha256)
19import Data.Text.Encoding (decodeUtf8)
20import OpenSSL.EVP.PKey
21import OpenSSL.PEM (readPublicKey)
22import OpenSSL.RSA
23import System.Process (readProcess)
24
25
26--------------------------------------------------------------------------------
27email :: String
28email = "noteed@gmail.com"
29
30domain :: String
31domain = "aaa.reesd.com"
32
33nonce_ :: String
34nonce_ = "ckYlMQ7BflfUb7HmxipdSpnkFle83-8lUkn50U-X97Q"
35
36terms :: String
37terms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"
38
39--------------------------------------------------------------------------------
40main :: IO ()
41main = do
42 userKey_ <- readFile "user.pub" >>= readPublicKey
43 case toPublicKey userKey_ of
44 Nothing -> error "Not a public RSA key."
45 Just (userKey :: RSAPubKey) -> do
46 let protected = b64 (header userKey nonce_)
47
48--------------------------------------------------------------------------------
49 -- Create user account
50
51 let reg = registration email
52 payload = b64 reg
53
54 -- Registration payload to sign with user key.
55 LB.writeFile "registration.txt"
56 (LB.fromChunks [protected, ".", payload])
57
58 -- Sign with user key.
59 sign "registration.txt" "registration.sig"
60 sig_ <- B.readFile "registration.sig"
61 let sig = b64 sig_
62
63 -- Registration POST body.
64 LB.writeFile "registration.body"
65 (encode (Request (header' userKey) protected payload sig))
66
67--------------------------------------------------------------------------------
68 -- Obtain a challenge
69
70 let auth = authz domain
71 payload = b64 auth
72
73 -- Challenge request payload to sign with user key.
74 LB.writeFile "challenge-request.txt"
75 (LB.fromChunks [protected, ".", payload])
76
77 -- Sign with user key.
78 sign "challenge-request.txt" "challenge-request.sig"
79 sig_ <- B.readFile "challenge-request.sig"
80 let sig = b64 sig_
81
82 -- Challenge request POST body.
83 LB.writeFile "challenge-request.body"
84 (encode (Request (header' userKey) protected payload sig))
85
86--------------------------------------------------------------------------------
87 -- Notify Let's Encrypt we answsered the challenge
88
89 let thumb = thumbprint (JWK (rsaE userKey) "RSA" (rsaN userKey))
90 -- Extracted from POST response above.
91 token = "DjyJpI3HVWAmsAwMT5ZFpW8dj19cel6ml6qaBUeGpCg"
92 thumbtoken = toStrict (LB.fromChunks [token, ".", thumb])
93
94 putStrLn ("Serve http://" ++ domain ++ "/.well-known/acme-challenge/" ++
95 BC.unpack token)
96 putStrLn ("With content:\n" ++ BC.unpack thumbtoken)
97
98 let chall = challenge thumbtoken
99 payload = b64 chall
100
101 -- Challenge response payload to sign with user key.
102 LB.writeFile "challenge-response.txt"
103 (LB.fromChunks [protected, ".", payload])
104
105 -- Sign with user key.
106 sign "challenge-response.txt" "challenge-response.sig"
107 sig_ <- B.readFile "challenge-response.sig"
108 let sig = b64 sig_
109
110 -- Challenge response POST body. (Means the server can query our server.)
111 LB.writeFile "challenge-response.body"
112 (encode (Request (header' userKey) protected payload sig))
113
114--------------------------------------------------------------------------------
115 -- Wait for challenge validation
116
117--------------------------------------------------------------------------------
118 -- Send a CSR and get a certificate
119
120 csr_ <- B.readFile (domain ++ ".csr.der")
121
122 let csr = (toStrict . encode . CSR) (b64 csr_)
123 payload = b64 csr
124
125 -- CSR request payload to sign with user key.
126 LB.writeFile "csr-request.txt"
127 (LB.fromChunks [protected, ".", payload])
128
129 -- Sign with user key.
130 sign "csr-request.txt" "csr-request.sig"
131 sig_ <- B.readFile "csr-request.sig"
132 let sig = b64 sig_
133
134 -- CSR request POST body.
135 LB.writeFile "csr-request.body"
136 (encode (Request (header' userKey) protected payload sig))
137
138
139--------------------------------------------------------------------------------
140-- | Base64URL encoding of Integer with padding '=' removed.
141b64i = b64 . i2osp
142
143b64 = B.takeWhile (/= 61) . Base64.encode
144
145toStrict = B.concat . LB.toChunks
146
147header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing
148
149header key nonce = (toStrict . encode)
150 (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce))
151
152registration email = (toStrict . encode) (Reg email terms)
153
154authz = toStrict . encode . Authz
155
156challenge = toStrict . encode . Challenge . BC.unpack
157
158sign inp out = do
159 _ <- readProcess "openssl"
160 [ "dgst", "-sha256"
161 , "-sign", "user.key"
162 , "-out", out
163 , inp
164 ]
165 ""
166 return ()
167
168thumbprint = b64 . toStrict .bytestringDigest . sha256 . encodeOrdered
169
170-- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here.
171encodeOrdered JWK{..} = LC.pack $
172 "{\"e\":\"" ++ hE' ++ "\",\"kty\":\"" ++ hKty ++ "\",\"n\":\"" ++ hN' ++ "\"}"
173 where
174 hE' = BC.unpack (b64i hE)
175 hN' = BC.unpack (b64i hN)
176
177
178--------------------------------------------------------------------------------
179data Header = Header
180 { hAlg :: String
181 , hJwk :: JWK
182 , hNonce :: Maybe String
183 }
184 deriving Show
185
186data JWK = JWK
187 { hE :: Integer
188 , hKty :: String
189 , hN :: Integer
190 }
191 deriving Show
192
193instance ToJSON Header where
194 toJSON Header{..} = object $
195 [ "alg" .= hAlg
196 , "jwk" .= toJSON hJwk
197 ] ++ maybe [] ((:[]) . ("nonce" .=)) hNonce
198
199instance ToJSON JWK where
200 toJSON JWK{..} = object
201 [ "e" .= decodeUtf8 (b64i hE)
202 , "kty" .= hKty
203 , "n" .= decodeUtf8 (b64i hN)
204 ]
205
206data Reg = Reg
207 { rMail :: String
208 , rAgreement :: String
209 }
210 deriving Show
211
212instance ToJSON Reg where
213 toJSON Reg{..} = object
214 [ "resource" .= ("new-reg" :: String)
215 , "contact" .= ["mailto:" ++ rMail]
216 , "agreement" .= rAgreement
217 ]
218
219data Request = Request
220 { rHeader :: Header
221 , rProtected :: ByteString
222 , rPayload :: ByteString
223 , rSignature :: ByteString
224 }
225 deriving Show
226
227instance ToJSON Request where
228 toJSON Request{..} = object
229 [ "header" .= toJSON rHeader
230 , "protected" .= decodeUtf8 rProtected
231 , "payload" .= decodeUtf8 rPayload
232 , "signature" .= decodeUtf8 rSignature
233 ]
234
235data Authz = Authz
236 { aDomain :: String
237 }
238
239instance ToJSON Authz where
240 toJSON Authz{..} = object
241 [ "resource" .= ("new-authz" :: String)
242 , "identifier" .= object
243 [ "type" .= ("dns" :: String)
244 , "value" .= aDomain
245 ]
246 ]
247
248data Challenge = Challenge
249 { cKeyAuth :: String
250 }
251
252instance ToJSON Challenge where
253 toJSON Challenge{..} = object
254 [ "resource" .= ("challenge" :: String)
255 , "keyAuthorization" .= cKeyAuth
256 ]
257
258data CSR = CSR ByteString
259 deriving Show
260
261instance ToJSON CSR where
262 toJSON (CSR s) = object
263 [ "resource" .= ("new-cert" :: String)
264 , "csr" .= decodeUtf8 s
265 ]
diff --git a/generate-nonce.sh b/generate-nonce.sh
new file mode 100755
index 0000000..ad72d5c
--- /dev/null
+++ b/generate-nonce.sh
@@ -0,0 +1,4 @@
1#! /bin/bash
2curl -s -i https://acme-v01.api.letsencrypt.org/directory \
3 | grep Replay-Nonce \
4 | cut -d ' ' -f 2
diff --git a/lets-encrypt-x1-cross-signed.pem b/lets-encrypt-x1-cross-signed.pem
new file mode 100644
index 0000000..8a92a0b
--- /dev/null
+++ b/lets-encrypt-x1-cross-signed.pem
@@ -0,0 +1,27 @@
1-----BEGIN CERTIFICATE-----
2MIIEqDCCA5CgAwIBAgIRAJgT9HUT5XULQ+dDHpceRL0wDQYJKoZIhvcNAQELBQAw
3PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD
4Ew5EU1QgUm9vdCBDQSBYMzAeFw0xNTEwMTkyMjMzMzZaFw0yMDEwMTkyMjMzMzZa
5MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
6ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMTCCASIwDQYJKoZIhvcNAQEBBQAD
7ggEPADCCAQoCggEBAJzTDPBa5S5Ht3JdN4OzaGMw6tc1Jhkl4b2+NfFwki+3uEtB
8BaupnjUIWOyxKsRohwuj43Xk5vOnYnG6eYFgH9eRmp/z0HhncchpDpWRz/7mmelg
9PEjMfspNdxIknUcbWuu57B43ABycrHunBerOSuu9QeU2mLnL/W08lmjfIypCkAyG
10dGfIf6WauFJhFBM/ZemCh8vb+g5W9oaJ84U/l4avsNwa72sNlRZ9xCugZbKZBDZ1
11gGusSvMbkEl4L6KWTyogJSkExnTA0DHNjzE4lRa6qDO4Q/GxH8Mwf6J5MRM9LTb4
124/zyM2q5OTHFr8SNDR1kFjOq+oQpttQLwNh9w5MCAwEAAaOCAZIwggGOMBIGA1Ud
13EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMH8GCCsGAQUFBwEBBHMwcTAy
14BggrBgEFBQcwAYYmaHR0cDovL2lzcmcudHJ1c3RpZC5vY3NwLmlkZW50cnVzdC5j
15b20wOwYIKwYBBQUHMAKGL2h0dHA6Ly9hcHBzLmlkZW50cnVzdC5jb20vcm9vdHMv
16ZHN0cm9vdGNheDMucDdjMB8GA1UdIwQYMBaAFMSnsaR7LHH62+FLkHX/xBVghYkQ
17MFQGA1UdIARNMEswCAYGZ4EMAQIBMD8GCysGAQQBgt8TAQEBMDAwLgYIKwYBBQUH
18AgEWImh0dHA6Ly9jcHMucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcwPAYDVR0fBDUw
19MzAxoC+gLYYraHR0cDovL2NybC5pZGVudHJ1c3QuY29tL0RTVFJPT1RDQVgzQ1JM
20LmNybDATBgNVHR4EDDAKoQgwBoIELm1pbDAdBgNVHQ4EFgQUqEpqYwR93brm0Tm3
21pkVl7/Oo7KEwDQYJKoZIhvcNAQELBQADggEBANHIIkus7+MJiZZQsY14cCoBG1hd
22v0J20/FyWo5ppnfjL78S2k4s2GLRJ7iD9ZDKErndvbNFGcsW+9kKK/TnY21hp4Dd
23ITv8S9ZYQ7oaoqs7HwhEMY9sibED4aXw09xrJZTC9zK1uIfW6t5dHQjuOWv+HHoW
24ZnupyxpsEUlEaFb+/SCI4KCSBdAsYxAcsHYI5xxEI4LutHp6s3OT2FuO90WfdsIk
256q78OMSdn875bNjdBYAqxUp2/LEIHfDBkLoQz0hFJmwAbYahqKaLn73PAAm1X2kj
26f1w8DdnkabOLGeOVcj9LQ+s67vBykx4anTjURkbqZslUEUsn2k5xeua2zUk=
27-----END CERTIFICATE-----