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 a JWK. 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 DateTime rc = extractExpiryData!(DateTime, extractor)(this.fullchain); 188 return rc; 189 } 190 191 /** Returns the 'Not After' result that openssl would display if 192 running the following command. 193 194 openssl x509 -noout -in fullchain.pem -text 195 196 For example: 197 198 May 6 21:15:03 2018 GMT 199 */ 200 string getExpiryDisplay() const 201 { 202 string extractor(const ASN1_TIME * t) 203 { 204 BIO* b = BIO_new(BIO_s_mem()); 205 if (!ASN1_TIME_print(b, t)) 206 { 207 throw new AcmeException("Can't print expiry time."); 208 } 209 return toString(b); 210 } 211 return extractExpiryData!(string, extractor)(this.fullchain); 212 } 213 } 214 215 /* ------------------------------------------------------------------------ */ 216 217 /** A simple ACME v2 client 218 * 219 * This class implements the ACME v2 protocol to obtain signed SSL 220 * certificates. 221 */ 222 class AcmeClient 223 { 224 private: 225 EVP_PKEY* privateKey_; /// Copy of private key as ASC PEM 226 227 JSONValue jwkData_; /// JWK object as JSONValue tree 228 string jwkString_; /// JWK as plain JSON string 229 ubyte[] jwkSHAHash_; /// The SHA256 hash value of jwkString_ 230 string jwkThumbprint_; /// Base64 url-safe string of jwkSHAHash_ 231 232 /** Create and send a JWS request with payload to a ACME enabled CA server 233 * 234 * Still unfinished template to build the JWS object. This code must be 235 * refactored later. 236 * 237 * Params: 238 * T - return type 239 * useKID - useKID 240 * url - Url to post to 241 * payload - data to send 242 * status - pointer to StatusLine 243 * rheaders - Pointer to ResponseHeaders received from server, or null 244 * 245 * See: https://tools.ietf.org/html/rfc7515 246 */ 247 T sendRequest(T, bool useKID = true)(string url, string payload, HTTP.StatusLine* status = null, string[string]* rheaders = null) 248 if ( is(T : string) || is(T : char[]) || is(T : ubyte[])) 249 { 250 string nonce = this.acmeRes.nonce; 251 assert(nonce !is null && !nonce.empty, "Invalid Nonce value."); 252 writeln("Using NOnce: ", nonce); 253 254 /* Create protection data */ 255 JSONValue jvReqHeader; 256 jvReqHeader["alg"] = "RS256"; 257 static if (useKID) 258 jvReqHeader["kid"] = acmeRes.accountUrl; 259 else 260 jvReqHeader["jwk"] = jwkData_; 261 jvReqHeader["nonce"] = nonce; 262 jvReqHeader["url"] = url; 263 char[] protectd = jvReqHeader.toJSON.dup; 264 265 protectd = base64EncodeUrlSafe(protectd); 266 267 char[] payld = base64EncodeUrlSafe(payload); 268 269 auto signData = protectd ~ "." ~ payld; 270 //writefln("Data to sign: %s", signData); 271 char[] signature = signDataWithSHA256(signData, privateKey_); 272 //writefln("Signature: %s", signature); 273 274 JSONValue jvBody; 275 jvBody["protected"] = protectd; 276 jvBody["payload"] = payld; 277 jvBody["signature"] = signature; 278 char[] body_ = jvBody.toJSON.dup; 279 //writefln("Body: %s", jvBody.toPrettyString); 280 281 auto response = doPost(url, body_, status, rheaders, &(acmeRes.nonce)); 282 if (rheaders) { 283 writeln( "ResponseHeaders: "); 284 foreach( v ; (*rheaders).byKey) { 285 writeln(" ", v, " : ", (*rheaders)[v]); 286 //~ if (v.toLower == "replay-nonce") { 287 //~ acmeRes.nonce = (*rheaders)[v]; 288 //~ writeln("Setting new NOnce: ", acmeRes.nonce); 289 //~ } 290 } 291 } 292 //writeln( "Response: ", response); 293 294 return to!T(response); 295 } 296 297 public: 298 AcmeResources acmeRes; // The Url to the ACME resources 299 300 301 /** Instanciate a AcmeClient using a private key for signing 302 * 303 * Param: 304 * accountPrivateKey - The signingKey is the Acme account private 305 * key used to sign requests to the acme CA, in pem format. 306 * Throws: an instance of AcmeException on fatal or unexpected errors. 307 */ 308 this(string accountPrivateKey) 309 { 310 acmeRes.init(); 311 SSL_OpenLibrary(); 312 313 /* Create the private key */ 314 RSA* rsa; 315 privateKey_ = SSL_x509_read_pkey_memory(accountPrivateKey, &rsa); 316 317 // https://tools.ietf.org/html/rfc7638 318 // JSON Web Key (JWK) Thumbprint 319 JSONValue jvJWK; 320 jvJWK["e"] = getBigNumberBytes(rsa.e).base64EncodeUrlSafe; 321 jvJWK["kty"] = "RSA"; 322 jvJWK["n"] = getBigNumberBytes(rsa.n).base64EncodeUrlSafe; 323 jwkData_ = jvJWK; 324 jwkString_ = jvJWK.toJSON; 325 jwkSHAHash_ = sha256Encode( jwkString_ ); 326 jwkThumbprint_ = jwkSHAHash_.base64EncodeUrlSafe.idup; 327 } 328 ~this () { 329 SSL_CloseLibrary(); 330 } 331 332 /** Call once after instantiating AcmeClient to setup parameters 333 * 334 * This function will fetch the directory from the ACME CA server and 335 * extracts the Urls. 336 * 337 * Also fetches the initial NOnce value for the next JWS transfer. 338 */ 339 void setupClient() 340 { 341 acmeRes.getResources(); 342 // Get initial Nonce 343 this.getNonce(); 344 345 } 346 347 /** Get a fresh and new Nonce from server 348 * 349 * To start the communication with JWS, an initial Nonce value must be 350 * fetched from the server. 351 * 352 * The Nonce returned is internally store in the AcmeResource structure. 353 * 354 * Note: 355 * Use the Nonce of a JWS response header to update the Nonce for the 356 * next transfer! So, only a single call to this function is needed to 357 * setup the initial transfer. 358 * 359 * Returns: 360 * a fresh and new Nonce value. 361 */ 362 string getNonce() 363 { 364 /* Get a NOnce number from server */ 365 auto nonce = getResponseHeader(acmeRes.newNOnceUrl, "Replay-Nonce"); 366 acmeRes.nonce = nonce; 367 return nonce; 368 } 369 370 /** Create a new account and bind a key pair to it. 371 * 372 * Before we can do anything, we need to register an account and 373 * bind a RSA/EC keypair to it, which is used for signatures in 374 * JWS and to create the CSR. 375 * 376 * Params: 377 * contacts - list of contacts for the account 378 * tosAgreed - set this to true, when user ack on commandline. otherwise 379 * the default is false, and the CA server might refuse to 380 * operate in this case. 381 * onlyReturnExisting - do not create a new account, but only reuse an 382 * existing one. Defaults to false. When set to true, an 383 * account is never created, but only existing accounts are 384 * returned. 385 * 386 * Note: tosAgreed must be queried from user, e.g. by setting a commandline 387 * option. This is required by the RFC8555. 388 * Note: Usually there is no need to set useExisting to false. If set to 389 * true, an existing account for a JWK is returned or new one 390 * is created and returned. 391 */ 392 bool createNewAccount(string[] contacts, bool tosAgreed = false, bool onlyReturnExisting = false) 393 { 394 bool rc; 395 /* Create newAccount payload */ 396 JSONValue jvPayload; 397 jvPayload["termsOfServiceAgreed"] = tosAgreed; 398 JSONValue jvContact = contacts; 399 jvPayload["contact"] = jvContact; 400 401 string payload = jvPayload.toJSON; 402 403 string[string] rheaders; 404 import std.net.curl : HTTP; 405 HTTP.StatusLine statusLine; 406 string response = sendRequest!(string,false)(acmeRes.newAccountUrl, payload, &statusLine, &rheaders); 407 if (statusLine.code / 100 == 2) 408 { 409 acmeRes.accountUrl = rheaders["location"]; 410 writeln("Account Location : ", acmeRes.accountUrl); 411 412 auto json = parseJSON(response); 413 writeln("Account Creation : ", json["createdAt"]); 414 // ... 415 rc = true; 416 } 417 else { 418 writeln("Got http error: ", statusLine); 419 writeln("Got response:\n", response); 420 // FIXME handle different error types... 421 } 422 return rc; 423 } 424 425 /** Authorization setup callback 426 * 427 * The implementation of this function allows Let's Encrypt to 428 * verify that the requestor has control of the domain name. 429 * 430 * The callback may be called once for each domain name in the 431 * 'issueCertificate' call. The callback should do whatever is 432 * needed so that a GET on the 'url' returns the 'keyAuthorization', 433 * (which is what the Acme protocol calls the expected response.) 434 * 435 * Note that this function may not be called in cases where 436 * Let's Encrypt already believes the caller has control 437 * of the domain name. 438 */ 439 alias Callback = 440 void function ( 441 string domainName, 442 string url, 443 string keyAuthorization); 444 445 /** Issue a certificate for domainNames 446 * 447 * The client begins the certificate issuance process by sending a POST 448 * request to the server's newOrder resource. The body of the POST is a 449 * JWS object whose JSON payload is a subset of the order object defined 450 * in Section 7.1.3, containing the fields that describe the certificate 451 * to be issued. 452 * 453 * Params: 454 * domainNames - list of domains 455 * callback - pointer to function to setup expected response 456 * on given URL 457 * Returns: A Certificate object or null. 458 * Throws: an instance of AcmeException on fatal or unexpected errors. 459 */ 460 Certificate issueCertificate(string domainKeyData, string[] domainNames, Callback callback) 461 { 462 if (domainNames.empty) 463 throw new AcmeException("There must be at least one domain name in a certificate"); 464 465 /* Pass any challenges we need to pass to make the CA believe we're entitled to a certificate. */ 466 JSONValue[] jvIdentifiers; 467 jvIdentifiers.length = domainNames.length; 468 foreach (i, domain ; domainNames) 469 { 470 jvIdentifiers[i]["type"] = "dns"; 471 jvIdentifiers[i]["value"] = domain; 472 } 473 JSONValue jvIdentifiersArray; 474 jvIdentifiersArray.array = jvIdentifiers; 475 476 JSONValue jvPayload; 477 // ISSUE: https://community.letsencrypt.org/t/notbefore-and-notafter-are-not-supported/54712 478 version (boulderHasBeforeAfter) { 479 jvPayload["notBefore"] = "2016-01-01T00:04:00+04:00"; // FIXME - use DateTime.to...() 480 jvPayload["notAfter"] = "2020-01-01T00:04:00+04:00"; // FIXME - use DateTime.to...() 481 } 482 jvPayload["identifiers"] = jvIdentifiersArray; 483 484 string payload = jvPayload.toJSON; 485 writeln("Payload : ", jvPayload.toPrettyString); 486 HTTP.StatusLine statusLine; 487 string response = sendRequest!string(acmeRes.newOrderUrl, payload, &statusLine); 488 489 if (statusLine.code / 100 != 2) { 490 writeln("Got http error: ", statusLine); 491 writeln("Got response:\n", response); 492 throw new AcmeException("Issue Request failed."); 493 //return cast(Certificate)null; 494 } 495 auto json = parseJSON(response); 496 writeln(json.toPrettyString); 497 498 /* If you pass a challenge, that's good for 300 days. The cert is only good for 90. 499 * This means for a while you can re-issue without passing another challenge, so we 500 * check to see if we need to validate again. 501 * 502 * Note that this introduces a race since it possible for the status to not be valid 503 * by the time the certificate is requested. The assumption is that client retries 504 * will deal with this. 505 */ 506 if ( ("status" in json) && 507 (json["status"].type == JSONType..string) && 508 (json["status"].str != "valid") ) 509 { 510 if ("authorizations" in json) { 511 auto authorizations = json["authorizations"]; 512 foreach ( i, authorizationUrl ; authorizations.array) 513 { 514 string authurl = authorizationUrl.str; 515 string response2 = sendRequest!string(authurl, "", &statusLine); 516 if (statusLine.code / 100 != 2) { 517 writeln("Got http error: ", statusLine); 518 writeln("Got response:\n", response2); 519 stdout.flush; 520 throw new AcmeException("Auth Request failed."); 521 //return cast(Certificate)null; 522 } 523 auto json2 = parseJSON(response2); 524 writeln(json2.toPrettyString); 525 526 if ("challenges" in json2) 527 { 528 auto domain = json2["identifier"]["value"].str; 529 auto challenges = json2["challenges"]; 530 foreach (j, challenge; challenges.array) 531 { 532 if ( ("type" in challenge) && 533 (challenge["type"].str == "http-01") ) 534 { 535 string token = challenge["token"].str; 536 string url = "http://" ~ domain ~ "/.well-known/acme-challenge/" ~ token; 537 string keyAuthorization = token ~ "." ~ jwkThumbprint_.to!string; 538 callback(domain, url, keyAuthorization); 539 verifyChallengePassed(authorizationUrl.str, challenge); 540 break; 541 } 542 } 543 } 544 } 545 } 546 } else { 547 writefln("Send payload: \n%s", jvPayload.toPrettyString); 548 writefln("Got failure response:\n%s", json.toPrettyString); 549 throw new AcmeException(json.toPrettyString); 550 } 551 552 // Issue the certificate 553 // auto r = makeCertificateSigningRequest(domainNames); 554 // string csr = r.csr; 555 // string privateKey = r.pkey; 556 const char[] privateKey = domainKeyData /* openSSL_CreatePrivateKey() */; 557 const char[] csr = openSSL_CreateCertificateSignRequest(privateKey, domainNames); 558 559 writeln("CSR:\n", csr); 560 561 /* Send CSRs and get the intermediate certs */ 562 string[string] rheaders; 563 564 JSONValue ncrs; 565 ncrs["csr"] = csr; 566 567 auto finalizeUrl = json["finalize"].str; 568 auto finalizePayLoad = ncrs.toJSON; 569 auto finalizeResponseStr = sendRequest!(char[])(finalizeUrl, finalizePayLoad, &statusLine, &rheaders); 570 if (statusLine.code / 100 != 2) { 571 writeln("Got http error: ", statusLine); 572 writeln("Got response:\n", finalizeResponseStr); 573 stdout.flush; 574 throw new AcmeException("Verification for passed challange failed."); 575 } 576 auto finalizeResponseJV = parseJSON(finalizeResponseStr); 577 writeln(finalizeResponseJV.toPrettyString); 578 579 /* Download the certificate (via POST-as-GET) */ 580 auto certificateUrl = finalizeResponseJV["certificate"].str; 581 auto crtpem = sendRequest!(char[])(certificateUrl, "", &statusLine, &rheaders); 582 writeln(crtpem); 583 584 /* Create a container object */ 585 Certificate cert; 586 //~ cert.fullchain = convertDERtoPEM(der) ~ cast(string)getIntermediateCertificate(rheaders["Link"]); 587 cert.fullchain = crtpem.to!string; 588 cert.privkey = privateKey.idup; 589 return cert; 590 } 591 592 /** Acknowledge to CA server that a Auth is setup for check. 593 * 594 * Params: 595 * authorizationUrl - url to a auth job 596 * challenge - the current challange for reference 597 * 598 * Throws if the challenge isn't accepted (or on timeout) 599 */ 600 void verifyChallengePassed(string authorizationUrl, JSONValue challenge) 601 { 602 string verificationUri = challenge["url"].str; 603 604 import std.net.curl : HTTP; 605 HTTP.StatusLine statusLine; 606 string response = sendRequest!string(verificationUri, q"({})", &statusLine ); 607 if (statusLine.code / 100 != 2) { 608 writeln("Got http error: ", statusLine); 609 writeln("Got response:\n", response); 610 stdout.flush; 611 throw new AcmeException("Verification for passed challange failed."); 612 //return cast(Certificate)null; 613 } 614 // Poll waiting for the CA to verify the challenge 615 int counter = 0; 616 enum count = 10; 617 do 618 { 619 // sleep for a second 620 import core.thread : Thread; 621 Thread.sleep(dur!"seconds"(2)); 622 623 // get response from verification URL 624 response = sendRequest!string(authorizationUrl, "", &statusLine); 625 if (statusLine.code / 100 != 2) { 626 writeln("Got http error: ", statusLine); 627 writeln("Got response:\n", response); 628 stdout.flush; 629 throw new AcmeException("Verification for passed challange failed."); 630 } 631 else { 632 writeln(response); 633 auto json = parseJSON(response); 634 //writeln(json.toPrettyString); 635 if (json["status"].str == "valid") 636 { 637 writeln("challange valid. Continue."); 638 return; 639 } 640 } 641 } while (counter++ < count); 642 643 throw new AcmeException("Failure / timeout verifying challenge passed"); 644 } 645 } 646 647 /* ------------------------------------------------------------------------ */ 648 /* --- Helper Functions --------------------------------------------------- */ 649 /* ------------------------------------------------------------------------ */ 650 651 /** Get the issuer certificate from a 'Link' response header 652 * 653 * Param: 654 * linkHeader - ResponseHeader Line of the form 655 * Link: <https://acme-v01.api.letsencrypt.org/acme/issuer-cert>;rel="up" 656 * Returns: 657 * Pem-encoded issuer certificate string 658 */ 659 string getIntermediateCertificate(string linkHeader) 660 { 661 /* Extract the URL from the Header */ 662 import std.regex; 663 // Link: <https://acme-v01.api.letsencrypt.org/acme/issuer-cert>;rel="up" 664 auto r = regex("^<(.*)>;rel=\"up\"$"); 665 auto match = matchFirst(linkHeader, r); 666 if (match.empty) 667 { 668 throw new AcmeException("Unable to parse 'Link' header with value " ~ linkHeader); 669 } 670 char[] url = cast(char[])match[1]; 671 672 /* Download the issuer certificate */ 673 auto reps = get(url); 674 auto rstr = convertDERtoPEM( reps ); 675 return rstr; 676 }