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