diff options
-rw-r--r-- | README.md | 173 | ||||
-rw-r--r-- | acme.hs | 265 | ||||
-rwxr-xr-x | generate-nonce.sh | 4 | ||||
-rw-r--r-- | lets-encrypt-x1-cross-signed.pem | 27 |
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 | |||
3 | This is a simple Haskell script to obtain a certificate from [Let's | ||
4 | Encrypt](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 | |||
12 | Most values are still hard-coded for my initial attempt (i.e. my email address | ||
13 | and a domain of mine). | ||
14 | |||
15 | |||
16 | ## Discover the URL for letsencrypt ACME endpoints | ||
17 | |||
18 | API endpoints are listed at https://acme-v01.api.letsencrypt.org/directory and | ||
19 | are 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 | |||
34 | You need an account with Let's Encrypt to ask and receive certificates for your | ||
35 | domains. The account is controlled by a public/private key pair: | ||
36 | |||
37 | ``` | ||
38 | openssl genrsa 4096 > user.key | ||
39 | openssl rsa -in user.key -pubout > user.pub | ||
40 | ``` | ||
41 | |||
42 | |||
43 | ## Generate nonces | ||
44 | |||
45 | |||
46 | Each request to the API have a nonce to prevent replays. The nonce is currently | ||
47 | hard-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 | |||
56 | Generate `registration.body` by using the `acme.hs` script then POST it to | ||
57 | letsencrypt (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 | |||
82 | Let's Encrypt needs a proof that you control the claimed domain. You can | ||
83 | request 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 | |||
120 | The script assumes you'll answer the challenge by hosting a file at a location | ||
121 | chosen by Let's Encrypt. Extract the token for the `http-01` challenge and run | ||
122 | the script again. Now you have to host the file at the location reported by the | ||
123 | script. | ||
124 | |||
125 | Once 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 | |||
139 | The same URL can then be polled until the status becomes valid. | ||
140 | |||
141 | |||
142 | ## Send CSR / Receive certificate | ||
143 | |||
144 | The 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 | |||
152 | And 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 | |||
162 | Including 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 | ``` | ||
@@ -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 | |||
8 | module Main where | ||
9 | |||
10 | import Crypto.Number.Serialize (i2osp) | ||
11 | import Data.Aeson (encode, object, ToJSON(..), (.=)) | ||
12 | import Data.ByteString (ByteString) | ||
13 | import qualified Data.ByteString as B | ||
14 | import qualified Data.ByteString.Char8 as BC | ||
15 | import qualified Data.ByteString.Lazy.Char8 as LC | ||
16 | import qualified Data.ByteString.Lazy as LB | ||
17 | import qualified Data.ByteString.Base64.URL as Base64 | ||
18 | import Data.Digest.Pure.SHA (bytestringDigest, sha256) | ||
19 | import Data.Text.Encoding (decodeUtf8) | ||
20 | import OpenSSL.EVP.PKey | ||
21 | import OpenSSL.PEM (readPublicKey) | ||
22 | import OpenSSL.RSA | ||
23 | import System.Process (readProcess) | ||
24 | |||
25 | |||
26 | -------------------------------------------------------------------------------- | ||
27 | email :: String | ||
28 | email = "noteed@gmail.com" | ||
29 | |||
30 | domain :: String | ||
31 | domain = "aaa.reesd.com" | ||
32 | |||
33 | nonce_ :: String | ||
34 | nonce_ = "ckYlMQ7BflfUb7HmxipdSpnkFle83-8lUkn50U-X97Q" | ||
35 | |||
36 | terms :: String | ||
37 | terms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" | ||
38 | |||
39 | -------------------------------------------------------------------------------- | ||
40 | main :: IO () | ||
41 | main = 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. | ||
141 | b64i = b64 . i2osp | ||
142 | |||
143 | b64 = B.takeWhile (/= 61) . Base64.encode | ||
144 | |||
145 | toStrict = B.concat . LB.toChunks | ||
146 | |||
147 | header' key = Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) Nothing | ||
148 | |||
149 | header key nonce = (toStrict . encode) | ||
150 | (Header "RS256" (JWK (rsaE key) "RSA" (rsaN key)) (Just nonce)) | ||
151 | |||
152 | registration email = (toStrict . encode) (Reg email terms) | ||
153 | |||
154 | authz = toStrict . encode . Authz | ||
155 | |||
156 | challenge = toStrict . encode . Challenge . BC.unpack | ||
157 | |||
158 | sign inp out = do | ||
159 | _ <- readProcess "openssl" | ||
160 | [ "dgst", "-sha256" | ||
161 | , "-sign", "user.key" | ||
162 | , "-out", out | ||
163 | , inp | ||
164 | ] | ||
165 | "" | ||
166 | return () | ||
167 | |||
168 | thumbprint = b64 . toStrict .bytestringDigest . sha256 . encodeOrdered | ||
169 | |||
170 | -- | There is an `encodePretty'` in `aeson-pretty`, but do it by hand here. | ||
171 | encodeOrdered 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 | -------------------------------------------------------------------------------- | ||
179 | data Header = Header | ||
180 | { hAlg :: String | ||
181 | , hJwk :: JWK | ||
182 | , hNonce :: Maybe String | ||
183 | } | ||
184 | deriving Show | ||
185 | |||
186 | data JWK = JWK | ||
187 | { hE :: Integer | ||
188 | , hKty :: String | ||
189 | , hN :: Integer | ||
190 | } | ||
191 | deriving Show | ||
192 | |||
193 | instance ToJSON Header where | ||
194 | toJSON Header{..} = object $ | ||
195 | [ "alg" .= hAlg | ||
196 | , "jwk" .= toJSON hJwk | ||
197 | ] ++ maybe [] ((:[]) . ("nonce" .=)) hNonce | ||
198 | |||
199 | instance ToJSON JWK where | ||
200 | toJSON JWK{..} = object | ||
201 | [ "e" .= decodeUtf8 (b64i hE) | ||
202 | , "kty" .= hKty | ||
203 | , "n" .= decodeUtf8 (b64i hN) | ||
204 | ] | ||
205 | |||
206 | data Reg = Reg | ||
207 | { rMail :: String | ||
208 | , rAgreement :: String | ||
209 | } | ||
210 | deriving Show | ||
211 | |||
212 | instance ToJSON Reg where | ||
213 | toJSON Reg{..} = object | ||
214 | [ "resource" .= ("new-reg" :: String) | ||
215 | , "contact" .= ["mailto:" ++ rMail] | ||
216 | , "agreement" .= rAgreement | ||
217 | ] | ||
218 | |||
219 | data Request = Request | ||
220 | { rHeader :: Header | ||
221 | , rProtected :: ByteString | ||
222 | , rPayload :: ByteString | ||
223 | , rSignature :: ByteString | ||
224 | } | ||
225 | deriving Show | ||
226 | |||
227 | instance 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 | |||
235 | data Authz = Authz | ||
236 | { aDomain :: String | ||
237 | } | ||
238 | |||
239 | instance 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 | |||
248 | data Challenge = Challenge | ||
249 | { cKeyAuth :: String | ||
250 | } | ||
251 | |||
252 | instance ToJSON Challenge where | ||
253 | toJSON Challenge{..} = object | ||
254 | [ "resource" .= ("challenge" :: String) | ||
255 | , "keyAuthorization" .= cKeyAuth | ||
256 | ] | ||
257 | |||
258 | data CSR = CSR ByteString | ||
259 | deriving Show | ||
260 | |||
261 | instance 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 | ||
2 | curl -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----- | ||
2 | MIIEqDCCA5CgAwIBAgIRAJgT9HUT5XULQ+dDHpceRL0wDQYJKoZIhvcNAQELBQAw | ||
3 | PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD | ||
4 | Ew5EU1QgUm9vdCBDQSBYMzAeFw0xNTEwMTkyMjMzMzZaFw0yMDEwMTkyMjMzMzZa | ||
5 | MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD | ||
6 | ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMTCCASIwDQYJKoZIhvcNAQEBBQAD | ||
7 | ggEPADCCAQoCggEBAJzTDPBa5S5Ht3JdN4OzaGMw6tc1Jhkl4b2+NfFwki+3uEtB | ||
8 | BaupnjUIWOyxKsRohwuj43Xk5vOnYnG6eYFgH9eRmp/z0HhncchpDpWRz/7mmelg | ||
9 | PEjMfspNdxIknUcbWuu57B43ABycrHunBerOSuu9QeU2mLnL/W08lmjfIypCkAyG | ||
10 | dGfIf6WauFJhFBM/ZemCh8vb+g5W9oaJ84U/l4avsNwa72sNlRZ9xCugZbKZBDZ1 | ||
11 | gGusSvMbkEl4L6KWTyogJSkExnTA0DHNjzE4lRa6qDO4Q/GxH8Mwf6J5MRM9LTb4 | ||
12 | 4/zyM2q5OTHFr8SNDR1kFjOq+oQpttQLwNh9w5MCAwEAAaOCAZIwggGOMBIGA1Ud | ||
13 | EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMH8GCCsGAQUFBwEBBHMwcTAy | ||
14 | BggrBgEFBQcwAYYmaHR0cDovL2lzcmcudHJ1c3RpZC5vY3NwLmlkZW50cnVzdC5j | ||
15 | b20wOwYIKwYBBQUHMAKGL2h0dHA6Ly9hcHBzLmlkZW50cnVzdC5jb20vcm9vdHMv | ||
16 | ZHN0cm9vdGNheDMucDdjMB8GA1UdIwQYMBaAFMSnsaR7LHH62+FLkHX/xBVghYkQ | ||
17 | MFQGA1UdIARNMEswCAYGZ4EMAQIBMD8GCysGAQQBgt8TAQEBMDAwLgYIKwYBBQUH | ||
18 | AgEWImh0dHA6Ly9jcHMucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcwPAYDVR0fBDUw | ||
19 | MzAxoC+gLYYraHR0cDovL2NybC5pZGVudHJ1c3QuY29tL0RTVFJPT1RDQVgzQ1JM | ||
20 | LmNybDATBgNVHR4EDDAKoQgwBoIELm1pbDAdBgNVHQ4EFgQUqEpqYwR93brm0Tm3 | ||
21 | pkVl7/Oo7KEwDQYJKoZIhvcNAQELBQADggEBANHIIkus7+MJiZZQsY14cCoBG1hd | ||
22 | v0J20/FyWo5ppnfjL78S2k4s2GLRJ7iD9ZDKErndvbNFGcsW+9kKK/TnY21hp4Dd | ||
23 | ITv8S9ZYQ7oaoqs7HwhEMY9sibED4aXw09xrJZTC9zK1uIfW6t5dHQjuOWv+HHoW | ||
24 | ZnupyxpsEUlEaFb+/SCI4KCSBdAsYxAcsHYI5xxEI4LutHp6s3OT2FuO90WfdsIk | ||
25 | 6q78OMSdn875bNjdBYAqxUp2/LEIHfDBkLoQz0hFJmwAbYahqKaLn73PAAm1X2kj | ||
26 | f1w8DdnkabOLGeOVcj9LQ+s67vBykx4anTjURkbqZslUEUsn2k5xeua2zUk= | ||
27 | -----END CERTIFICATE----- | ||