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