|
|
@ -0,0 +1,478 @@ |
|
|
|
(function (exports) { |
|
|
|
'use strict'; |
|
|
|
|
|
|
|
var BACME = exports.BACME = {}; |
|
|
|
var webFetch = exports.fetch; |
|
|
|
var webCrypto = exports.crypto; |
|
|
|
|
|
|
|
var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; |
|
|
|
var directory; |
|
|
|
|
|
|
|
var nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce'; |
|
|
|
var nonce; |
|
|
|
|
|
|
|
var accountKeypair; |
|
|
|
var accountJwk; |
|
|
|
|
|
|
|
var accountUrl = directory.newAccount; |
|
|
|
var signedAccount; |
|
|
|
|
|
|
|
BACME.challengePrefixes = { |
|
|
|
'http-01': '/.well-known/acme-challenge' |
|
|
|
, 'dns-01': '_acme-challenge' |
|
|
|
}; |
|
|
|
|
|
|
|
BACME._logHeaders = function (resp) { |
|
|
|
console.log('Headers:'); |
|
|
|
Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME._logBody = function (body) { |
|
|
|
console.log('Body:'); |
|
|
|
console.log(JSON.stringify(body, null, 2)); |
|
|
|
console.log(''); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.directory = function (url) { |
|
|
|
return webFetch(directoryUrl, { mode: 'cors' }).then(function (resp) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
return resp.json().then(function (body) { |
|
|
|
directory = body; |
|
|
|
BACME._logBody(body); |
|
|
|
return body; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.nonce = function () { |
|
|
|
return webFetch(nonceUrl, { mode: 'cors' }).then(function (resp) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
nonce = resp.headers.get('replay-nonce'); |
|
|
|
console.log('Nonce:', nonce); |
|
|
|
// resp.body is empty
|
|
|
|
return resp.headers.get('replay-nonce'); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.accounts = {}; |
|
|
|
BACME.accounts.generateKeypair = function () { |
|
|
|
// https://github.com/diafygi/webcrypto-examples#ecdsa---generatekey
|
|
|
|
var extractable = true; |
|
|
|
return webCrypto.subtle.generateKey( |
|
|
|
{ name: "ECDSA", namedCurve: "P-256" } |
|
|
|
, extractable |
|
|
|
, [ 'sign', 'verify' ] |
|
|
|
).then(function (result) { |
|
|
|
accountKeypair = result; |
|
|
|
|
|
|
|
return webCrypto.subtle.exportKey( |
|
|
|
"jwk" |
|
|
|
, result.privateKey |
|
|
|
).then(function (jwk) { |
|
|
|
|
|
|
|
accountJwk = jwk; |
|
|
|
console.log('private jwk:'); |
|
|
|
console.log(JSON.stringify(jwk, null, 2)); |
|
|
|
|
|
|
|
return webCrypto.subtle.exportKey( |
|
|
|
"pkcs8" |
|
|
|
, result.privateKey |
|
|
|
).then(function (keydata) { |
|
|
|
console.log('pkcs8:'); |
|
|
|
console.log(Array.from(new Uint8Array(keydata))); |
|
|
|
|
|
|
|
return accountKeypair; |
|
|
|
}); |
|
|
|
}) |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
// json to url-safe base64
|
|
|
|
BACME._jsto64 = function (json) { |
|
|
|
return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |
|
|
|
}; |
|
|
|
|
|
|
|
var textEncoder = new TextEncoder(); |
|
|
|
|
|
|
|
// email = john.doe@gmail.com
|
|
|
|
BACME.accounts.sign = function (email) { |
|
|
|
var payload64 = BACME._jsto64( |
|
|
|
{ termsOfServiceAgreed: true |
|
|
|
, onlyReturnExisting: false |
|
|
|
, contact: [ 'mailto:' + email ] |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
var protected64 = BACME._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
|
|
|
|
return 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)); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var account; |
|
|
|
var accountId; |
|
|
|
|
|
|
|
BACME.accounts.set = function () { |
|
|
|
nonce = null; |
|
|
|
return window.fetch(accountUrl, { |
|
|
|
mode: 'cors' |
|
|
|
, method: 'POST' |
|
|
|
, headers: { 'Content-Type': 'application/jose+json' } |
|
|
|
, body: JSON.stringify(signedAccount) |
|
|
|
}).then(function (resp) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
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) { |
|
|
|
BACME._logBody(result); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"; |
|
|
|
var signedOrder; |
|
|
|
|
|
|
|
BACME.orders = {}; |
|
|
|
|
|
|
|
// identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ]
|
|
|
|
BACME.orders.sign = function (identifiers) { |
|
|
|
var payload64 = jsto64({ identifiers: identifiers }); |
|
|
|
|
|
|
|
var protected64 = jsto64( |
|
|
|
{ nonce: nonce, alg: 'ES256', url: orderUrl, kid: accountId } |
|
|
|
); |
|
|
|
|
|
|
|
return 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(signedAccount, null, 2)); |
|
|
|
|
|
|
|
return signedOrder; |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var order; |
|
|
|
var currentOrderUrl; |
|
|
|
var authorizationUrls; |
|
|
|
var finalizeUrl; |
|
|
|
|
|
|
|
BACME.orders.create = function () { |
|
|
|
nonce = null; |
|
|
|
return 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)); |
|
|
|
|
|
|
|
return result; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.challenges = {}; |
|
|
|
BACME.challenges.view = function () { |
|
|
|
var authzUrl = authorizationUrls.pop(); |
|
|
|
var token; |
|
|
|
var challengeDomain; |
|
|
|
var challengeUrl; |
|
|
|
|
|
|
|
return window.fetch(authzUrl, { |
|
|
|
mode: 'cors' |
|
|
|
}).then(function (resp) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
BACME._logBody(result); |
|
|
|
|
|
|
|
return { token: challenge.token, url: challenge.url, domain: result.identifier.value, challenges: result.challenges }; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var thumbprint; |
|
|
|
var keyAuth; |
|
|
|
var httpPath; |
|
|
|
var dnsAuth; |
|
|
|
var dnsRecord; |
|
|
|
|
|
|
|
BACME.thumbprint = function () { |
|
|
|
// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk
|
|
|
|
|
|
|
|
var accountPublicStr = '{' + ['crv', 'kty', 'x', 'y'].map(function (key) { |
|
|
|
return '"' + key + '":"' + accountJwk[key] + '"'; |
|
|
|
}).join(',') + '}'; |
|
|
|
|
|
|
|
return window.crypto.subtle.digest( |
|
|
|
{ name: "SHA-256" } // SHA-256 is spec'd, non-optional
|
|
|
|
, 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); |
|
|
|
|
|
|
|
return thumbprint; |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.challenges['http-01'] = function () { |
|
|
|
// The contents of the key authorization file
|
|
|
|
keyAuth = token + '.' + thumbprint; |
|
|
|
|
|
|
|
// Where the key authorization file goes
|
|
|
|
httpPath = 'http://' + challengeDomain + '/.well-known/acme-challenge/' + token; |
|
|
|
|
|
|
|
console.log("echo '" + keyAuth + "' > '" + httpPath + "'"); |
|
|
|
|
|
|
|
return { |
|
|
|
path: httpPath |
|
|
|
, value: keyAuth |
|
|
|
}; |
|
|
|
}); |
|
|
|
BACME.challenges['dns-01'] = function () { |
|
|
|
return 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); |
|
|
|
|
|
|
|
return { |
|
|
|
type: 'TXT' |
|
|
|
, host: dnsRecord |
|
|
|
, answer: dnsAuth; |
|
|
|
}; |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var challengePollUrl; |
|
|
|
|
|
|
|
BACME.challenges.accept = function () { |
|
|
|
var payload64 = jsto64( |
|
|
|
{} |
|
|
|
); |
|
|
|
|
|
|
|
var protected64 = jsto64( |
|
|
|
{ nonce: nonce, alg: 'ES256', url: challengeUrl, kid: accountId } |
|
|
|
); |
|
|
|
|
|
|
|
nonce = null; |
|
|
|
return 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)); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.challenges.check = function () { |
|
|
|
return window.fetch(challengePollUrl, { mode: 'cors' }).then(function (resp) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
nonce = resp.headers.get('replay-nonce'); |
|
|
|
|
|
|
|
return resp.json().then(function (reply) { |
|
|
|
challengePollUrl = reply.url; |
|
|
|
|
|
|
|
BACME._logBody(reply); |
|
|
|
|
|
|
|
return reply; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
var domainKeypair; |
|
|
|
var domainJwk; |
|
|
|
|
|
|
|
BACME.domains = {}; |
|
|
|
// TODO factor out from BACME.accounts.generateKeypair
|
|
|
|
BACME.domains.generateKeypair = function () { |
|
|
|
var extractable = true; |
|
|
|
return 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)); |
|
|
|
|
|
|
|
return domainKeypair; |
|
|
|
}) |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
BACME.order.generateCsr = function (keypair, domains) { |
|
|
|
return Promise.resolve(CSR.generate(keypair, domains)); |
|
|
|
}; |
|
|
|
|
|
|
|
var certificateUrl; |
|
|
|
|
|
|
|
BACME.order.finalize = function () { |
|
|
|
var payload64 = jsto64( |
|
|
|
{ csr: csr } |
|
|
|
); |
|
|
|
|
|
|
|
var protected64 = jsto64( |
|
|
|
{ nonce: nonce, alg: 'ES256', url: finalizeUrl, kid: accountId } |
|
|
|
); |
|
|
|
|
|
|
|
nonce = null; |
|
|
|
return 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) { |
|
|
|
BACME._logHeaders(resp); |
|
|
|
nonce = resp.headers.get('replay-nonce'); |
|
|
|
|
|
|
|
return resp.json().then(function (reply) { |
|
|
|
certificateUrl = reply.certificate; |
|
|
|
BACME._logBody(reply); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
}(window)); |