diff options
-rw-r--r-- | README.md | 52 | ||||
-rw-r--r-- | acme-certify.hs | 130 |
2 files changed, 111 insertions, 71 deletions
@@ -1,31 +1,35 @@ | |||
1 | # Let's Encrypt ACME protocol | 1 | # `acme-certify` -- A `Let's Encrypt!` ACME client |
2 | 2 | ||
3 | ``` | 3 | ``` |
4 | Let's Encrypt! ACME client | 4 | Let's Encrypt! ACME client |
5 | 5 | ||
6 | Usage: acme-certify --key FILE --domain DOMAIN --challenge-dir DIR | 6 | Usage: acme-certify --key FILE --domain DOMAIN --challenge-dir DIR |
7 | [--domain-dir DIR] [--email ADDRESS] [--terms URL] | 7 | [--domain-dir DIR] [--email ADDRESS] [--terms URL] |
8 | [--staging] | 8 | [--skip-dhparams] [--staging] [--skip-provision-check] |
9 | This program will generate a signed TLS certificate using the ACME protocol | 9 | This program will generate a signed TLS certificate using the ACME protocol |
10 | and the free Let's Encrypt! CA. | 10 | and the free Let's Encrypt! CA. |
11 | 11 | ||
12 | Available options: | 12 | Available options: |
13 | -h,--help Show this help text | 13 | -h,--help Show this help text |
14 | --key FILE filename of your private RSA key | 14 | --key FILE Filename of your private RSA key |
15 | --domain DOMAIN the domain name(s) to certify; specify more than once | 15 | --domain DOMAIN The domain name(s) to certify; specify more than once |
16 | for a multi-domain certificate | 16 | for a multi-domain certificate |
17 | --challenge-dir DIR output directory for ACME challenges | 17 | --challenge-dir DIR Output directory for ACME challenges |
18 | --domain-dir DIR directory in which to domain certificates and keys | 18 | --domain-dir DIR Directory in which to domain certificates and keys |
19 | are stored; the default is to use the (first) domain | 19 | are stored; the default is to use the (first) domain |
20 | name as a directory name | 20 | name as a directory name |
21 | --email ADDRESS an email address with which to register an account | 21 | --email ADDRESS An email address with which to register an account |
22 | --terms URL the terms param of the registration request | 22 | --terms URL The terms param of the registration request |
23 | --staging use staging servers instead of live servers | 23 | --skip-dhparams Don't generate DH params for combined cert |
24 | --staging Use staging servers instead of live servers | ||
24 | (generated certificates will not be trusted!) | 25 | (generated certificates will not be trusted!) |
26 | --skip-provision-check Don't test whether HTTP provisioning works before | ||
27 | making ACME requests; only useful for testing. | ||
25 | ``` | 28 | ``` |
26 | 29 | ||
27 | This program can be used to obtain a certificate from | 30 | This program can be used to obtain a certificate from the |
28 | [Let's Encrypt](https://letsencrypt.org/) using their ACME protocol. | 31 | [Let's Encrypt](https://letsencrypt.org/) certificate authority, using their |
32 | ACME protocol. | ||
29 | 33 | ||
30 | ## Rate Limits | 34 | ## Rate Limits |
31 | 35 | ||
@@ -63,27 +67,35 @@ openssl genrsa 4096 > ${DOMAIN}/rsa.key | |||
63 | 67 | ||
64 | ## Receive certificate | 68 | ## Receive certificate |
65 | 69 | ||
66 | The signed certificate will be saved by this program in | 70 | The signed certificate will be saved by this program in `./${DOMAIN}/cert.pem`. |
67 | ``./${DOMAIN}/cert.der``. You can copy that file to the place your TLS | 71 | A combined certificate, containing the issuer certificate, the private key, and |
68 | server is configured to read it. | 72 | (possibly) DH parameters, will be saved in `./${DOMAIN}/cert.combined.pem`. You |
73 | can copy that file to the place your TLS server is configured to read it. | ||
69 | 74 | ||
70 | You can also view the certificate like so: | 75 | You can also view the certificate like so: |
71 | 76 | ||
72 | ``` | 77 | ``` |
73 | openssl x509 -inform der -in ${DOMAIN}/cert.der -noout -text | less | 78 | openssl x509 -in ${DOMAIN}/cert.pem -noout -text | less |
74 | ``` | 79 | ``` |
75 | 80 | ||
76 | ## Create a certificate for HAProxy | 81 | ## Create a certificate for HAProxy |
77 | 82 | ||
78 | Vo Minh Thu, the original author of this program, suggests to include explicit | 83 | Vo Minh Thu, the original author of this program, suggests to include explicit |
79 | DH key exchange parameters to prevent the [Logjam attack](https://weakdh.org/). | 84 | DH key exchange parameters to prevent the [Logjam attack](https://weakdh.org/). |
85 | This is now automatically performed by default. | ||
86 | |||
87 | Note: generating DH params is CPU-intensive and takes a long time. For that | ||
88 | reason, it is done once per domain, and the result is saved in | ||
89 | `${DOMAIN}/dhparams.pem`. | ||
90 | |||
91 | You can also disable DH generation it with `--skip-dhparams`. | ||
92 | |||
93 | The certificate is generated by this program equivalently to this: | ||
80 | 94 | ||
81 | ``` | 95 | ``` |
82 | > openssl x509 -inform der -in ${DOMAIN}/cert.der \ | 96 | openssl dhparam -out ${DOMAIN}/dhparams.pem 2048 |
83 | -out ${DOMAIN}/cert.pem | 97 | cat ${DOMAIN}/cert.pem \ |
84 | > openssl dhparam -out ${DOMAIN}/dhparams.pem 2048 | ||
85 | > cat ${DOMAIN}/cert.pem \ | ||
86 | lets-encrypt-x1-cross-signed.pem \ | 98 | lets-encrypt-x1-cross-signed.pem \ |
87 | ${DOMAIN}/rsa.key \ | 99 | ${DOMAIN}/rsa.key \ |
88 | ${DOMAIN}/dhparams.pem > aaa.reesd.com-combined.pem | 100 | ${DOMAIN}/dhparams.pem > ${DOMAIN}/cert.combined.pem |
89 | ``` | 101 | ``` |
diff --git a/acme-certify.hs b/acme-certify.hs index b84a728..219b0c1 100644 --- a/acme-certify.hs +++ b/acme-certify.hs | |||
@@ -12,21 +12,22 @@ | |||
12 | module Main where | 12 | module Main where |
13 | 13 | ||
14 | import BasePrelude | 14 | import BasePrelude |
15 | import qualified Data.ByteString.Lazy.Char8 as LC | 15 | import Network.ACME (canProvision, certify, |
16 | import Network.ACME (canProvision, certify, fileProvisioner, ensureWritableDir, (</>), genReq) | 16 | ensureWritableDir, fileProvisioner, |
17 | import Network.ACME.Encoding (Keys (..), readKeys) | 17 | genReq, (</>)) |
18 | import Network.ACME.Encoding (Keys (..), readKeys) | ||
18 | import Network.URI | 19 | import Network.URI |
19 | import OpenSSL | 20 | import OpenSSL |
20 | import OpenSSL.X509 (X509) | ||
21 | import OpenSSL.DH | 21 | import OpenSSL.DH |
22 | import OpenSSL.PEM | 22 | import OpenSSL.PEM |
23 | import OpenSSL.RSA | 23 | import OpenSSL.RSA |
24 | import Options.Applicative hiding (header) | 24 | import OpenSSL.X509 (X509) |
25 | import qualified Options.Applicative as Opt | 25 | import Options.Applicative hiding (header) |
26 | import qualified Options.Applicative as Opt | ||
26 | import System.Directory | 27 | import System.Directory |
27 | import Text.Domain.Validate hiding (validate) | 28 | import System.IO |
29 | import Text.Domain.Validate hiding (validate) | ||
28 | import Text.Email.Validate | 30 | import Text.Email.Validate |
29 | import System.IO | ||
30 | 31 | ||
31 | stagingDirectoryUrl, liveDirectoryUrl :: URI | 32 | stagingDirectoryUrl, liveDirectoryUrl :: URI |
32 | Just liveDirectoryUrl = parseAbsoluteURI "https://acme-v01.api.letsencrypt.org/directory" | 33 | Just liveDirectoryUrl = parseAbsoluteURI "https://acme-v01.api.letsencrypt.org/directory" |
@@ -42,13 +43,15 @@ main = execParser opts >>= go | |||
42 | ] | 43 | ] |
43 | 44 | ||
44 | data CmdOpts = CmdOpts { | 45 | data CmdOpts = CmdOpts { |
45 | optKeyFile :: String, | 46 | optKeyFile :: String, |
46 | optDomains :: [String], | 47 | optDomains :: [String], |
47 | optChallengeDir :: String, | 48 | optChallengeDir :: String, |
48 | optDomainDir :: Maybe String, | 49 | optDomainDir :: Maybe String, |
49 | optEmail :: Maybe String, | 50 | optEmail :: Maybe String, |
50 | optTerms :: Maybe String, | 51 | optTerms :: Maybe String, |
51 | optStaging :: Bool | 52 | optSkipDH :: Bool, |
53 | optStaging :: Bool, | ||
54 | optSkipProvisionCheck :: Bool | ||
52 | } | 55 | } |
53 | 56 | ||
54 | defaultTerms :: URI | 57 | defaultTerms :: URI |
@@ -56,71 +59,71 @@ Just defaultTerms = parseAbsoluteURI "https://letsencrypt.org/documents/LE-SA-v1 | |||
56 | 59 | ||
57 | cmdopts :: Parser CmdOpts | 60 | cmdopts :: Parser CmdOpts |
58 | cmdopts = CmdOpts <$> strOption (long "key" <> metavar "FILE" <> | 61 | cmdopts = CmdOpts <$> strOption (long "key" <> metavar "FILE" <> |
59 | help "filename of your private RSA key") | 62 | help "Filename of your private RSA key") |
60 | <*> some | 63 | <*> some |
61 | (strOption | 64 | (strOption |
62 | (long "domain" <> | 65 | (long "domain" <> |
63 | metavar "DOMAIN" <> | 66 | metavar "DOMAIN" <> |
64 | help | 67 | help |
65 | (unwords | 68 | (unwords |
66 | [ "the domain name(s) to certify;" | 69 | [ "The domain name(s) to certify;" |
67 | , "specify more than once for a multi-domain certificate" | 70 | , "specify more than once for a multi-domain certificate" |
68 | ]))) | 71 | ]))) |
69 | <*> strOption (long "challenge-dir" <> metavar "DIR" <> | 72 | <*> strOption (long "challenge-dir" <> metavar "DIR" <> |
70 | help "output directory for ACME challenges") | 73 | help "Output directory for ACME challenges") |
71 | <*> optional | 74 | <*> optional |
72 | (strOption | 75 | (strOption |
73 | (long "domain-dir" <> | 76 | (long "domain-dir" <> |
74 | metavar "DIR" <> | 77 | metavar "DIR" <> |
75 | help | 78 | help |
76 | (unwords | 79 | (unwords |
77 | [ "directory in which to domain certificates and keys are stored;" | 80 | [ "Directory in which to domain certificates and keys are stored;" |
78 | , "the default is to use the (first) domain name as a directory name" | 81 | , "the default is to use the (first) domain name as a directory name" |
79 | ]))) | 82 | ]))) |
80 | <*> optional | 83 | <*> optional |
81 | (strOption (long "email" <> metavar "ADDRESS" <> | 84 | (strOption (long "email" <> metavar "ADDRESS" <> |
82 | help "an email address with which to register an account")) | 85 | help "An email address with which to register an account")) |
83 | <*> optional (strOption (long "terms" <> metavar "URL" <> | 86 | <*> optional (strOption (long "terms" <> metavar "URL" <> |
84 | help "the terms param of the registration request")) | 87 | help "The terms param of the registration request")) |
88 | <*> switch | ||
89 | (long "skip-dhparams" <> help "Don't generate DH params for combined cert") | ||
85 | <*> switch | 90 | <*> switch |
86 | (long "staging" <> help | 91 | (long "staging" <> help |
87 | (unwords | 92 | (unwords |
88 | [ "use staging servers instead of live servers" | 93 | [ "Use staging servers instead of live servers" |
89 | , "(generated certificates will not be trusted!)" | 94 | , "(generated certificates will not be trusted!)" |
90 | ])) | 95 | ])) |
91 | 96 | <*> switch | |
92 | genKey :: FilePath -> IO String | 97 | (long "skip-provision-check" <> help |
93 | genKey privKeyFile = withOpenSSL $ do | 98 | (unwords |
94 | kp <- generateRSAKey' 4096 65537 | 99 | [ "Don't test whether HTTP provisioning works before" |
95 | pem <- writePKCS8PrivateKey kp Nothing | 100 | , "making ACME requests; only useful for testing." |
96 | writeFile privKeyFile pem | 101 | ])) |
97 | return pem | ||
98 | |||
99 | getOrCreateKeys :: FilePath -> IO (Maybe Keys) | ||
100 | getOrCreateKeys file = do | ||
101 | exists <- doesFileExist file | ||
102 | readKeys =<< if exists then readFile file else genKey file | ||
103 | 102 | ||
104 | go :: CmdOpts -> IO () | 103 | go :: CmdOpts -> IO () |
105 | go CmdOpts { .. } = do | 104 | go CmdOpts { .. } = do |
106 | let terms = fromMaybe defaultTerms (join $ parseAbsoluteURI <$> optTerms) | 105 | let terms = fromMaybe defaultTerms (join $ parseAbsoluteURI <$> optTerms) |
107 | directoryUrl = if optStaging then stagingDirectoryUrl else liveDirectoryUrl | 106 | directoryUrl = if optStaging then stagingDirectoryUrl else liveDirectoryUrl |
108 | domainKeyFile = domainDir </> "rsa.key" | 107 | domainKeyFile = domainDir </> "rsa.key" |
109 | domainCertFile = domainDir </> "cert.der" | 108 | domainCombinedFile = domainDir </> "cert.combined.pem" |
110 | domainDir = fromMaybe (head optDomains) optDomainDir | 109 | domainCertFile = domainDir </> "cert.pem" |
111 | privKeyFile = optKeyFile | 110 | domainDhFile = domainDir </> "dhparams.pem" |
112 | requestDomains = fromMaybe (error "invalid domain name") $ sequence $ domainName . fromString <$> optDomains | 111 | domainDir = fromMaybe (head optDomains) optDomainDir |
112 | privKeyFile = optKeyFile | ||
113 | requestDomains = map domainName' optDomains | ||
113 | 114 | ||
114 | doesDirectoryExist domainDir `otherwiseM` createDirectory domainDir | 115 | doesDirectoryExist domainDir `otherwiseM` createDirectory domainDir |
115 | 116 | ||
117 | let issuerCertFile = "lets-encrypt-x1-cross-signed.pem" | ||
118 | issuerCert <- readFile issuerCertFile >>= readX509 | ||
119 | |||
116 | Just domainKeys <- getOrCreateKeys domainKeyFile | 120 | Just domainKeys <- getOrCreateKeys domainKeyFile |
117 | Just keys <- getOrCreateKeys privKeyFile | 121 | Just keys <- getOrCreateKeys privKeyFile |
118 | 122 | ||
119 | challengeDir <- ensureWritableDir optChallengeDir "challenge directory" | 123 | challengeDir <- ensureWritableDir optChallengeDir "challenge directory" |
120 | void $ ensureWritableDir domainDir "domain directory" | 124 | void $ ensureWritableDir domainDir "domain directory" |
121 | 125 | ||
122 | let skipProvisionCheck = True | 126 | unless optSkipProvisionCheck $ |
123 | unless skipProvisionCheck $ | ||
124 | forM_ requestDomains $ canProvision challengeDir >=> | 127 | forM_ requestDomains $ canProvision challengeDir >=> |
125 | (`unless` error "Error: cannot provision files to web server via challenge directory") | 128 | (`unless` error "Error: cannot provision files to web server via challenge directory") |
126 | 129 | ||
@@ -128,19 +131,44 @@ go CmdOpts { .. } = do | |||
128 | 131 | ||
129 | let email = either (error . ("Error: invalid email address: " ++)) id . validate . fromString <$> optEmail | 132 | let email = either (error . ("Error: invalid email address: " ++)) id . validate . fromString <$> optEmail |
130 | 133 | ||
131 | let issuerCertFile = "lets-encrypt-x1-cross-signed.pem" | 134 | dh <- if optSkipDH then return Nothing else Just <$> getOrCreateDH domainDhFile |
132 | issuerCert <- readFile issuerCertFile >>= readX509 | 135 | |
136 | certificate <- certify directoryUrl keys ((,) terms <$> email) (fileProvisioner challengeDir) certReq | ||
137 | |||
138 | let saveCombined = combinedCert issuerCert dh domainKeys >=> writeFile domainCombinedFile | ||
139 | savePEM = writeX509 >=> writeFile domainCertFile | ||
140 | saveBoth x509 = savePEM x509 >> saveCombined x509 | ||
141 | |||
142 | either (error . ("Error: " ++)) saveBoth certificate | ||
143 | |||
144 | genKey :: IO String | ||
145 | genKey = withOpenSSL $ do | ||
146 | kp <- generateRSAKey' 4096 65537 | ||
147 | writePKCS8PrivateKey kp Nothing | ||
148 | |||
149 | getOrCreate :: IO String -> (String -> IO a) -> FilePath -> IO a | ||
150 | getOrCreate gen parse file = do | ||
151 | exists <- doesFileExist file | ||
152 | parse =<< if exists then readFile file else gen >>= save file | ||
153 | where | ||
154 | save f x = writeFile f x >> return x | ||
133 | 155 | ||
156 | getOrCreateKeys :: FilePath -> IO (Maybe Keys) | ||
157 | getOrCreateKeys = getOrCreate genKey readKeys | ||
158 | |||
159 | getOrCreateDH :: FilePath -> IO DHP | ||
160 | getOrCreateDH = getOrCreate (genDHParams' >>= writeDHParams) readDHParams | ||
161 | |||
162 | domainName' :: String -> DomainName | ||
163 | domainName' dom = fromMaybe (error $ "Error: invalid domain name: " ++ show dom) (domainName $ fromString dom) | ||
164 | |||
165 | genDHParams' :: IO DHP | ||
166 | genDHParams' = do | ||
134 | hSetBuffering stdout NoBuffering | 167 | hSetBuffering stdout NoBuffering |
135 | putStr "Generating DH Params..." | 168 | putStr "Generating DH Params..." |
136 | dh <- genDHParams DHGen2 2048 | 169 | dh <- genDHParams DHGen2 2048 |
137 | putStrLn " Done." | 170 | putStrLn " Done." |
138 | 171 | return dh | |
139 | certificate <- certify directoryUrl keys ((,) terms <$> email) (fileProvisioner challengeDir) certReq | ||
140 | |||
141 | either (error . ("Error: " ++)) | ||
142 | (combinedCert issuerCert (Just dh) domainKeys >=> writeFile domainCertFile) | ||
143 | certificate | ||
144 | 172 | ||
145 | combinedCert :: X509 -> Maybe DHP -> Keys -> X509 -> IO String | 173 | combinedCert :: X509 -> Maybe DHP -> Keys -> X509 -> IO String |
146 | combinedCert issuerCert dh (Keys privKey _) cert = do | 174 | combinedCert issuerCert dh (Keys privKey _) cert = do |