summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Cady <d@jerkface.net>2016-01-23 23:31:36 -0500
committerAndrew Cady <d@jerkface.net>2016-01-23 23:31:36 -0500
commit331965d2807bd888e7d5dfe3ee3e31f7161b6e30 (patch)
treed1c3e70cc700b7450f3cac21fa96a65bc35c8c42
parent60cc8e93ae7a647c5f5da5ee8628c6aca5b58d02 (diff)
add support for multi-domain (subjectAltName) certificates
-rw-r--r--README.md18
-rw-r--r--acme-encrypt.cabal4
-rw-r--r--acme.hs106
3 files changed, 66 insertions, 62 deletions
diff --git a/README.md b/README.md
index 99ae8e0..d2a6e03 100644
--- a/README.md
+++ b/README.md
@@ -3,23 +3,25 @@
3``` 3```
4Let's Encrypt! ACME client 4Let's Encrypt! ACME client
5 5
6Usage: acme-encrypt-exe --key FILE --domain DOMAIN --challenge-dir DIR 6Usage: 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
11Available options: 12Available 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
25This is a simple Haskell script to obtain a certificate from [Let's 27This 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
23executable letsencrypt 23executable 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
diff --git a/acme.hs b/acme.hs
index 2731b15..795b822 100644
--- a/acme.hs
+++ b/acme.hs
@@ -11,7 +11,7 @@
11 11
12module Main where 12module Main where
13 13
14import Control.Lens hiding ((.=)) 14import Control.Lens hiding ((.=), each)
15import Control.Monad 15import Control.Monad
16import Control.Monad.RWS.Strict 16import Control.Monad.RWS.Strict
17import Crypto.Number.Serialize (i2osp) 17import Crypto.Number.Serialize (i2osp)
@@ -50,6 +50,8 @@ import qualified Options.Applicative as Opt
50import System.Directory 50import System.Directory
51 51
52import Network.ACME 52import Network.ACME
53import Data.List
54import Pipes
53 55
54stagingDirectoryUrl, liveDirectoryUrl :: String 56stagingDirectoryUrl, liveDirectoryUrl :: String
55liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory" 57liveDirectoryUrl = "https://acme-v01.api.letsencrypt.org/directory"
@@ -59,11 +61,14 @@ main :: IO ()
59main = execParser opts >>= go 61main = 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
64data CmdOpts = CmdOpts { 69data 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
75defaultTerms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf" 80defaultTerms = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"
76 81
77cmdopts :: Parser CmdOpts 82cmdopts :: Parser CmdOpts
78cmdopts = CmdOpts <$> strOption 83cmdopts = 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
106genKey :: String -> IO () 117genKey :: String -> IO ()
107genKey privKeyFile = withOpenSSL $ do 118genKey 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
112genReq :: FilePath -> String -> IO LC.ByteString 123genReq :: FilePath -> [String] -> IO LC.ByteString
113genReq domainKeyFile domain = withOpenSSL $ do 124genReq _ [] = error "genReq called with zero domains"
125genReq 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
151infixl 0 `otherwiseM` 148infixl 0 `otherwiseM`
152 149
153go :: CmdOpts -> IO () 150go :: CmdOpts -> IO ()
154go CmdOpts{..} = do 151go 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
191a </> b = a ++ "/" ++ b 193a </> b = a ++ "/" ++ b
192infixr 5 </> 194infixr 5 </>
193 195
194canProvision :: String -> FilePath -> IO Bool 196canProvision :: FilePath -> String -> IO Bool
195canProvision domain challengeDir = do 197canProvision 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
252data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session } 254data Env = Env { getDir :: Directory, getKeys :: Keys, getSession :: WS.Session }
253 255
254type ACME a = RWST Env () Nonce IO a 256type ACME = RWST Env () Nonce IO
255runACME :: String -> Keys -> ACME a -> IO a 257runACME :: String -> Keys -> ACME a -> IO a
256runACME url keys f = WS.withSession $ \sess -> do 258runACME url keys f = WS.withSession $ \sess -> do
257 Just (dir, nonce) <- getDirectory sess url 259 Just (dir, nonce) <- getDirectory sess url