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 }