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 }