diff options
author | Andrew Cady <d@jerkface.net> | 2016-01-23 23:31:36 -0500 |
---|---|---|
committer | Andrew Cady <d@jerkface.net> | 2016-01-23 23:31:36 -0500 |
commit | 331965d2807bd888e7d5dfe3ee3e31f7161b6e30 (patch) | |
tree | d1c3e70cc700b7450f3cac21fa96a65bc35c8c42 | |
parent | 60cc8e93ae7a647c5f5da5ee8628c6aca5b58d02 (diff) |
add support for multi-domain (subjectAltName) certificates
-rw-r--r-- | README.md | 18 | ||||
-rw-r--r-- | acme-encrypt.cabal | 4 | ||||
-rw-r--r-- | acme.hs | 106 |
3 files changed, 66 insertions, 62 deletions
@@ -3,23 +3,25 @@ | |||
3 | ``` | 3 | ``` |
4 | Let's Encrypt! ACME client | 4 | Let's Encrypt! ACME client |
5 | 5 | ||
6 | Usage: acme-encrypt-exe --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 | [--staging] |
9 | This is a work in progress. | 9 | This program will generate a signed TLS certificate using the ACME protocol |
10 | and the free Let's Encrypt! CA. | ||
10 | 11 | ||
11 | Available options: | 12 | Available options: |
12 | -h,--help Show this help text | 13 | -h,--help Show this help text |
13 | --key FILE filename of your private RSA key | 14 | --key FILE filename of your private RSA key |
14 | --domain DOMAIN the domain name to certify | 15 | --domain DOMAIN the domain name(s) to certify; specify more than once |
16 | for a multi-domain certificate | ||
15 | --challenge-dir DIR output directory for ACME challenges | 17 | --challenge-dir DIR output directory for ACME challenges |
16 | --domain-dir DIR directory in which to domain certificates and keys | 18 | --domain-dir DIR directory in which to domain certificates and keys |
17 | are stored; the default is to use the domain name as | 19 | are stored; the default is to use the (first) domain |
18 | a directory name | 20 | name as a directory name |
19 | --email ADDRESS an email address with which to register an account | 21 | --email ADDRESS an email address with which to register an account |
20 | --terms URL the terms param of the registration request | 22 | --terms URL the terms param of the registration request |
21 | --staging use staging servers instead of live servers | 23 | --staging use staging servers instead of live servers |
22 | (certificates will not be real!) | 24 | (generated certificates will not be trusted!) |
23 | ``` | 25 | ``` |
24 | 26 | ||
25 | This is a simple Haskell script to obtain a certificate from [Let's | 27 | This is a simple Haskell script to obtain a certificate from [Let's |
diff --git a/acme-encrypt.cabal b/acme-encrypt.cabal index 9d9e980..e859c8e 100644 --- a/acme-encrypt.cabal +++ b/acme-encrypt.cabal | |||
@@ -20,14 +20,14 @@ library | |||
20 | mtl, time | 20 | mtl, time |
21 | default-language: Haskell2010 | 21 | default-language: Haskell2010 |
22 | 22 | ||
23 | executable letsencrypt | 23 | executable acme-certify |
24 | -- hs-source-dirs: app | 24 | -- hs-source-dirs: app |
25 | main-is: acme.hs | 25 | main-is: acme.hs |
26 | ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall | 26 | ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall |
27 | build-depends: base, acme-encrypt, | 27 | build-depends: base, acme-encrypt, |
28 | cryptonite, aeson, bytestring, base64-bytestring, SHA, | 28 | cryptonite, aeson, bytestring, base64-bytestring, SHA, |
29 | text, HsOpenSSL, wreq, lens, lens-aeson, | 29 | text, HsOpenSSL, wreq, lens, lens-aeson, |
30 | optparse-applicative, directory, mtl, time | 30 | optparse-applicative, directory, mtl, time, pipes |
31 | default-language: Haskell2010 | 31 | default-language: Haskell2010 |
32 | 32 | ||
33 | -- test-suite acme-encrypt-test | 33 | -- test-suite acme-encrypt-test |
@@ -11,7 +11,7 @@ | |||
11 | 11 | ||
12 | module Main where | 12 | module Main where |
13 | 13 | ||
14 | import Control.Lens hiding ((.=)) | 14 | import Control.Lens hiding ((.=), each) |
15 | import Control.Monad | 15 | import Control.Monad |
16 | import Control.Monad.RWS.Strict | 16 | import Control.Monad.RWS.Strict |
17 | import Crypto.Number.Serialize (i2osp) | 17 | import Crypto.Number.Serialize (i2osp) |
@@ -50,6 +50,8 @@ import qualified Options.Applicative as Opt | |||
50 | import System.Directory | 50 | import System.Directory |
51 | 51 | ||
52 | import Network.ACME | 52 | import Network.ACME |
53 | import Data.List | ||
54 | import Pipes | ||
53 | 55 | ||
54 | stagingDirectoryUrl, liveDirectoryUrl :: String | 56 | stagingDirectoryUrl, liveDirectoryUrl :: String |
55 | liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" | 57 | liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" |
@@ -59,11 +61,14 @@ main :: IO () | |||
59 | main = execParser opts >>= go | 61 | main = execParser opts >>= go |
60 | where | 62 | where |
61 | opts = info (helper <*> cmdopts) (fullDesc <> progDesc detailedDescription <> Opt.header "Let's Encrypt! ACME client") | 63 | opts = info (helper <*> cmdopts) (fullDesc <> progDesc detailedDescription <> Opt.header "Let's Encrypt! ACME client") |
62 | detailedDescription = "This is a work in progress." | 64 | detailedDescription = unwords |
65 | [ "This program will generate a signed TLS certificate" | ||
66 | , "using the ACME protocol and the free Let's Encrypt! CA." | ||
67 | ] | ||
63 | 68 | ||
64 | data CmdOpts = CmdOpts { | 69 | data CmdOpts = CmdOpts { |
65 | optKeyFile :: String, | 70 | optKeyFile :: String, |
66 | optDomain :: String, | 71 | optDomains :: [String], |
67 | optChallengeDir :: String, | 72 | optChallengeDir :: String, |
68 | optDomainDir :: Maybe String, | 73 | optDomainDir :: Maybe String, |
69 | optEmail :: Maybe String, | 74 | optEmail :: Maybe String, |
@@ -75,33 +80,39 @@ defaultTerms :: String | |||
75 | defaultTerms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" | 80 | defaultTerms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" |
76 | 81 | ||
77 | cmdopts :: Parser CmdOpts | 82 | cmdopts :: Parser CmdOpts |
78 | cmdopts = CmdOpts <$> strOption | 83 | cmdopts = CmdOpts <$> strOption (long "key" <> metavar "FILE" <> |
79 | (long "key" <> metavar "FILE" <> help "filename of your private RSA key") | 84 | help "filename of your private RSA key") |
80 | <*> strOption | 85 | <*> some |
81 | (long "domain" <> metavar "DOMAIN" <> help "the domain name to certify") | 86 | (strOption |
82 | <*> strOption | 87 | (long "domain" <> |
83 | (long "challenge-dir" <> | 88 | metavar "DOMAIN" <> |
84 | metavar "DIR" <> | 89 | help |
85 | help "output directory for ACME challenges") | 90 | (unwords |
91 | [ "the domain name(s) to certify;" | ||
92 | , "specify more than once for a multi-domain certificate" | ||
93 | ]))) | ||
94 | <*> strOption (long "challenge-dir" <> metavar "DIR" <> | ||
95 | help "output directory for ACME challenges") | ||
86 | <*> optional | 96 | <*> optional |
87 | (strOption | 97 | (strOption |
88 | (long "domain-dir" <> | 98 | (long "domain-dir" <> |
89 | metavar "DIR" <> | 99 | metavar "DIR" <> |
90 | help | 100 | help |
91 | "directory in which to domain certificates and keys are stored; the default is to use the domain name as a directory name")) | 101 | (unwords |
92 | <*> optional | 102 | [ "directory in which to domain certificates and keys are stored;" |
93 | (strOption | 103 | , "the default is to use the (first) domain name as a directory name" |
94 | (long "email" <> | 104 | ]))) |
95 | metavar "ADDRESS" <> | ||
96 | help "an email address with which to register an account")) | ||
97 | <*> optional | 105 | <*> optional |
98 | (strOption | 106 | (strOption (long "email" <> metavar "ADDRESS" <> |
99 | (long "terms" <> | 107 | help "an email address with which to register an account")) |
100 | metavar "URL" <> | 108 | <*> optional (strOption (long "terms" <> metavar "URL" <> |
101 | help "the terms param of the registration request")) | 109 | help "the terms param of the registration request")) |
102 | <*> switch | 110 | <*> switch |
103 | (long "staging" <> help | 111 | (long "staging" <> help |
104 | "use staging servers instead of live servers (certificates will not be real!)") | 112 | (unwords |
113 | [ "use staging servers instead of live servers" | ||
114 | , "(generated certificates will not be trusted!)" | ||
115 | ])) | ||
105 | 116 | ||
106 | genKey :: String -> IO () | 117 | genKey :: String -> IO () |
107 | genKey privKeyFile = withOpenSSL $ do | 118 | genKey privKeyFile = withOpenSSL $ do |
@@ -109,30 +120,16 @@ genKey privKeyFile = withOpenSSL $ do | |||
109 | pem <- writePKCS8PrivateKey kp Nothing | 120 | pem <- writePKCS8PrivateKey kp Nothing |
110 | writeFile privKeyFile pem | 121 | writeFile privKeyFile pem |
111 | 122 | ||
112 | genReq :: FilePath -> String -> IO LC.ByteString | 123 | genReq :: FilePath -> [String] -> IO LC.ByteString |
113 | genReq domainKeyFile domain = withOpenSSL $ do | 124 | genReq _ [] = error "genReq called with zero domains" |
125 | genReq domainKeyFile domains@(domain:_) = withOpenSSL $ do | ||
114 | Just (Keys priv pub) <- readKeyFile domainKeyFile | 126 | Just (Keys priv pub) <- readKeyFile domainKeyFile |
115 | Just dig <- getDigestByName "SHA256" | 127 | Just dig <- getDigestByName "SHA256" |
116 | req <- newX509Req | 128 | req <- newX509Req |
117 | setSubjectName req [("CN", domain)] | 129 | setSubjectName req [("CN", domain)] |
118 | setVersion req 0 | 130 | setVersion req 0 |
119 | setPublicKey req pub | 131 | setPublicKey req pub |
120 | when False $ | 132 | void $ addExtensions req [nidSubjectAltName %%% intercalate ", " (map ("DNS:" ++) domains)] |
121 | -- This certificate seems well-formed ('openssl req' can parse it) but Let's Encrypt rejects it. | ||
122 | void $ addExtensions req | ||
123 | [ nidSubjectAltName %%% "DNS:" ++ domain | ||
124 | , nidKeyUsage %%% "critical,digitalSignature,keyEncipherment" | ||
125 | ] | ||
126 | |||
127 | -- This, on the other hand, is accepted: | ||
128 | void $ addExtensions req [nidSubjectAltName %%% "DNS:" ++ domain] | ||
129 | |||
130 | -- Trying to name other domains, though, results in this: | ||
131 | -- | ||
132 | -- void $ addExtensions req [nidSubjectAltName %%% "DNS:" ++ domain ++ ", DNS:www." ++ domain] | ||
133 | -- | ||
134 | -- urn:acme:error:unauthorized ---- Error creating new cert :: Authorizations | ||
135 | -- for these names not found or expired: www.fifty.childrenofmay.org | ||
136 | signX509Req req priv (Just dig) | 133 | signX509Req req priv (Just dig) |
137 | writeX509ReqDER req | 134 | writeX509ReqDER req |
138 | where | 135 | where |
@@ -151,39 +148,44 @@ a `otherwiseM` b = a >>= flip unless b | |||
151 | infixl 0 `otherwiseM` | 148 | infixl 0 `otherwiseM` |
152 | 149 | ||
153 | go :: CmdOpts -> IO () | 150 | go :: CmdOpts -> IO () |
154 | go CmdOpts{..} = do | 151 | go CmdOpts { .. } = do |
155 | let terms = fromMaybe defaultTerms optTerms | 152 | let terms = fromMaybe defaultTerms optTerms |
156 | directoryUrl = if optStaging then stagingDirectoryUrl else liveDirectoryUrl | 153 | directoryUrl = if optStaging then stagingDirectoryUrl else liveDirectoryUrl |
157 | domainKeyFile = domainDir </> "rsa.key" | 154 | domainKeyFile = domainDir </> "rsa.key" |
158 | domainCSRFile = domainDir </> "csr.der" | 155 | domainCSRFile = domainDir </> "csr.der" |
159 | domainCertFile = domainDir </> "cert.der" | 156 | domainCertFile = domainDir </> "cert.der" |
160 | domainDir = fromMaybe optDomain optDomainDir | 157 | domainDir = fromMaybe (head optDomains) optDomainDir |
161 | privKeyFile = optKeyFile | 158 | privKeyFile = optKeyFile |
159 | requestDomains = optDomains | ||
162 | 160 | ||
163 | doesFileExist privKeyFile `otherwiseM` genKey privKeyFile | 161 | doesFileExist privKeyFile `otherwiseM` genKey privKeyFile |
164 | 162 | ||
165 | doesDirectoryExist optDomain `otherwiseM` createDirectory domainDir | 163 | doesDirectoryExist domainDir `otherwiseM` createDirectory domainDir |
166 | doesFileExist domainKeyFile `otherwiseM` genKey domainKeyFile | 164 | doesFileExist domainKeyFile `otherwiseM` genKey domainKeyFile |
167 | 165 | ||
168 | Just keys <- readKeyFile privKeyFile | 166 | Just keys <- readKeyFile privKeyFile |
169 | 167 | ||
170 | doesFileExist domainCSRFile `otherwiseM` genReq domainKeyFile optDomain >>= LC.writeFile domainCSRFile | 168 | doesFileExist domainCSRFile `otherwiseM` genReq domainKeyFile requestDomains >>= LC.writeFile domainCSRFile |
171 | 169 | ||
172 | csrData <- B.readFile domainCSRFile | 170 | csrData <- B.readFile domainCSRFile |
173 | 171 | ||
174 | ensureWritable optChallengeDir "challenge directory" | 172 | ensureWritable optChallengeDir "challenge directory" |
175 | ensureWritable domainDir "domain directory" | 173 | ensureWritable domainDir "domain directory" |
176 | 174 | ||
177 | canProvision optDomain optChallengeDir `otherwiseM` error "Error: cannot provision files to web server via challenge directory" | 175 | forM_ requestDomains $ canProvision optChallengeDir >=> (`unless` error "Error: cannot provision files to web server via challenge directory") |
178 | 176 | ||
179 | runACME directoryUrl keys $ do | 177 | runACME directoryUrl keys $ do |
180 | forM_ optEmail $ register terms >=> statusReport | 178 | forM_ optEmail $ register terms >=> statusReport |
181 | 179 | ||
182 | (ChallengeRequest nextUri token thumbtoken) <- challengeRequest optDomain >>= statusReport >>= extractCR | 180 | let producer :: Producer ChallengeRequest ACME () |
183 | 181 | producer = for (each requestDomains) $ challengeRequest >=> statusReport >=> extractCR >=> yield | |
184 | liftIO $ BC.writeFile (optChallengeDir </> BC.unpack token) thumbtoken | 182 | consumer :: Consumer ChallengeRequest ACME () |
183 | consumer = forever $ await >>= consume1 | ||
184 | consume1 (ChallengeRequest nextUri token thumbtoken) = do | ||
185 | lift $ liftIO $ BC.writeFile (optChallengeDir </> BC.unpack token) thumbtoken | ||
186 | notifyChallenge nextUri thumbtoken >>= statusReport >>= ncErrorReport | ||
185 | 187 | ||
186 | notifyChallenge nextUri thumbtoken >>= statusReport >>= ncErrorReport | 188 | runEffect $ producer >-> consumer |
187 | 189 | ||
188 | retrieveCert csrData >>= statusReport >>= saveCert domainCertFile | 190 | retrieveCert csrData >>= statusReport >>= saveCert domainCertFile |
189 | 191 | ||
@@ -191,8 +193,8 @@ go CmdOpts{..} = do | |||
191 | a </> b = a ++ "/" ++ b | 193 | a </> b = a ++ "/" ++ b |
192 | infixr 5 </> | 194 | infixr 5 </> |
193 | 195 | ||
194 | canProvision :: String -> FilePath -> IO Bool | 196 | canProvision :: FilePath -> String -> IO Bool |
195 | canProvision domain challengeDir = do | 197 | canProvision challengeDir domain = do |
196 | randomish <- fromString . show <$> getPOSIXTime | 198 | randomish <- fromString . show <$> getPOSIXTime |
197 | 199 | ||
198 | let absFile = challengeDir </> relFile | 200 | let absFile = challengeDir </> relFile |
@@ -251,7 +253,7 @@ notifyChallenge crUri thumbtoken = sendPayload (const crUri) (challenge thumbtok | |||
251 | 253 | ||
252 | data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } | 254 | data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } |
253 | 255 | ||
254 | type ACME a = RWST Env () Nonce IO a | 256 | type ACME = RWST Env () Nonce IO |
255 | runACME :: String -> Keys -> ACME a -> IO a | 257 | runACME :: String -> Keys -> ACME a -> IO a |
256 | runACME url keys f = WS.withSession $ \sess -> do | 258 | runACME url keys f = WS.withSession $ \sess -> do |
257 | Just (dir, nonce) <- getDirectory sess url | 259 | Just (dir, nonce) <- getDirectory sess url |