I just updated the node.js Let's Encrypt libraries (greenlock.js and acme-v2.js) to use Let's Encrypt v2, which has wildcard support.

I want to explain step by step how you could build your own client, if you so chose.

:)

Let's Encrypt v2 vs ACME draft 11

A quick note: There is no "ACME v2". In fact, we don't even have ACME v1 yet!

Let's Encrypt v2 (more properly v02) is ACME draft 11 (whereas Let's Encrypt v1 was a pre-spec, experimental implementation of ACME)

Overview

This is a summary of the process defined in the ACME spec for all of the things that you need to do in order to get a certificate from Let's Encrypt.

  1. Retrieve API "Directory"
  2. Get a "nonce" for each non-GET API action
  3. Generate an "account keypair"
  4. Sign/Create an "account" with the keypair
  5. Sign/Create an "order" of multiple domains
  6. Retrieve each "challenge" for the order
  7. Create/Sign each "challenge response"
  • http-01
  • dns-01
  • R.I.P. tls-01
  1. Poll for "challange validation"
  2. Generate a "domain keypair"
  3. Sign/Create "CSR"
  4. Poll for "certificate ready"
  5. Download your certificate ("fullchain.pem")

Analogy: Callback from your Bank

If you've ever called your bank from a phone that isn't listed on your account, you've likely had an experienc very similar to the Let's Encrypt / ACME challenge process.

You call and ask to make a change to the account.

They tell you that they don't recognize the number and ask which number they should call you back on.

You select a number.

You answer the call on that phone.

They ask you a security question.

Now you know that you're talking to your bank (because you called them on the number you expect) and they know that they're talking to you (because they called you on the number they expect).

You're now authorized to make a change to the account.

Step by Step

In the words of my favorite Italian New York plumber: Here we go!

(and, believe it or not, I'm going to show you how to do this entirely from a modern web browser!)

1. List the API directory

You'll need to follow the documentation of the service that you're using to get the initial URL by which you discover the API directory.

Most likely you're using Let's Encrypt v2 with the following URLs:

globals:

var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory';
var directory;

code:

window.fetch(directoryUrl, { mode: 'cors' }).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
  return resp.json().then(function (body) {
    directory = body;
    console.log('Body:');
    console.log(JSON.stringify(directory, null, 2));
    console.log('');
  });
});

What you see here may look something like this:

{
  "P5PlKUcn7CA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
  "keyChange": "https://acme-staging-v02.api.letsencrypt.org/acme/key-change",
  "meta": {
    "caaIdentities": [
      "letsencrypt.org"
    ],
    "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf",
    "website": "https://letsencrypt.org/docs/staging-environment/"
  },
  "newAccount": "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct",
  "newNonce": "https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce",
  "newOrder": "https://acme-staging-v02.api.letsencrypt.org/acme/new-order",
  "revokeCert": "https://acme-staging-v02.api.letsencrypt.org/acme/revoke-cert"
}

Congrats! You've just discovered all of the necessary API endpoints for Let's Encrypt.

2. Obtain your first nonce

Every signed request must have a "nonce" which is in the API world similar to what a "salt" is in the password world.

It's a random value that is only used once. It is used to prevent "replay" attacks (an attack where a man-in-the-middle attacker simply resends a packet it captures, even if encrypted).

globals:

var nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce';
var nonce;

code:

window.fetch(nonceUrl, { mode: 'cors' }).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
  nonce = resp.headers.get('replay-nonce');
  console.log('Nonce:', nonce);
  // resp.body is empty
  return resp.headers.get('replay-nonce');
});

The header replay-nonce will contain a random value like this:

WqRXqwgixeDMvirOUplqLODfRZUcnLiJyFKhcFRA7UQ

3. Generate your account key

Instead of having a username and password, Let's Encrypt uses PKI.

A simple public key represents your account. Signing any message with your private key, and including your key id (kid) in the message, is sufficient to verify the authenticity of your identity and grant the proper authorizations to your domains and certificates.

globals:

var accountKeypair;
var accountJwk;

code:

// https://github.com/diafygi/webcrypto-examples#ecdsa---generatekey
var extractable = true;
window.crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" }
, extractable
, [ 'sign', 'verify' ]
).then(function (result) {
  accountKeypair = result;

  return window.crypto.subtle.exportKey(
    "jwk"
  , result.privateKey
  ).then(function (jwk) {

    accountJwk = jwk;
    console.log('private jwk:');
    console.log(JSON.stringify(jwk, null, 2));

    return window.crypto.subtle.exportKey(
      "pkcs8"
    , result.privateKey
    ).then(function (keydata) {
      console.log('pkcs8:');
      console.log(Array.from(new Uint8Array(keydata)));
    });
  })
});

In truth an ECDSA key is simply any 256-bit random number, but it's fun to use the web APIs for key generation. ECDSA is much, much faster than RSA key generation, but the signing and verifying take much longer.

This is what an ECDSA private key looks like:

{ "crv": "P-256"
, "d": "eivqotYAIXqPTMVdsm40l2aYidrlDrSL0l3gXJA4EAY"
, "ext": true
, "key_ops": [ "sign" ]
, "kty": "EC"
, "x": "-gTUXZfzdRWELDjErugXikzpw70X5VPQXkkKKoSzIws"
, "y": "142Hl46OSFbv4mifDYdAcgy6LLHerv_4Gucc8SZ01uY"
}

And the public key is identitical to the private key, sans the d property:

// return window.crypto.subtle.exportKey("jwk", publicKey)
{ "crv": "P-256"
, "ext": true
, "key_ops": [ "verify" ]
, "kty": "EC"
, "x": "-gTUXZfzdRWELDjErugXikzpw70X5VPQXkkKKoSzIws"
, "y": "142Hl46OSFbv4mifDYdAcgy6LLHerv_4Gucc8SZ01uY"
}

Note that the keys ext and key_ops are only for WebCrypto.

4a. Sign your account

Your "account" is really just two pieces of information:

  • a message including your public key, signed by your private key
  • a kid (key id) from Let's Encrypt

globals:

var accountUrl = directory.newAccount;
var signedAccount;

code:

// json to url-safe base64
function jsto64(json) {
  return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
var textEncoder = new TextEncoder();

var payload64 = jsto64(
  { termsOfServiceAgreed: true
  , onlyReturnExisting: false
  , contact: [ 'mailto:john.doe@gmail.com' ]
  }
);

var protected64 = jsto64(
  { nonce: nonce
  , url: accountUrl
  , alg: 'ES256'
  , jwk: {
      kty: accountJwk.kty
    , crv: accountJwk.crv
    , x: accountJwk.x
    , y: accountJwk.y
    }
  }
);

// Note: this function hashes before signing so send data, not the hash
window.crypto.subtle.sign(
  { name: "ECDSA", hash: { name: "SHA-256" } }
, accountKeypair.privateKey
, textEncoder.encode(protected64 + '.' + payload64)
).then(function (signature) {

  // convert buffer to urlsafe base64
  var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  console.log('URL-safe Base64 Signature:');
  console.log(sig64);

  signedAccount = {
    protected: protected64
  , payload: payload64
  , signature: sig64
  };
  console.log('Signed Base64 Account:');
  console.log(JSON.stringify(signedAccount, null, 2));
});

Note: You should read and agree to directory.meta.termsOfService before specifying termsOfServiceAgreed: true and you should not create clients which automatically agree by default.

And this is what your signed account looks like:

{ "protected": "eyJub25jZSI6Il80ZEhjQlRab2RFVS1OcWtnckR4NnNfQVd1OHBZWFU0c1A3alpsZmhKM1UiLCJ1cmwiOiJodHRwczovL2FjbWUtc3RhZ2luZy12MDIuYXBpLmxldHNlbmNyeXB0Lm9yZy9hY21lL25ldy1hY2N0IiwiYWxnIjoiRVMyNTYiLCJqd2siOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJHN2t1VjRKaXFacy1HenRyenBzbVVNN1JhZjl0RFVFTFd0NU8zMzdzVHF3IiwieSI6Im41U0Z6OXoyaS1aRl96dTVhb1M5dDlPOHlfZzJxZm9uWHYzQ25hMmUzOWsifX0"
, "payload": "eyJ0ZXJtc09mU2VydmljZUFncmVlZCI6dHJ1ZSwib25seVJldHVybkV4aXN0aW5nIjpmYWxzZSwiY29udGFjdCI6WyJtYWlsdG86am9obi5kb2VAZ21haWwuY29tIl19"
, "signature": "XGJtuj-BnohWmlOT9vAO5c-2yrbj_2GeRrJMs4_Cu-lt78FjqcqWUvtjo2nl5bhyEV492mrkQjJrkFgJKexXsA"
}

4b. Create/Retrieve your account

When you POST your signed account to Let's Encrypt you'll get one of three outcomes:

  • You'll get a notice that the account was created
  • You'll get back your account id
  • You'll get an error
  • (secret fourth option: the world ends before you get any of those)

globals:

var account;
var accountId;
nonce = null;
window.fetch(accountUrl, {
  mode: 'cors'
, method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(signedAccount)
}).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
  nonce = resp.headers.get('replay-nonce');
  accountId = resp.headers.get('location');
  console.log('Next nonce:', nonce);
  console.log('Location/kid:', accountId);

  if (!resp.headers.get('content-type')) {
   console.log('Body: <none>');
   return;
  }

  return resp.json().then(function (result) {
    console.log('Body:');
    console.log(JSON.stringify(result, null, 2));
  });
});

A new account creation response looks like this:

Headers:

Cache-Control: max-age=0, no-cache, no-store
Content-Type: application/json
Expires: Tue, 17 Apr 2018 21:29:10 GMT
Link: <https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf>;rel="terms-of-service"
Location: https://acme-staging-v02.api.letsencrypt.org/acme/acct/5937234
Pragma: no-cache
Replay-nonce: DKxX61imF38y_qkKvVcnWyo9oxQlHll0t9dMwGbkcxw

Body:

{
  "id": 5937234,
  "key": {
    "kty": "EC",
    "crv": "P-256",
    "x": "G7kuV4JiqZs-GztrzpsmUM7Raf9tDUELWt5O337sTqw",
    "y": "n5SFz9z2i-ZF_zu5aoS9t9O8y_g2qfonXv3Cna2e39k"
  },
  "contact": [
    "mailto:john.doe@gmail.com"
  ],
  "initialIp": "128.187.116.28",
  "createdAt": "2018-04-17T21:29:10.833305103Z",
  "status": "valid"
}

If the account already exists the then you get back just headers and no body:

Headers

Cache-Control: max-age=0, no-cache, no-store
Expires: Tue, 17 Apr 2018 21:36:26 GMT
Location: https://acme-staging-v02.api.letsencrypt.org/acme/acct/5937234
Pragma: no-cache
Replay-nonce: wMA5WBzkBMLVJ_BevwTqaDrBerDp0dm-JQ7HHGaLTls

5a. Sign an order

Orders can have multiple domains, subdomains, and even wildcards.

globals:

var orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order";
var signedOrder;

code:

var payload64 = jsto64(
  { identifiers: [
      { type: 'dns', value: 'example.com' }
    , { type: 'dns', value: '*.example.com' }
    ]
  }
);

var protected64 = jsto64(
  { nonce: nonce, alg: 'ES256', url: orderUrl, kid: accountId }
);

window.crypto.subtle.sign(
  { name: "ECDSA", hash: { name: "SHA-256" } }
, accountKeypair.privateKey
, textEncoder.encode(protected64 + '.' + payload64)
).then(function (signature) {

  // convert buffer to urlsafe base64
  var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  console.log('URL-safe Base64 Signature:');
  console.log(sig64);

  signedOrder = {
    protected: protected64
  , payload: payload64
  , signature: sig64
  };
  console.log('Signed Base64 Order:');
  console.log(JSON.stringify(signedOrder, null, 2));
});

Your signed order will look very similar to the signed account:

{ "protected": "..."
, "payload": "..."
, "signature": "..."
}

5b. Create an order

globals:

var order;
var currentOrderUrl;
var authorizationUrls;
var finalizeUrl;

code:

nonce = null;
window.fetch(orderUrl, {
  mode: 'cors'
, method: 'POST'
, headers: { 'Content-Type': 'application/jose+json' }
, body: JSON.stringify(signedOrder)
}).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
  currentOrderUrl = resp.headers.get('location');
  nonce = resp.headers.get('replay-nonce');
  console.log('Next nonce:', nonce);

  return resp.json().then(function (result) {
    authorizationUrls = result.authorizations;
    finalizeUrl = result.finalize;
    console.log('Body:');
    console.log(JSON.stringify(result, null, 2));
  });
});

The response will look like this:

Cache-Control: max-age=0, no-cache, no-store
Content-Type: application/json
Expires: Wed, 18 Apr 2018 00:30:22 GMT
Location: https://acme-staging-v02.api.letsencrypt.org/acme/order/5937234/443948
Pragma: no-cache
Replay-nonce: CQCWNQldJpHtbr9W3G2VycbqD-QD15horO_B3nqivZk
{ "status": "pending",
, "expires": "2018-04-25T00:23:57.096955215Z"
  "identifiers": [
    { "type": "dns", "value": "*.example.com" }
  , { "type": "dns", "value": "example.com" }
  ]
, "authorizations": [
    "https://acme-staging-v02.api.letsencrypt.org/acme/authz/RUOHaPFbGTE0yU-WR_fg0JgnvbWvk7XVpYIWg-pyJYI"
  , "https://acme-staging-v02.api.letsencrypt.org/acme/authz/cMkwXI8pIeKN04Ynfem8ErHK3GeqAPdSt2x6q7PvVGU"
  ]
, "finalize": "https://acme-staging-v02.api.letsencrypt.org/acme/finalize/5937234/443948"
}

Identical orders will produce identical order results.

6a. View Challenge

var authzUrl = authorizationUrls.pop();
var token;
var challengeDomain;
var challengeUrl;

window.fetch(authzUrl, {
  mode: 'cors'
}).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });

  return resp.json().then(function (result) {
    // Note: select the challenge you wish to use
    var challenge = result.challenges.slice(0).pop();
    token = challenge.token;
    challengeUrl = challenge.url;
    challengeDomain = result.identifier.value;

    console.log('Body:');
    console.log(JSON.stringify(result, null, 2));
  });
});

As you can see, each domain, subdomain, and wildcard must be validated independently.

Here's what domain and subdomain challenges look like:

{
  "identifier": {
    "type": "dns",
    "value": "example.com"
  },
  "status": "pending",
  "expires": "2018-04-25T00:23:57Z",
  "challenges": [
    {
      "type": "dns-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/challenge/cMkwXI8pIeKN04Ynfem8ErHK3GeqAPdSt2x6q7PvVGU/118755342",
      "token": "LZdlUiZ-kWPs6q5WTmQFYQHZKpz9szn2vxEUu0XhyyM"
    },
    {
      "type": "http-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/challenge/cMkwXI8pIeKN04Ynfem8ErHK3GeqAPdSt2x6q7PvVGU/118755343",
      "token": "1S4zBG5YVhwSBaIY4ksI_KNMRrSmH0DZfNM9v7PYjDU"
    }
  ]
}

Here's what a wildcard challenge looks like:

Note that wildcard domains can only be challenged via dns-01. For obvious reasons http-01 can't work.

{
  "identifier": { "type": "dns", "value": "example.com" },
  "status": "pending",
  "expires": "2018-04-25T00:23:57Z",
  "challenges": [
    {
      "type": "dns-01",
      "status": "pending",
      "url": "https://acme-staging-v02.api.letsencrypt.org/acme/challenge/RUOHaPFbGTE0yU-WR_fg0JgnvbWvk7XVpYIWg-pyJYI/118755341",
      "token": "P0GIkR6_t9kKnLjQ2OuEqHK4N9f6y8A5JaCkMDD-ezo"
    }
  ],
  "wildcard": true
}

6b. Setup Challenge

The challenge is derived from the token and the account public key thumbprint.

The thumbprint is the hash of the stringified public JWK, with the keys lexographically sorted.

var thumbprint;
var keyAuth;
var httpPath;
var dnsAuth;
var dnsRecord;

Thumbprint:

// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk

var accountPublicStr = '{' + ['crv', 'kty', 'x', 'y'].map(function (key) {
  return '"' + key + '":"' + accountJwk[key] + '"';
}).join(',') + '}';

window.crypto.subtle.digest(
  { name: "SHA-256", }
, textEncoder.encode(accountPublicStr)
).then(function(hash){
  thumbprint = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  console.log('Thumbprint:');
  console.log(thumbprint);
});

The method for http-01 is simply to concatonate the challenge token and the thumbprint (and then place it at the predetermined path):

// The contents of the key authorization file
keyAuth = token + '.' + thumbprint;

// Where the key authorization file goes
httpPath = 'http://' + challengeDomain + '/.well-known/acme-challenge/' + token;

Since DNS records need to be fairly short, the method for dns-01 is to hash the key authorization (and then create a record for it at the predetermined path):

window.crypto.subtle.digest(
  { name: "SHA-256", }
, textEncoder.encode(keyAuth)
).then(function(hash){
  dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  dnsRecord = '_acme-challenge.' + challengeDomain;

  console.log('DNS TXT Auth:');
  // The name of the record
  console.log(dnsRecord);
  // The TXT record value
  console.log(dnsAuth);
});

6c. Sign and Confirm that Challenge is Ready

Now's where it starts to get fun!

You may want to check for yourself that the files exist and records are set before you proceed.

dig TXT _acme-challenge.example.com

curl http://example.com/.well-known/acme-challenge/xxxxxxxxxx

Once that's done you're ready to let the Let's Encrypt server know that you're ready for the challenge to be validated:

var challengePollUrl;
var payload64 = jsto64(
  {}
);

var protected64 = jsto64(
  { nonce: nonce, alg: 'ES256', url: challengeUrl, kid: accountId }
);

nonce = null;
window.crypto.subtle.sign(
  { name: "ECDSA", hash: { name: "SHA-256" } }
, accountKeypair.privateKey
, textEncoder.encode(protected64 + '.' + payload64)
).then(function (signature) {

  var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  var body = {
    protected: protected64
  , payload: payload64
  , signature: sig64
  };

  return window.fetch(
    challengeUrl
  , { mode: 'cors'
    , method: 'POST'
    , headers: { 'Content-Type': 'application/jose+json' }
    , body: JSON.stringify(body)
    }
  ).then(function (resp) {
    console.log('Headers:');
    Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
    nonce = resp.headers.get('replay-nonce');

    return resp.json().then(function (reply) {
      challengePollUrl = reply.url;

      console.log('Challenge ACK:');
      console.log(JSON.stringify(reply));
    });
  });
});

And the response looks like this:

Cache-Control: max-age=0, no-cache, no-store
Content-Type: application/json
Expires: Wed, 18 Apr 2018 05:19:13 GMT
Link: <https://acme-staging-v02.api.letsencrypt.org/acme/authz/ZFM2inKfwb6k9xNwyPV_98DN2lBr4uJl-rKjJUqKRy8>;rel="up"
Location: https://acme-staging-v02.api.letsencrypt.org/acme/challenge/ZFM2inKfwb6k9xNwyPV_98DN2lBr4uJl-rKjJUqKRy8/118809539
Pragma: no-cache
Replay-nonce: TjJXk628UbgXd-8zHR7jdOOYaF7pSPFHyoMtHeL17ks
{
  "type": "dns-01",
  "status": "pending",
  "url": "https://acme-staging-v02.api.letsencrypt.org/acme/challenge/ZFM2inKfwb6k9xNwyPV_98DN2lBr4uJl-rKjJUqKRy8/118809539",
  "token": "7rR455cCuTn1o-Xoa2E7_Ut-BkE3DwdfHytx4kFjw7Y"
}

6d. Poll Challenge State

It may take some small amount of time for the Let's Encrypt server to finish validating the authorization challenge.

You need to poll until you get a 'valid' or 'invalid'. You'll probably only need to poll once.

return window.fetch(challengePollUrl, { mode: 'cors' }).then(function (resp) {
  console.log('Headers:');
  Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
  nonce = resp.headers.get('replay-nonce');

  return resp.json().then(function (reply) {
    challengePollUrl = reply.url;

    console.log('Challenge Poll:');
    console.log(JSON.stringify(reply));
  });
});

6e. Rinse and Repeat

Repeat for the next challenge until all challenges have been validated (or invalidated).

7a. Generate Domain Keypair

This is the same process as it was for the account, but this key is specefic to the domain(s).

globals:

var domainKeypair;
var domainJwk;

code:

var extractable = true;
window.crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" }
, extractable
, [ 'sign', 'verify' ]
).then(function (result) {
  domainKeypair = result;

  return window.crypto.subtle.exportKey(
    "jwk"
  , result.privateKey
  ).then(function (jwk) {

    domainJwk = jwk;
    console.log('private jwk:');
    console.log(JSON.stringify(jwk, null, 2));

  })
});

7b. Create CSR for validated domains

This is where things get a little trixy. Now we have to find a library to use to create a CSR. I took CloudFlare's advice to use PKI.js (v1.3.33)

wget "https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/common.js"
wget "https://raw.githubusercontent.com/PeculiarVentures/ASN1.js/41b63af/org/pkijs/asn1.js"
wget "https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/x509_schema.js"
wget "https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/x509_simpl.js"
<script type="text/javascript" src="https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/common.js"></script>
<script type="text/javascript" src="https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/asn1.js"></script>
<script type="text/javascript" src="https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/x509_schema.js"></script>
<script type="text/javascript" src="https://raw.githubusercontent.com/PeculiarVentures/PKI.js/41b63af/org/pkijs/x509_simpl.js"></script>

And then I had to home-brew a little function to build the CSR:

wget "https://git.coolaj86.com/coolaj86/browser-csr.js/raw/branch/master/csr.js"
<script src="https://git.coolaj86.com/coolaj86/browser-csr.js/raw/branch/master/csr.js"></script>

Then the CSR generation became as easy as this:

var csr;

code:

CSR.generate(domainKeypair, [ 'example.com', '*.example.com' ]).then(function (csrweb64) {
  csr = csrweb64
  console.log(csrweb64);
});

ACME CSR URL-safe Base64:

This is different from a normal CSR PEM in the following ways:

  • no headers and footers
  • no newlines (no pretty printing)
  • url-safe base64
MIIBLjCB1gIBADAaMRgwFgYDVQQDDA90ZXN0LnBwbC5mYW1pbHkwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS2Owc0BcPhIpdDXcIdTqtY4-0LBN8Om24TZp4xeBQ-DWXkXGyWYgkrfOJwUdgb5XY2W_RjED74-YCaz56AiGrioFowWAYJKoZIhvcNAQkOMUswSTApBgNVHQ4EIgQgkeHkooFHTDefC9HkXFv5RsTYGghKMNYtqhnvGC7cr-owHAYDVR0RBBUwE4IRKi50ZXN0LnBwbC5mYW1pbHkwCgYIKoZIzj0EAwIDRwAwRAIgLyl3S_zpqx4khWfHhrlG4bb1fFQl5PWb410AmKJHDroCIBk8J0Q0jalQ-igzhtSPr8ClJwOwWbUSfpOpBPmmWZN7

Typical CSR PEM:

-----BEGIN CERTIFICATE REQUEST-----
MIIBLjCB1gIBADAaMRgwFgYDVQQDDA90ZXN0LnBwbC5mYW1pbHkwWTATBgcqhkjO
PQIBBggqhkjOPQMBBwNCAAS2Owc0BcPhIpdDXcIdTqtY4+0LBN8Om24TZp4xeBQ+
DWXkXGyWYgkrfOJwUdgb5XY2W/RjED74+YCaz56AiGrioFowWAYJKoZIhvcNAQkO
MUswSTApBgNVHQ4EIgQgkeHkooFHTDefC9HkXFv5RsTYGghKMNYtqhnvGC7cr+ow
HAYDVR0RBBUwE4IRKi50ZXN0LnBwbC5mYW1pbHkwCgYIKoZIzj0EAwIDRwAwRAIg
Lyl3S/zpqx4khWfHhrlG4bb1fFQl5PWb410AmKJHDroCIBk8J0Q0jalQ+igzhtSP
r8ClJwOwWbUSfpOpBPmmWZN7
-----END CERTIFICATE REQUEST-----

You can inspect a CSR with openssl

openssl req -in csr.pem -noout -text

7c. Finalize Order (CSR)

All the hard work is done, now on to the fun part!

var certificateUrl;
var payload64 = jsto64(
  { csr: csr }
);

var protected64 = jsto64(
  { nonce: nonce, alg: 'ES256', url: finalizeUrl, kid: accountId }
);

nonce = null;
window.crypto.subtle.sign(
  { name: "ECDSA", hash: { name: "SHA-256" } }
, accountKeypair.privateKey
, textEncoder.encode(protected64 + '.' + payload64)
).then(function (signature) {

  var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) {
    return String.fromCharCode(ch);
  }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  var body = {
    protected: protected64
  , payload: payload64
  , signature: sig64
  };

  return window.fetch(
    finalizeUrl
  , { mode: 'cors'
    , method: 'POST'
    , headers: { 'Content-Type': 'application/jose+json' }
    , body: JSON.stringify(body)
    }
  ).then(function (resp) {
    console.log('Headers:');
    Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); });
    nonce = resp.headers.get('replay-nonce');

    return resp.json().then(function (reply) {
      certificateUrl = reply.certificate;
      console.log('Certificate Request ACK:');
      console.log(JSON.stringify(reply));
    });
  });
});

The response looks like this:

{
  "status": "processing",
  "expires": "2018-04-25T05:13:17Z",
  "identifiers": [
    {
      "type": "dns",
      "value": "*.test.rootprojects.org"
    },
    {
      "type": "dns",
      "value": "test.rootprojects.org"
    }
  ],
  "authorizations": [
    "https://acme-staging-v02.api.letsencrypt.org/acme/authz/Xj5z621le4b9zR30U90cTkcYy6WplAUAvZWHHagcxYQ",
    "https://acme-staging-v02.api.letsencrypt.org/acme/authz/ZFM2inKfwb6k9xNwyPV_98DN2lBr4uJl-rKjJUqKRy8"
  ],
  "finalize": "https://acme-staging-v02.api.letsencrypt.org/acme/finalize/5938644/446187",
  "certificate": "https://acme-staging-v02.api.letsencrypt.org/acme/cert/fa76d29a54d7b31b77bb0c670b8596168a6c"
}

7d. Poll Certificate Readiness

If the status of the finalized order is not "valid" (i.e. it is "processing"), you will need to poll the currentOrderUrl until it is valid.

window.fetch(currentOrderUrl, { mode: 'cors' }).then(function (resp) {
  return resp.json().then(function (result) {
    console.log(JSON.stringify(result, null, 2));
  });
});

This response is exactly the same as the above, but hopefully the status changes to valid.

{
  "status": "valid",
  "expires": "2018-04-25T05:13:17Z",
  "identifiers": [
    {
      "type": "dns",
      "value": "*.test.rootprojects.org"
    },
    {
      "type": "dns",
      "value": "test.rootprojects.org"
    }
  ],
  "authorizations": [
    "https://acme-staging-v02.api.letsencrypt.org/acme/authz/Xj5z621le4b9zR30U90cTkcYy6WplAUAvZWHHagcxYQ",
    "https://acme-staging-v02.api.letsencrypt.org/acme/authz/ZFM2inKfwb6k9xNwyPV_98DN2lBr4uJl-rKjJUqKRy8"
  ],
  "finalize": "https://acme-staging-v02.api.letsencrypt.org/acme/finalize/5938644/446187",
  "certificate": "https://acme-staging-v02.api.letsencrypt.org/acme/cert/fa76d29a54d7b31b77bb0c670b8596168a6c"
}

7e. Download Certificate (fullchain.pem)

At last you get to enjoy the fruits of your labor. Your certificate is ready!

var fullchainPem;
window.fetch(certificateUrl, { mode: 'cors' }).then(function (resp) {
  return resp.text().then(function (result) {
    fullchainPem = result;
    console.log(result);
  });
});

The result looks like this (and specifically uses \r\n as the line separator):

-----BEGIN CERTIFICATE-----
MIIFMjCCBBqgAwIBAgITAPp20ppU17Mbd7sMZwuFlhaKbDANBgkqhkiG9w0BAQsF
ADAiMSAwHgYDVQQDDBdGYWtlIExFIEludGVybWVkaWF0ZSBYMTAeFw0xODA0MTgw
OTQwMDdaFw0xODA3MTcwOTQwMDdaMBoxGDAWBgNVBAMTD3Rlc3QucHBsLmZhbWls
eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLY7BzQFw+Eil0Ndwh1Oq1jj7QsE
3w6bbhNmnjF4FD4NZeRcbJZiCSt84nBR2BvldjZb9GMQPvj5gJrPnoCIauKjggMy
MIIDLjAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
BwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFNzmdRwrzlaApwBsvK7cVq1kJeTG
MB8GA1UdIwQYMBaAFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHcGCCsGAQUFBwEBBGsw
aTAyBggrBgEFBQcwAYYmaHR0cDovL29jc3Auc3RnLWludC14MS5sZXRzZW5jcnlw
dC5vcmcwMwYIKwYBBQUHMAKGJ2h0dHA6Ly9jZXJ0LnN0Zy1pbnQteDEubGV0c2Vu
Y3J5cHQub3JnLzAtBgNVHREEJjAkghEqLnRlc3QucHBsLmZhbWlseYIPdGVzdC5w
cGwuZmFtaWx5MIH+BgNVHSAEgfYwgfMwCAYGZ4EMAQIBMIHmBgsrBgEEAYLfEwEB
ATCB1jAmBggrBgEFBQcCARYaaHR0cDovL2Nwcy5sZXRzZW5jcnlwdC5vcmcwgasG
CCsGAQUFBwICMIGeDIGbVGhpcyBDZXJ0aWZpY2F0ZSBtYXkgb25seSBiZSByZWxp
ZWQgdXBvbiBieSBSZWx5aW5nIFBhcnRpZXMgYW5kIG9ubHkgaW4gYWNjb3JkYW5j
ZSB3aXRoIHRoZSBDZXJ0aWZpY2F0ZSBQb2xpY3kgZm91bmQgYXQgaHR0cHM6Ly9s
ZXRzZW5jcnlwdC5vcmcvcmVwb3NpdG9yeS8wggEEBgorBgEEAdZ5AgQCBIH1BIHy
APAAdgDdmTT8peckgMlWaH2BNJkISbJJ97Vp2Me8qz9cwfNuZAAAAWLYVxAcAAAE
AwBHMEUCIC8Nh9Fh13W3WbA2bsrCwEFo3eG9pvY1sotI5Fhcn2ibAiEAhAKbJNiF
dWjlz9ktkAgsma/c51MgnvLR5HE2CZ7rhysAdgCwzIPlpfl9a698CcwoSQSHKsfo
ixMsY1C3xv0m4WxsdwAAAWLYVxO3AAAEAwBHMEUCIQDqKANVNQ/y0MypX55ZasRT
JygZP3pUFslKu1Q4nkyJyAIgdkEaPLZFyeiCzQm/ZDhp1pFz2PAAJGHU7UUoxDyG
BSYwDQYJKoZIhvcNAQELBQADggEBABa38ZTyuFzKE3/UvRrYfkNlyWmVEWvLqMJ2
VhzlkHyOwkbixc64TCqvAjYo0EGFGO03RKRSKDIUghBlWOW4mcqRO9/60IHWkBcF
WGqs9G2AqORXnsbUNg2nTApts+TtdQw0BFIJfjJCDiV1KBy75+sN8b68BM8vZqYe
YUMPa/gfek+aBqQwPxY0OAoIjOAJh0N0YH6gmo36Lrm5/sLoFS0+RPNLPmM7zWCG
cuXZ7tMjuHdWQaxA9OnDNFF133lNeSwcwANX4IUOu8nS1ycGUNQlXq0yY1Jto+dl
lBcRcyiPKSxmf3GI+1oPFc2iDfSQDRXFtvrh+3eNPGCI1wV4lk4=
-----END CERTIFICATE-----

-----BEGIN CERTIFICATE-----
MIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw
GjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2
MDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0
8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym
oLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0
ZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN
xDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56
dhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9
AgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw
HQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0
BggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu
b3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu
Y3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq
hkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF
UGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9
AFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp
DQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7
IkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf
zWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI
PTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w
SVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em
2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0
WzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt
n5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=
-----END CERTIFICATE-----

By AJ ONeal

If you loved this and want more like it, sign up!


Did I make your day?
Buy me a coffeeBuy me a coffee  

(you can learn about the bigger picture I'm working towards on my patreon page )