1 
2 /** Simple client for ACME protocol
3  *
4  * This software provides a simple and minimalistic ACME client. It
5  * provides no fancy options, but sticks with the most common settings.
6  */
7 module acme.acme_client;
8 
9 import std.conv;
10 import std.datetime;
11 import std.json;
12 import std.net.curl;
13 import std.stdio;
14 import std.string;
15 import std.typecons;
16 
17 import deimos.openssl.asn1;
18 import deimos.openssl.evp;
19 import deimos.openssl.pem;
20 import deimos.openssl.rsa;
21 import deimos.openssl.x509v3;
22 
23 import acme;
24 import acme.exception;
25 import acme.curl_helpers;
26 import acme.openssl_helpers;
27 
28 /* ------------------------------------------------------------------------ */
29 
30 enum directoryUrlProd = "https://acme-v02.api.letsencrypt.org/directory";
31 enum directoryUrlStaging = "https://acme-staging-v02.api.letsencrypt.org/directory";
32 
33 version (STAGING)
34 	enum directoryUrlInit = directoryUrlStaging;
35 else
36 	enum directoryUrlInit = directoryUrlProd;
37 
38 /* ------------------------------------------------------------------------ */
39 
40 /** This structure stores the resource url of the ACME server
41  */
42 struct AcmeResources
43 {
44 	string nonce;            /// The Nonce for the next JWS transfer
45 
46 	string directoryUrl;     /// Initial config url to directory resource
47 	JSONValue directoryJson; /// JSON string returned returned from directoryURL
48 
49 	string newNOnceUrl;      /// Url to newNonce resource
50 	string newAccountUrl;    /// Url to newAccount resource
51 	string newOrderUrl;      /// Url to newOrder resource
52 	string newAuthZUrl;      /// Url to newAuthz resource
53 	string revokeCrtUrl;     /// Url to revokeCert resource
54 	string keyChangeUrl;     /// Url to keyChange resource
55 
56 	string metaJson;         /// Metadata as JSON string (undecoded)
57 
58 	// FIXME
59 	string accountUrl;       /// Account Url for the key
60 	string newCertUrl;
61 	string newRegUrl;
62 
63 	void init(string initstr = directoryUrlInit) {
64 		directoryUrl = initstr;
65 	}
66 	void decodeDirectoryJson(const(char[]) directory)
67 	{
68 		directoryJson = parseJSON(directory);
69 		alias json = directoryJson;
70 		if ("keyChange" in json) this.keyChangeUrl = json["keyChange"].str;
71 		if ("newAccount" in json) this.newAccountUrl = json["newAccount"].str;
72 		if ("newNonce" in json) this.newNOnceUrl = json["newNonce"].str;
73 		if ("newOrder" in json) this.newOrderUrl = json["newOrder"].str;
74 		if ("revokeCert" in json) this.revokeCrtUrl = json["revokeCert"].str;
75 
76 		if ("newAuthz" in json) this.newAuthZUrl = json["newAuthz"].str;
77 		if ("newCert" in json) this.newCertUrl = json["newCert"].str;
78 		if ("newReg" in json) this.newRegUrl = json["newReg"].str;
79 
80 		if ("meta" in json) this.metaJson = json["meta"].toJSON;
81 	}
82 	void getResources()
83 	{
84 		try
85 		{
86 			char[] directory = get(this.directoryUrl);
87 			decodeDirectoryJson(directory);
88 		}
89 		catch (Exception e)
90 		{
91 			string msg = "Unable to initialize resource url from " ~ this.directoryUrl ~ ": " ~ e.msg;
92 			throw new AcmeException(msg, __FILE__, __LINE__, e );
93 		}
94 	}
95 }
96 
97 unittest
98 {
99 	string dirTestData = q"({
100     "Ca1Xc_O0Nwk": "https:\/\/community.letsencrypt.org\/t\/adding-random-entries-to-the-directory\/33417",
101     "keyChange": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/key-change",
102     "meta": {
103         "caaIdentities": [
104             "letsencrypt.org"
105         ],
106         "termsOfService": "https:\/\/letsencrypt.org\/documents\/LE-SA-v1.2-November-15-2017.pdf",
107         "website": "https:\/\/letsencrypt.org\/docs\/staging-environment\/"
108     },
109     "newAccount": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/new-acct",
110     "newAuthz": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/new-authz",
111     "newNonce": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/new-nonce",
112     "newOrder": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/new-order",
113     "revokeCert": "https:\/\/acme-staging-v02.api.letsencrypt.org\/acme\/revoke-cert"
114 })";
115 	void testcode(string url, bool dofullasserts = false )
116 	{
117 		AcmeResources test;
118 		if (url is null) {
119 			test.init();
120 			test.decodeDirectoryJson(dirTestData);
121 		} else {
122 			test.init(url);
123 			test.directoryUrl = url;
124 			test.getResources();
125 		}
126 		writeln("Received directory data :\n", test.directoryJson.toPrettyString);
127 		assert( test.directoryUrl !is null, "Shouldn't be null");
128 
129 		assert( test.keyChangeUrl !is null, "Shouldn't be null");
130 		assert( test.newAccountUrl !is null, "Shouldn't be null");
131 		assert( test.newNOnceUrl !is null, "Shouldn't be null");
132 		assert( test.newOrderUrl !is null, "Shouldn't be null");
133 		assert( test.revokeCrtUrl !is null, "Shouldn't be null");
134 		assert( test.metaJson !is null, "Shouldn't be null");
135 		if (dofullasserts) {
136 			assert( test.newAuthZUrl !is null, "Shouldn't be null");
137 		}
138 	}
139 	writeln("**** Testing AcmeResources : Decode test vector");
140 	testcode(null, true);
141 	writeln("**** Testing AcmeResources : Use staging server : ", directoryUrlStaging);
142 	testcode(directoryUrlStaging);
143 	writeln("**** Testing AcmeResources : Use production server : ", directoryUrlProd);
144 	testcode(directoryUrlProd);
145 }
146 
147 /* ------------------------------------------------------------------------ */
148 
149 /** An openssl certificate */
150 struct Certificate
151 {
152 	/** The full CA chain with cert */
153 	string fullchain;
154 	/** The private key to sign requests */
155 	string privkey;
156 
157 	// Note that neither of the 'Expiry' calls below require 'privkey'
158 	// to be set; they only rely on 'fullchain'.
159 
160 	/**
161 		Returns the number of seconds since 1970, i.e., epoch time.
162 
163 		Due to openssl quirkiness there might be a little drift
164 		from a strictly accurate result, but it should be close
165 		enough for the purpose of determining whether the certificate
166 		needs to be renewed.
167 	*/
168 	DateTime getExpiry() const
169 	{
170 		static const(DateTime) extractor(const ASN1_TIME * t)
171 		{
172 			// See this link for issues in converting from ASN1_TIME to epoch time.
173 			// https://stackoverflow.com/questions/10975542/asn1-time-to-time-t-conversion
174 
175 			int days, seconds;
176 			if (!ASN1_TIME_diff(&days, &seconds, null, t))
177 			{
178 				throw new AcmeException("Can't get time diff.");
179 			}
180 			// Hackery here, since the call to time(0) will not necessarily match
181 			// the equivilent call openssl just made in the 'diff' call above.
182 			// Nonetheless, it'll be close at worst.
183 			auto dt = DateTime( Date(0) );
184 			dt += dur!"seconds"(seconds + days * 3600 * 24);
185 			return dt;
186 		}
187 	  	return extractExpiryData!(DateTime, extractor)(this.fullchain);
188 	}
189 
190 	/** Returns the 'Not After' result that openssl would display if
191 		running the following command.
192 
193 			openssl x509 -noout -in fullchain.pem -text
194 
195 		For example:
196 
197 			May  6 21:15:03 2018 GMT
198 	*/
199 	string getExpiryDisplay() const
200 	{
201 		string extractor(const ASN1_TIME * t)
202 		{
203 			BIO* b = BIO_new(BIO_s_mem());
204 			if (!ASN1_TIME_print(b, t))
205 			{
206 				throw new AcmeException("Can't print expiry time.");
207 			}
208 			return toString(b);
209 		}
210 		return extractExpiryData!(string, extractor)(this.fullchain);
211 	}
212 }
213 
214 /* ------------------------------------------------------------------------ */
215 
216 /** A simple ACME v2 client
217  *
218  * This class implements the ACME v2 protocol to obtain signed SSL
219  * certificates.
220  */
221 class AcmeClient
222 {
223 private:
224 	EVP_PKEY*   privateKey_;     /// Copy of private key as ASC PEM
225 
226 	JSONValue   jwkData_;        /// JWK object as JSONValue tree
227 	string      jwkString_;      /// JWK as plain JSON string
228 	ubyte[]     jwkSHAHash_;     /// The SHA256 hash value of jwkString_
229 	string      jwkThumbprint_;  /// Base64 url-safe string of jwkSHAHash_
230 
231 	/** Create and send a JWS request with payload to a ACME enabled CAA server
232 	 *
233 	 * Params:
234 	 *  url - Url to post to
235 	 *  payload - data to send
236 	 *  status - pointer to StatusLine
237 	 *  rheaders - Pointer to ResponseHeaders received from server, or null
238 	 *
239 	 * See: https://tools.ietf.org/html/rfc7515
240 	 */
241 	T sendRequest(T)(string url, string payload, HTTP.StatusLine* status = null, string[string]* rheaders = null)
242 			if ( is(T : string) || is(T : char[]) || is(T : ubyte[]))
243 	{
244 		string nonce = this.acmeRes.nonce;
245 		assert(nonce !is null && !nonce.empty, "Invalid Nonce value.");
246 		writeln("Use NOnce: ", nonce);
247 
248 		/* Create protection data */
249 		JSONValue jvReqHeader;
250 		jvReqHeader["alg"] = "RS256";
251 		jvReqHeader["jwk"] = jwkData_;
252 		jvReqHeader["nonce"] = nonce;
253 		jvReqHeader["url"] = url;
254 		char[] protectd = jvReqHeader.toJSON.dup;
255 
256 		protectd = base64EncodeUrlSafe(protectd);
257 
258 		char[] payld = base64EncodeUrlSafe(payload);
259 
260 		auto signData = protectd ~ "." ~ payld;
261 		writefln("Data to sign: %s", signData);
262 		char[] signature = signDataWithSHA256(signData, privateKey_);
263 		writefln("Signature: %s", signature);
264 
265 		JSONValue jvBody;
266 		jvBody["protected"] = protectd;
267 		jvBody["payload"] = payld;
268 		jvBody["signature"] = signature;
269 		char[] body_ = jvBody.toJSON.dup;
270 		writefln("Body: %s", jvBody.toPrettyString);
271 
272 		auto response = doPost(url, body_, status, rheaders);
273 		if (rheaders && ("replay-nonce" in *rheaders)) {
274 			writeln( "ResponseHeaders: ");
275 			foreach( v ; (*rheaders).byKey) writeln("  ", v, " : ", (*rheaders)[v]);
276 			acmeRes.nonce = (*rheaders)["replay-nonce"];
277 		}
278 		writeln( "Response: ", response);
279 
280 		return to!T(response);
281 	}
282 
283 public:
284 	AcmeResources acmeRes;       // The Url to the ACME resources
285 
286 
287 	/** Instanciate a AcmeClient using a private key for signing
288 	 *
289 	 *  Param:
290 	 *     accountPrivateKey - The signingKey is the Acme account private
291 	 *     		key used to sign requests to the acme CA, in pem format.
292 	 *  Throws: an instance of AcmeException on fatal or unexpected errors.
293 	 */
294 	this(string accountPrivateKey)
295 	{
296 		acmeRes.init();
297 
298 		privateKey_ = EVP_PKEY_new();
299 		// Create the private key and 'header suffix', used to sign LE certs.
300 		{
301 			BIO * bio = BIO_new_mem_buf(cast(void*)(accountPrivateKey.toStringz), -1);
302 			RSA * rsa = PEM_read_bio_RSAPrivateKey(bio, null, null, null);
303 			if (!rsa)
304 			{
305 				throw new AcmeException("Unable to read private key");
306 			}
307 
308 			// rsa will get freed when privateKey_ is freed
309 			if (!EVP_PKEY_assign_RSA(privateKey_, rsa))
310 			{
311 				throw new AcmeException("Unable to assign RSA to private key");
312 			}
313 
314 			// https://tools.ietf.org/html/rfc7638
315 			// JSON Web Key (JWK) Thumbprint
316 			JSONValue jvJWK;
317 			jvJWK["e"] = getBigNumberBytes(rsa.e).base64EncodeUrlSafe;
318 			jvJWK["kty"] = "RSA";
319 			jvJWK["n"] = getBigNumberBytes(rsa.n).base64EncodeUrlSafe;
320 			jwkData_ = jvJWK;
321 			jwkString_ = jvJWK.toJSON;
322 			jwkSHAHash_ = sha256Encode( jwkString_ );
323 			jwkThumbprint_ = jwkSHAHash_.base64EncodeUrlSafe.idup;
324 		}
325 	}
326 	/** Call once after instantiating AcmeClient to setup parameters
327 	 *
328 	 * This function will fetch the directory from the ACME CA server and
329 	 * extracts the Urls.
330 	 *
331 	 * Also fetches the initial NOnce value for the next JWS transfer.
332 	 */
333 	void setupClient()
334 	{
335 		acmeRes.getResources();
336 		// Get initial Nonce
337 		this.getNonce();
338 
339 	}
340 
341 	/** Get a fresh and new Nonce from server
342 	 *
343 	 * To start the communication with JWS, an initial Nonce value must be
344 	 * fetched from the server.
345 	 *
346 	 * The Nonce returned is internally store in the AcmeResource structure.
347 	 *
348 	 * Note:
349 	 *   Use the Nonce of a JWS response header to update the Nonce for the
350 	 *   next transfer! So, only a single call to this function is needed to
351 	 *   setup the initial transfer.
352 	 *
353 	 * Returns:
354 	 *   a fresh and new Nonce value.
355 	 */
356 	string getNonce()
357 	{
358 		/* Get a NOnce number from server */
359 		auto nonce = getResponseHeader(acmeRes.newNOnceUrl, "Replay-Nonce");
360 		acmeRes.nonce = nonce;
361 		return nonce;
362 	}
363 
364 	/** Create a new account and bind a key pair to it.
365 	 *
366 	 * Before we can do anything, we need to register an account and
367 	 * bind a RSA/EC keypair to it, which is used for signatures in
368 	 * JWS and to create the CSR.
369 	 *
370 	 * Params:
371 	 *   contacts - list of contacts for the account
372 	 *   tosAgreed - set this to true, when user ack on commandline. otherwise
373 	 *               the default is false, and the CA server might refuse to
374 	 *               operate in this case.
375 	 *   useExisting - do not create a new account, but reuse the
376 	 *                 existing one. Defaults to true.
377 	 *
378 	 * Note: tosAgreed must be queried from user, e.g. by setting a commandline
379 	 *       option. This is required by the RFC8555.
380 	 * Note: Usually there is no need to set useExisting to false. If set to
381 	 *       true, an existing account for a JWK is returned or new one
382 	 *       is created and returned.
383 	 */
384 	bool createNewAccount(string[] contacts, bool tosAgreed = false, bool useExisting = true)
385 	{
386 		bool rc;
387 		/* Create newAccount payload */
388 		JSONValue jvPayload;
389 		jvPayload["termsOfServiceAgreed"] = tosAgreed;
390 		JSONValue jvContact = contacts;
391 		jvPayload["contact"] = jvContact;
392 
393 		string payload = jvPayload.toJSON;
394 
395 		string[string] rheaders;
396 		import std.net.curl : HTTP;
397 		HTTP.StatusLine statusLine;
398 		string response = sendRequest!string(acmeRes.newAccountUrl, payload, &statusLine, &rheaders);
399 		if (statusLine.code / 100 == 2)
400 		{
401 			acmeRes.accountUrl = rheaders["location"];
402 			writeln("Account Location : ", acmeRes.accountUrl);
403 
404 			auto json = parseJSON(response);
405 			writeln("Account Creation : ", json["createdAt"]);
406 			// ...
407 			rc = true;
408 		}
409 		else {
410 			writeln("Got http error: ", statusLine);
411 			writeln("Got response:\n", response);
412 		}
413 		return rc;
414 	}
415 
416 	/** Authorization setup callback
417    *
418 	*	The implementation of this function allows Let's Encrypt to
419 	*	verify that the requestor has control of the domain name.
420 *
421 	*	The callback may be called once for each domain name in the
422 	*	'issueCertificate' call. The callback should do whatever is
423 	*	needed so that a GET on the 'url' returns the 'keyAuthorization',
424 	*	(which is what the Acme protocol calls the expected response.)
425 *
426 	*	Note that this function may not be called in cases where
427 	*	Let's Encrypt already believes the caller has control
428 	*	of the domain name.
429 	*/
430 	alias Callback =
431 		void function (
432 			string domainName,
433 			string url,
434 			string keyAuthorization);
435 
436 	/** Issue a certificate for the domainNames.
437 	 *
438 	 * The first one will be the 'Subject' (CN) in the certificate.
439 	 * Params:
440 	 *   domainNames - list of domains
441 	 *   callback - pointer to function to setup expected response
442 	 *              on given URL
443 	 * Returns: A Certificate object or null.
444 	 * Throws: an instance of AcmeException on fatal or unexpected errors.
445 	 */
446 	Certificate issueCertificate(string[] domainNames, Callback callback)
447 	{
448 		if (domainNames.empty)
449 			throw new AcmeException("There must be at least one domain name in a certificate");
450 
451 		/* Pass any challenges we need to pass to make the CA believe we're entitled to a certificate. */
452 		foreach (domain ; domainNames)
453 		{
454 			JSONValue jvPayload, jvPayload2;
455 			jvPayload2["type"] = "dns";
456 			jvPayload2["value"] = domain;
457 			jvPayload["resource"] = "new-authz";
458 			jvPayload["identifier"] = jvPayload2;
459 			string payload = jvPayload.toString;
460 
461 			HTTP.StatusLine status;
462 			string response = sendRequest!string(acmeRes.newAuthZUrl, payload, &status);
463 
464 			auto json = parseJSON(response);
465 
466 			/* If you pass a challenge, that's good for 300 days. The cert is only good for 90.
467 			 * This means for a while you can re-issue without passing another challenge, so we
468 			 * check to see if we need to validate again.
469 			 *
470 			 * Note that this introduces a race since it possible for the status to not be valid
471 			 * by the time the certificate is requested. The assumption is that client retries
472 			 * will deal with this.
473 			 */
474 			writeln(json.toPrettyString);
475 			if ( ("status" in json) &&
476 			     (json.type == JSONType..string) &&
477 			     (json["status"].str != "valid") )
478 			{
479 				if ("challenges" in json) {
480 					auto challenges = json["challenges"];
481 					foreach ( i, challenge ; challenges.array)
482 					{
483 						if ( ("type" in challenge) && (challenge["type"].str == "http-01") )
484 						{
485 							string token = challenge["token"].str;
486 							string url = "http://" ~ domain ~ "/.well-known/acme-challenge/" ~ token;
487 							string keyAuthorization = token ~ "." ~ jwkThumbprint_.to!string;
488 							callback(domain, url, keyAuthorization);
489 							verifyChallengePassed(challenge, keyAuthorization);
490 							break;
491 						}
492 					}
493 				}
494 			} else {
495 				writefln("Send payload: \n%s", jvPayload.toPrettyString);
496 				writefln("Got failure response:\n%s", json.toPrettyString);
497 				throw new AcmeException(json.toPrettyString);
498 			}
499 		}
500 
501 		// Issue the certificate
502 		auto r = makeCertificateSigningRequest(domainNames);
503 		string csr = r.csr;
504 		string privateKey = r.pkey;
505 
506 		// Send CSRs and get the intermediate certs
507 		string[string] rheaders;
508 
509 		JSONValue ncrs;
510 		ncrs["resource"] = "new-cert";
511 		ncrs["csr"] = csr;
512 
513 		import std.net.curl : HTTP;
514 		HTTP.StatusLine statusLine;
515 		auto der = sendRequest!(char[])(acmeRes.newCertUrl, ncrs.toJSON, &statusLine, &rheaders);
516 
517 		// Create a container object
518 		Certificate cert;
519 		cert.fullchain = convertDERtoPEM(der) ~ cast(string)getIntermediateCertificate(rheaders["Link"]);
520 		cert.privkey = privateKey;
521 		return cert;
522 	}
523 
524 	// Throws if the challenge isn't accepted (or on timeout)
525 	void verifyChallengePassed(JSONValue challenge, string keyAuthorization)
526 	{
527 		// Tell the CA we're prepared for the challenge.
528 		string verificationUri = challenge["uri"].str;
529 		import std.net.curl : HTTP;
530 		HTTP.StatusLine statusLine;
531 		sendRequest!string(verificationUri, q"(  {
532 														"resource": "challenge",
533 														"keyAuthorization": ")" ~ keyAuthorization ~ "\"}" );
534 
535 		// Poll waiting for the CA to verify the challenge
536 		int counter = 0;
537 		enum count = 10;
538 		do
539 		{
540 			// sleep for a second
541 			import core.thread;
542 			Thread.sleep(dur!"seconds"(1));
543 
544 			// get response from verification URL
545 			char[] response = get(verificationUri);
546 			auto json = parseJSON(response);
547 			if (json["status"].str == "valid")
548 			{
549 				return;
550 			}
551 		} while (counter++ < count);
552 
553 		throw new AcmeException("Failure / timeout verifying challenge passed");
554 	}
555 }
556 
557 /* ------------------------------------------------------------------------ */
558 /* --- Helper Functions --------------------------------------------------- */
559 /* ------------------------------------------------------------------------ */
560 
561 /** Get the issuer certificate from a 'Link' response header
562  *
563  * Param:
564  *  linkHeader - ResponseHeader Line of the form
565  *               Link: <https://acme-v01.api.letsencrypt.org/acme/issuer-cert>;rel="up"
566  * Returns:
567  *   Pem-encoded issuer certificate string
568  */
569 string getIntermediateCertificate(string linkHeader)
570 {
571 	/* Extract the URL from the Header */
572 	import std.regex;
573 	// Link: <https://acme-v01.api.letsencrypt.org/acme/issuer-cert>;rel="up"
574 	auto r = regex("^<(.*)>;rel=\"up\"$");
575 	auto match = matchFirst(linkHeader, r);
576 	if (match.empty)
577 	{
578 		throw new AcmeException("Unable to parse 'Link' header with value " ~ linkHeader);
579 	}
580 	char[] url = cast(char[])match[1];
581 
582 	/* Download the issuer certificate */
583 	auto reps = get(url);
584 	auto rstr = convertDERtoPEM( reps );
585 	return rstr;
586 }