first version

This commit is contained in:
Philipp Kunz 2016-11-01 18:27:57 +01:00
commit 904cf09788
23 changed files with 2846 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
coverage/
public/
pages/

59
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,59 @@
image: hosttoday/ht-docker-node:npmts
stages:
- test
- release
- trigger
- pages
testLEGACY:
stage: test
script:
- npmci test legacy
tags:
- docker
allow_failure: true
testLTS:
stage: test
script:
- npmci test lts
tags:
- docker
testSTABLE:
stage: test
script:
- npmci test stable
tags:
- docker
release:
stage: release
script:
- npmci publish
only:
- tags
tags:
- docker
trigger:
stage: trigger
script:
- npmci trigger
only:
- tags
tags:
- docker
pages:
image: hosttoday/ht-docker-node:npmpage
stage: pages
script:
- npmci command npmpage --host gitlab
only:
- tags
artifacts:
expire_in: 1 week
paths:
- public

20
LICENSE Normal file
View File

@ -0,0 +1,20 @@
Copyright (C) 2016, Lossless GmbH
Copyright (C) 2016, Martin Springwald
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
dist/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from './smartacme.classes.smartacme';

6
dist/index.js vendored Normal file
View File

@ -0,0 +1,6 @@
"use strict";
function __export(m) {
for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
}
__export(require("./smartacme.classes.smartacme"));
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7O0FBQUEsbURBQTZDIn0=

195
dist/smartacme.classes.acmeclient.d.ts vendored Normal file
View File

@ -0,0 +1,195 @@
/**
* @class AcmeClient
* @constructor
* @description ACME protocol implementation from client perspective
* @param {string} directory_url - Address of directory
* @param {module:JWebClient~JWebClient} jWebClient - Reference to JSON-Web-Client
*/
export declare class AcmeClient {
clientProfilePubKey: any;
days_valid: number;
defaultRsaKeySize: number;
directory: any;
directoryUrl: string;
emailDefaultPrefix: string;
emailOverride: string;
jWebClient: any;
regLink: string;
tosLink: string;
webroot: string;
well_known_path: string;
withInteraction: boolean;
constructor(directoryUrlArg: any);
/**
* getDirectory
* @description retrieve directory entries (directory url must be set prior to execution)
* @param {function} callback - first argument will be the answer object
*/
getDirectory(callback: any): void;
/**
* newRegistration
* @description try to register (directory lookup must have occured prior to execution)
* @param {Object} payload
* @param {function} callback - first argument will be the answer object
*/
newRegistration(payload: any, callback: any): void;
/**
* getRegistration
* @description get information about registration
* @param {string} uri - will be exposed when trying to register
* @param {Object} payload - update information
* @param {function} callback - first argument will be the answer object
*/
getRegistration(uri: any, payload: any, callback: any): void;
/**
* authorizeDomain
* @description authorize domain using challenge-response-method
* @param {string} domain
* @param {function} callback - first argument will be the answer object
*/
authorizeDomain(domain: any, callback: any): void;
/**
* acceptChallenge
* @description tell server which challenge will be accepted
* @param {Object} challenge
* @param {function} callback - first argument will be the answer object
*/
acceptChallenge(challenge: any, callback: any): void;
/**
* pollUntilValid
* @description periodically (with exponential back-off) check status of challenge
* @param {string} uri
* @param {function} callback - first argument will be the answer object
* @param {number} retry - factor of delay
*/
pollUntilValid(uri: any, callback: any, retry?: number): void;
/**
* pollUntilIssued
* @description periodically (with exponential back-off) check status of CSR
* @param {string} uri
* @param {function} callback - first argument will be the answer object
* @param {number} retry - factor of delay
*/
pollUntilIssued(uri: any, callback: any, retry?: number): void;
/**
* requestSigning
* @description send CSR
* @param {string} domain - expected to be already sanitized
* @param {function} callback - first argument will be the answer object
*/
requestSigning(domain: any, callback: any): void;
/**
* getProfile
* @description retrieve profile of user (will make directory lookup and registration check)
* @param {function} callback - first argument will be the answer object
*/
getProfile(callback: any): void;
/**
* createAccount
* @description create new account (assumes directory lookup has already occured)
* @param {string} email
* @param {function} callback - first argument will be the registration URI
*/
createAccount(email: any, callback: any): void;
/**
* agreeTos
* @description agree with terms of service (update agreement status in profile)
* @param {string} tosLink
* @param {function} callback - first argument will be the answer object
*/
agreeTos(tosLink: any, callback: any): void;
/**
* Entry-Point: Request certificate
* @param {string} domain
* @param {string} organization
* @param {string} country
* @param {function} callback
*/
requestCertificate(domain: any, organization: any, country: any, callback: any): void;
/**
* External: Create key pair
* @param {number} bit - key strength, expected to be already sanitized
* @param {string} c - country code, expected to be already sanitized
* @param {string} o - organization, expected to be already sanitized
* @param {string} cn - common name (domain name), expected to be already sanitized
* @param {string} e - email address, expected to be already sanitized
* @param {function} callback
*/
createKeyPair(bit: any, c: any, o: any, cn: any, e: any, callback: any): void;
/**
* Helper: Empty callback
*/
emptyCallback(): void;
/**
* Helper: Make safe file name or path from string
* @param {string} name
* @param {boolean} withPath - optional, default false
* @return {string}
*/
makeSafeFileName(name: any, withPath?: boolean): any;
/**
* Helper: Prepare challenge
* @param {string} domain
* @param {Object} challenge
* @param {function} callback
*/
prepareChallenge(domain: any, challenge: any, callback: any): void;
/**
* Helper: Extract TOS Link, e.g. from "<http://...>;rel="terms-of-service"
* @param {string} linkStr
* @return {string}
*/
getTosLink(linkStr: any): string;
/**
* Helper: Select challenge by type
* @param {Object} ans
* @param {string} challenge_type
* @return {Object}
*/
selectChallenge(ans: any, challengeType: string): any;
/**
* Helper: Extract first found email from profile (without mailto prefix)
* @param {Object} profile
* @return {string}
*/
extractEmail(profile: any): string;
/**
* Make ACME-Request: Domain-Authorization Request - Object: resource, identifier
* @param {string} domain
* @return {{resource: string, identifier: Object}}
*/
makeDomainAuthorizationRequest(domain: any): {
'resource': string;
'identifier': {
'type': string;
'value': any;
};
};
/**
* Make ACME-Object: Key-Authorization (encoded) - String: Challenge-Token . Encoded-Account-Key-Hash
* @param {Object} challenge
* @return {string}
*/
makeKeyAuthorization(challenge: any): string;
/**
* Make ACME-Request: Challenge-Response - Object: resource, keyAuthorization
* @param {Object} challenge
* @return {{resource: string, keyAuthorization: string}}
*/
makeChallengeResponse(challenge: any): {
'resource': string;
'keyAuthorization': string;
};
/**
* Make ACME-Request: CSR - Object: resource, csr, notBefore, notAfter
* @param {string} csr
* @param {number} days_valid
* @return {{resource: string, csr: string, notBefore: string, notAfter: string}}
*/
makeCertRequest(csr: any, DAYS_VALID: number): {
'resource': string;
'csr': string;
'notBefore': string;
'notAfter': string;
};
}

903
dist/smartacme.classes.acmeclient.js vendored Normal file

File diff suppressed because one or more lines are too long

61
dist/smartacme.classes.jwebclient.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
/**
* @class JWebClient
* @constructor
* @description Implementation of HTTPS-based JSON-Web-Client
*/
export declare class JWebClient {
key_pair: any;
last_nonce: string;
verbose: boolean;
constructor();
/**
* createJWT
* @description create JSON-Web-Token signed object
* @param {string|undefined} nonce
* @param {Object|string|number|boolean} payload
* @param {string} alg
* @param {Object|string} key
* @param {Object} jwk
* @return {string}
*/
createJWT(nonce: any, payload: any, alg: any, key: any, jwk: any): string;
/**
* request
* @description make GET or POST request over HTTPS and use JOSE as payload type
* @param {string} query
* @param {string} payload
* @param {function} callback
* @param {function} errorCallback
*/
request(query: any, payload: any, callback: any, errorCallback: any): void;
/**
* get
* @description make GET request
* @param {string} uri
* @param {function} callback
* @param {function} errorCallback
*/
get(uri: any, callback: any, errorCallback: any): void;
/**
* post
* @description make POST request
* @param {string} uri
* @param {Object|string|number|boolean} payload
* @param {function} callback
* @param {function} errorCallback
*/
post(uri: any, payload: any, callback: any, errorCallback: any): void;
/**
* evaluateStatus
* @description check if status is expected and log errors
* @param {string} uri
* @param {Object|string|number|boolean} payload
* @param {Object|string} ans
* @param {Object} res
*/
evaluateStatus(uri: any, payload: any, ans: any, res: any): void;
/**
* Helper: Empty callback
*/
emptyCallback(): void;
}

283
dist/smartacme.classes.jwebclient.js vendored Normal file

File diff suppressed because one or more lines are too long

5
dist/smartacme.classes.smartacme.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import * as acmeclient from './smartacme.classes.acmeclient';
export declare class SmartAcme {
acmeClient: acmeclient.AcmeClient;
constructor(directoryUrlArg?: string);
}

9
dist/smartacme.classes.smartacme.js vendored Normal file
View File

@ -0,0 +1,9 @@
"use strict";
const acmeclient = require("./smartacme.classes.acmeclient");
class SmartAcme {
constructor(directoryUrlArg = 'https://acme-staging.api.letsencrypt.org/directory') {
this.acmeClient = new acmeclient.AcmeClient(directoryUrlArg);
}
}
exports.SmartAcme = SmartAcme;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRhY21lLmNsYXNzZXMuc21hcnRhY21lLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvc21hcnRhY21lLmNsYXNzZXMuc21hcnRhY21lLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7QUFDQSw2REFBNEQ7QUFFNUQ7SUFFSSxZQUFZLGtCQUEwQixvREFBb0Q7UUFDdEYsSUFBSSxDQUFDLFVBQVUsR0FBRyxJQUFJLFVBQVUsQ0FBQyxVQUFVLENBQUMsZUFBZSxDQUFDLENBQUE7SUFDaEUsQ0FBQztDQUNKO0FBTEQsOEJBS0MifQ==

3
dist/smartacme.plugins.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import 'typings-global';
import * as path from 'path';
export { path };

5
dist/smartacme.plugins.js vendored Normal file
View File

@ -0,0 +1,5 @@
"use strict";
require("typings-global");
const path = require("path");
exports.path = path;
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic21hcnRhY21lLnBsdWdpbnMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9zbWFydGFjbWUucGx1Z2lucy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQUEsMEJBQXVCO0FBQ3ZCLDZCQUE0QjtBQUd4QixvQkFBSSJ9

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "smartacme",
"version": "1.0.0",
"description": "acme implementation in TypeScript",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"test": "(npmts --nodocs)"
},
"repository": {
"type": "git",
"url": "git+ssh://git@gitlab.com/pushrocks/smartacme.git"
},
"keywords": [
"TypeScript",
"acme",
"letsencrypt"
],
"author": "Lossless GmbH",
"license": "MIT",
"bugs": {
"url": "https://gitlab.com/pushrocks/smartacme/issues"
},
"homepage": "https://gitlab.com/pushrocks/smartacme#README",
"dependencies": {
"@types/base64url": "^2.0.3",
"base64url": "^2.0.0",
"jwa": "^1.1.3",
"rsa-pem-to-jwk": "^1.1.3",
"smartstring": "^2.0.19",
"typings-global": "^1.0.14"
},
"devDependencies": {
"@types/should": "^8.1.30",
"should": "^11.1.1",
"typings-test": "^1.0.3"
}
}

1
test/test.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import 'typings-test';

7
test/test.js Normal file
View File

@ -0,0 +1,7 @@
"use strict";
require("typings-test");
describe('smartacme', function () {
let testAcme;
it('should create a valid instance');
});
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVzdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInRlc3QudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBLHdCQUFxQjtBQU1yQixRQUFRLENBQUMsV0FBVyxFQUFFO0lBQ2xCLElBQUksUUFBNkIsQ0FBQTtJQUNqQyxFQUFFLENBQUMsZ0NBQWdDLENBQUMsQ0FBQTtBQUN4QyxDQUFDLENBQUMsQ0FBQSJ9

10
test/test.ts Normal file
View File

@ -0,0 +1,10 @@
import 'typings-test'
import * as should from 'should'
// import the module to test
import * as smartacme from '../dist/index'
describe('smartacme', function(){
let testAcme: smartacme.smartacme
it('should create a valid instance')
})

1
ts/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './smartacme.classes.smartacme'

View File

@ -0,0 +1,923 @@
import * as plugins from './smartacme.plugins'
import * as base64url from 'base64url'
import * as child_process from 'child_process'
import * as crypto from 'crypto'
import * as fs from 'fs'
import * as readline from 'readline'
import { JWebClient } from './smartacme.classes.jwebclient'
/**
* json_to_utf8buffer
* @private
* @description convert JSON to Buffer using UTF-8 encoding
* @param {Object} obj
* @return {Buffer}
* @throws Exception if object cannot be stringified or contains cycle
*/
let json_to_utf8buffer = function (obj) {
return new Buffer(JSON.stringify(obj), 'utf8')
}
/**
* @class AcmeClient
* @constructor
* @description ACME protocol implementation from client perspective
* @param {string} directory_url - Address of directory
* @param {module:JWebClient~JWebClient} jWebClient - Reference to JSON-Web-Client
*/
export class AcmeClient {
clientProfilePubKey: any
days_valid: number
defaultRsaKeySize: number
directory: any
directoryUrl: string
emailDefaultPrefix: string
emailOverride: string
jWebClient: any
regLink: string
tosLink: string
webroot: string
well_known_path: string
withInteraction: boolean
constructor(directoryUrlArg) {
/**
* @member {Object} module:AcmeClient~AcmeClient#clientProfilePubKey
* @desc Cached public key obtained from profile
*/
this.clientProfilePubKey = {}
/**
* @member {number} module:AcmeClient~AcmeClient#days_valid
* @desc Validity period in days
* @default 1
*/
this.days_valid = 1
/**
* @member {number} module:AcmeClient~AcmeClient#defaultRsaKeySize
* @desc Key strength in bits
* @default 4096
*/
this.defaultRsaKeySize = 4096
/**
* @member {Object} module:AcmeClient~AcmeClient#directory
* @desc Hash map of REST URIs
*/
this.directory = {}
/**
* @member {string} module:AcmeClient~AcmeClient#directory_url
* @desc Address of directory
*/
this.directoryUrl = directoryUrlArg
/**
* @member {string} module:AcmeClient~AcmeClient#emailDefaultPrefix
* @desc Prefix of email address if constructed from domain name
* @default "hostmaster"
*/
this.emailDefaultPrefix = 'hostmaster' // {string}
/**
* @member {string} module:AcmeClient~AcmeClient#emailOverride
* @desc Email address to use
*/
this.emailOverride = null // {string}
/**
* @member {module:JWebClient~JWebClient} module:AcmeClient~AcmeClient#jWebClient
* @desc Reference to JSON-Web-Client
*/
this.jWebClient = new JWebClient() // {JWebClient}
/**
* @member {string} module:AcmeClient~AcmeClient#regLink
* @desc Cached registration URI
*/
this.regLink = null // {string}
/**
* @member {string} module:AcmeClient~AcmeClient#tosLink
* @desc Cached terms of service URI
*/
this.tosLink = null // {string}
/**
* @member {string} module:AcmeClient~AcmeClient#webroot
* @desc Path to server web root (or path to store challenge data)
* @default "."
*/
this.webroot = '.' // {string}
/**
* @member {string} module:AcmeClient~AcmeClient#well_known_path
* @desc Directory structure for challenge data
* @default "/.well-known/acme-challenge/"
*/
this.well_known_path = '/.well-known/acme-challenge/' // {string}
/**
* @member {boolean} module:AcmeClient~AcmeClient#withInteraction
* @desc Determines if interaction of user is required
* @default true
*/
this.withInteraction = true // {boolean}
}
// *****************************************************************************
// REQUEST-Section
// *****************************************************************************
/**
* getDirectory
* @description retrieve directory entries (directory url must be set prior to execution)
* @param {function} callback - first argument will be the answer object
*/
getDirectory(callback) {
this.jWebClient.get(this.directoryUrl, callback, callback)
// dereference
callback = null
}
/**
* newRegistration
* @description try to register (directory lookup must have occured prior to execution)
* @param {Object} payload
* @param {function} callback - first argument will be the answer object
*/
newRegistration(payload, callback) {
if (!(payload instanceof Object)) {
payload = {} // ensure payload is object
}
payload.resource = 'new-reg'
this.jWebClient.post(this.directory['new-reg'], payload, callback, callback)
// dereference
callback = null
payload = null
}
/**
* getRegistration
* @description get information about registration
* @param {string} uri - will be exposed when trying to register
* @param {Object} payload - update information
* @param {function} callback - first argument will be the answer object
*/
getRegistration(uri, payload, callback) {
/*jshint -W069 */
let ctx = this
if (!(payload instanceof Object)) {
payload = {} // ensure payload is object
}
payload['resource'] = 'reg'
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
this.jWebClient.post(uri, payload, function (ans, res) {
if (ans instanceof Object) {
ctx.clientProfilePubKey = ans.key // cache or reset returned public key
if ((res instanceof Object) && (res['headers'] instanceof Object)) {
let linkStr = res.headers['link']
if (typeof linkStr === 'string') {
let tosLink = ctx.getTosLink(linkStr)
if (typeof tosLink === 'string') {
ctx.tosLink = tosLink // cache TOS link
} else {
ctx.tosLink = null // reset TOS link
}
} else {
ctx.tosLink = null // reset TOS link
}
} else {
ctx.tosLink = null // reset TOS link
}
callback(ans, res)
} else {
callback(false)
}
// dereference
ans = null
callback = null
ctx = null
res = null
})
// dereference
payload = null
}
/**
* authorizeDomain
* @description authorize domain using challenge-response-method
* @param {string} domain
* @param {function} callback - first argument will be the answer object
*/
authorizeDomain(domain, callback) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
this.getProfile(function (profile) {
if (!(profile instanceof Object)) {
callback(false) // no profile returned
// dereference
callback = null
ctx = null
} else {
ctx.jWebClient.post(ctx.directory['new-authz'], ctx.makeDomainAuthorizationRequest(domain), function (ans, res) {
if ((res instanceof Object) && (res['statusCode'] === 403)) { // if unauthorized
ctx.agreeTos(ctx.tosLink, function (ans_, res_) { // agree to TOS
if ( // if TOS were agreed successfully
(res_ instanceof Object)
&& (res_['statusCode'] >= 200)
&& (res_['statusCode'] <= 400)
) {
ctx.authorizeDomain(domain, callback) // try authorization again
} else {
callback(false) // agreement failed
}
// dereference
ans = null
ans_ = null
callback = null
ctx = null
profile = null
res = null
res_ = null
})
} else {
if (
(res instanceof Object)
&& (res['headers'] instanceof Object)
&& (typeof res.headers['location'] === 'string')
&& (ans instanceof Object)
) {
let poll_uri = res.headers['location'] // status URI for polling
let challenge = ctx.selectChallenge(ans, 'http-01') // select simple http challenge
if (challenge instanceof Object) { // desired challenge is in list
ctx.prepareChallenge(domain, challenge, function () { // prepare all objects and files for challenge
// reset
ans = null
res = null
// accept challenge
ctx.acceptChallenge(challenge, function (ans, res) {
if (
(res instanceof Object)
&& (res['statusCode'] < 400) // server confirms challenge acceptance
) {
ctx.pollUntilValid(poll_uri, callback) // poll status until server states success
} else {
callback(false) // server did not confirm challenge acceptance
}
// dereference
ans = null
callback = null
challenge = null
ctx = null
profile = null
res = null
})
})
} else {
callback(false) // desired challenge is not in list
// dereference
ans = null
callback = null
ctx = null
profile = null
res = null
}
} else {
callback(false) // server did not respond with status URI
// dereference
ans = null
callback = null
ctx = null
profile = null
res = null
}
}
})
}
})
}
/**
* acceptChallenge
* @description tell server which challenge will be accepted
* @param {Object} challenge
* @param {function} callback - first argument will be the answer object
*/
acceptChallenge(challenge, callback) {
/*jshint -W069 */
if (!(challenge instanceof Object)) {
challenge = {} // ensure challenge is object
}
this.jWebClient.post(challenge['uri'], this.makeChallengeResponse(challenge), callback)
// dereference
callback = null
challenge = null
}
/**
* pollUntilValid
* @description periodically (with exponential back-off) check status of challenge
* @param {string} uri
* @param {function} callback - first argument will be the answer object
* @param {number} retry - factor of delay
*/
pollUntilValid(uri, callback, retry = 1) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
if (retry > 128) {
callback(false) // stop if retry value exceeds maximum
} else {
this.jWebClient.get(uri, function (ans, res) {
if (!(ans instanceof Object)) {
callback(false) // invalid answer
// dereference
callback = null
ctx = null
res = null
} else {
if (ans['status'] === 'pending') { // still pending
setTimeout(function () {
ctx.pollUntilValid(uri, callback, retry * 2) // retry
// dereference
ans = null
callback = null
ctx = null
res = null
}, retry * 500)
} else {
callback(ans, res) // challenge complete
// dereference
ans = null
callback = null
ctx = null
res = null
}
}
})
}
}
/**
* pollUntilIssued
* @description periodically (with exponential back-off) check status of CSR
* @param {string} uri
* @param {function} callback - first argument will be the answer object
* @param {number} retry - factor of delay
*/
pollUntilIssued(uri, callback, retry = 1) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
if (retry > 128) {
callback(false) // stop if retry value exceeds maximum
} else {
this.jWebClient.get(uri, function (ans, res) {
if ((ans instanceof Buffer) && (ans.length > 0)) {
callback(ans) // certificate was returned with answer
// dereference
ans = null
callback = null
ctx = null
res = null
} else {
if ((res instanceof Object) && (res['statusCode'] < 400)) { // still pending
setTimeout(function () {
ctx.pollUntilIssued(uri, callback, retry * 2) // retry
// dereference
ans = null
callback = null
ctx = null
res = null
}, retry * 500)
} else {
callback(false) // CSR complete
// dereference
ans = null
callback = null
ctx = null
res = null
}
}
})
}
}
/**
* requestSigning
* @description send CSR
* @param {string} domain - expected to be already sanitized
* @param {function} callback - first argument will be the answer object
*/
requestSigning(domain, callback) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
fs.readFile(domain + '.csr', function (err, csr) {
if (err instanceof Object) { // file system error
if (ctx.jWebClient.verbose) {
console.error('Error : File system error', err['code'], 'while reading key from file')
}
callback(false)
// dereference
callback = null
csr = null
ctx = null
err = null
} else {
ctx.jWebClient.post(ctx.directory['new-cert'], ctx.makeCertRequest(csr, ctx.days_valid), function (ans, res) {
if ((ans instanceof Buffer) && (ans.length > 0)) { // answer is buffer
callback(ans) // certificate was returned with answer
// dereference
ans = null
callback = null
csr = null
ctx = null
err = null
res = null
} else {
if (res instanceof Object) {
if ((res['statusCode'] < 400) && !ans) { // success response, but no answer was provided
let headers = res['headers']
if (!(headers instanceof Object)) {
headers = {} // ensure headers is object
}
ctx.pollUntilIssued(headers['location'], callback) // poll provided status URI
// dereference
headers = null
} else {
callback((res['statusCode'] < 400) ? ans : false) // answer may be provided as string or object
}
} else {
callback(false) // invalid response
}
// dereference
ans = null
callback = null
csr = null
ctx = null
err = null
res = null
}
})
}
})
}
/**
* getProfile
* @description retrieve profile of user (will make directory lookup and registration check)
* @param {function} callback - first argument will be the answer object
*/
getProfile(callback) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
this.getDirectory(function (dir) {
if (!(dir instanceof Object)) {
callback(false) // server did not respond with directory
// dereference
callback = null
ctx = null
} else {
ctx.directory = dir // cache directory
ctx.newRegistration(null, function (ans, res) { // try new registration to get registration link
if (
(res instanceof Object)
&& (res['headers'] instanceof Object)
&& (typeof res.headers['location'] === 'string')
) {
ctx.regLink = res.headers['location']
ctx.getRegistration(ctx.regLink, null, callback) // get registration info from link
} else {
callback(false) // registration failed
}
// dereference
ans = null
callback = null
ctx = null
dir = null
res = null
})
}
})
}
/**
* createAccount
* @description create new account (assumes directory lookup has already occured)
* @param {string} email
* @param {function} callback - first argument will be the registration URI
*/
createAccount(email, callback) {
/*jshint -W069 */
let ctx = this
if (typeof email === 'string') {
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
ctx.newRegistration(
{
contact: [
'mailto:' + email
]
},
function (ans, res) {
if (
(res instanceof Object)
&& (res['statusCode'] === 201)
&& (res['headers'] instanceof Object)
&& (typeof res.headers['location'] === 'string')
) {
ctx.regLink = res.headers['location']
callback(ctx.regLink) // registration URI
} else {
callback(false) // registration failed
}
// dereference
ans = null
callback = null
ctx = null
res = null
})
} else {
callback(false) // no email address provided
// dereference
callback = null
ctx = null
}
}
/**
* agreeTos
* @description agree with terms of service (update agreement status in profile)
* @param {string} tosLink
* @param {function} callback - first argument will be the answer object
*/
agreeTos(tosLink, callback) {
this.getRegistration(this.regLink, {
'Agreement': tosLink // terms of service URI
}, callback)
// dereference
callback = null
}
/**
* Entry-Point: Request certificate
* @param {string} domain
* @param {string} organization
* @param {string} country
* @param {function} callback
*/
requestCertificate(domain, organization, country, callback) {
/*jshint -W069 */
let ctx = this
if (typeof domain !== 'string') {
domain = '' // ensure domain is string
}
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
this.getProfile(function (profile) {
let email = ctx.extractEmail(profile) // try to determine email address from profile
if (typeof ctx.emailOverride === 'string') {
email = ctx.emailOverride // override email address if set
} else if (typeof email !== 'string') {
email = ctx.emailDefaultPrefix + '@' + domain // or set default
}
let bit = ctx.defaultRsaKeySize
// sanitize
bit = Number(bit)
country = ctx.makeSafeFileName(country)
domain = ctx.makeSafeFileName(domain)
email = ctx.makeSafeFileName(email)
organization = ctx.makeSafeFileName(organization)
// create key pair
ctx.createKeyPair(bit, country, organization, domain, email, function (e) { // create key pair
if (!e) {
ctx.requestSigning(domain, function (cert) { // send CSR
if ((cert instanceof Buffer) || (typeof cert === 'string')) { // valid certificate data
fs.writeFile(domain + '.der', cert, function (err) { // sanitize domain name for file path
if (err instanceof Object) { // file system error
if (ctx.jWebClient.verbose) {
console.error('Error : File system error', err['code'], 'while writing certificate to file')
}
callback(false)
} else {
callback(true) // CSR complete and certificate written to file system
}
// dereference
callback = null
cert = null
ctx = null
e = null
err = null
profile = null
})
} else {
callback(false) // invalid certificate data
// dereference
callback = null
cert = null
ctx = null
e = null
profile = null
}
})
} else {
callback(false) // could not create key pair
// dereference
callback = null
ctx = null
e = null
profile = null
}
})
})
}
/**
* External: Create key pair
* @param {number} bit - key strength, expected to be already sanitized
* @param {string} c - country code, expected to be already sanitized
* @param {string} o - organization, expected to be already sanitized
* @param {string} cn - common name (domain name), expected to be already sanitized
* @param {string} e - email address, expected to be already sanitized
* @param {function} callback
*/
createKeyPair(bit, c, o, cn, e, callback) {
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
let openssl = `openssl req -new -nodes -newkey rsa:${bit} -sha256 -subj "/C=${c}/O=${o}/CN=${cn}/emailAddress=${e}" -keyout \"${cn}.key\" -outform der -out \"${cn}.csr\"`
console.error('Action : Creating key pair')
if (this.jWebClient.verbose) {
console.error('Running:', openssl)
}
child_process.exec(openssl, function (e) {
if (!e) {
console.error('Result : done')
} else {
console.error('Result : failed')
}
callback(e)
// dereference
callback = null
e = null
}
)
}
/**
* Helper: Empty callback
*/
emptyCallback() {
// nop
}
/**
* Helper: Make safe file name or path from string
* @param {string} name
* @param {boolean} withPath - optional, default false
* @return {string}
*/
makeSafeFileName(name, withPath = false) {
if (typeof name !== 'string') {
name = ''
}
// respects file name restrictions for ntfs and ext2
let regex_file = '[<>:\"/\\\\\\|\\?\\*\\u0000-\\u001f\\u007f\\u0080-\\u009f]'
let regex_path = '[<>:\"\\\\\\|\\?\\*\\u0000-\\u001f\\u007f\\u0080-\\u009f]'
return name.replace(new RegExp(withPath ? regex_path : regex_file, 'g'), function (charToReplace) {
if (typeof charToReplace === 'string') {
return '%' + charToReplace.charCodeAt(0).toString(16).toLocaleUpperCase()
}
return '%00'
})
}
/**
* Helper: Prepare challenge
* @param {string} domain
* @param {Object} challenge
* @param {function} callback
*/
prepareChallenge(domain, challenge, callback) {
/*jshint -W069, unused:false*/
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
if (challenge instanceof Object) {
if (challenge['type'] === 'http-01') { // simple http challenge
let path = this.webroot + this.well_known_path + challenge['token'] // webroot and well_known_path are expected to be already sanitized
fs.writeFile(path, this.makeKeyAuthorization(challenge), function (err) { // create challenge file
if (err instanceof Object) { // file system error
if (ctx.jWebClient.verbose) {
console.error(
'Error : File system error',
err['code'], 'while writing challenge data to file'
)
}
callback()
// dereference
callback = null
challenge = null
ctx = null
err = null
} else {
// let uri = "http://" + domain + this.well_known_path + challenge["token"]
let rl = readline.createInterface(process.stdin, process.stdout)
if (ctx.withInteraction) {
rl.question('Press enter to proceed', function (answer) { // wait for user to proceed
rl.close()
callback()
// dereference
callback = null
challenge = null
ctx = null
rl = null
})
} else {
rl.close()
callback() // skip interaction prompt if desired
// dereference
callback = null
challenge = null
ctx = null
rl = null
}
}
})
} else { // no supported challenge
console.error('Error : Challenge not supported')
callback()
// dereference
callback = null
challenge = null
ctx = null
}
} else { // invalid challenge response
console.error('Error : Invalid challenge response')
callback()
// dereference
callback = null
challenge = null
ctx = null
}
}
/**
* Helper: Extract TOS Link, e.g. from "&lt;http://...&gt;;rel="terms-of-service"
* @param {string} linkStr
* @return {string}
*/
getTosLink(linkStr) {
let match = /(<)([^>]+)(>;rel="terms-of-service")/g.exec(linkStr)
if ((match instanceof Array) && (match.length > 2)) {
let result = match[2]
// dereference
match = null
return result
}
// dereference
match = null
return void 0
}
/**
* Helper: Select challenge by type
* @param {Object} ans
* @param {string} challenge_type
* @return {Object}
*/
selectChallenge(ans, challengeType: string) {
/*jshint -W069 */
if ((ans instanceof Object) && (ans['challenges'] instanceof Array)) {
return ans.challenges.filter(function (entry) {
let type = entry['type']
// dereference
entry = null
if (type === challengeType) { // check for type match
return true
}
return false
}).pop()
} // return first match or undefined
// dereference
ans = null
return void 0 // challenges not available or in expected format
}
/**
* Helper: Extract first found email from profile (without mailto prefix)
* @param {Object} profile
* @return {string}
*/
extractEmail(profile) {
/*jshint -W069 */
if (!(profile instanceof Object) || !(profile['contact'] instanceof Array)) {
// dereference
profile = null
return void 0 // invalid profile
}
let prefix = 'mailto:'
let email = profile.contact.filter(function (entry) {
if (typeof entry !== 'string') {
return false
} else {
return !entry.indexOf(prefix) // check for mail prefix
}
}
).pop()
// dereference
profile = null
if (typeof email !== 'string') {
return void 0
} // return default
return email.substr(prefix.length) // only return email address without protocol prefix
}
/**
* Make ACME-Request: Domain-Authorization Request - Object: resource, identifier
* @param {string} domain
* @return {{resource: string, identifier: Object}}
*/
makeDomainAuthorizationRequest(domain) {
return {
'resource': 'new-authz',
'identifier': {
'type': 'dns',
'value': domain
}
}
}
/**
* Make ACME-Object: Key-Authorization (encoded) - String: Challenge-Token . Encoded-Account-Key-Hash
* @param {Object} challenge
* @return {string}
*/
makeKeyAuthorization(challenge) {
/*jshint -W069 */
if (challenge instanceof Object) {
if (this.clientProfilePubKey instanceof Object) {
let jwk = json_to_utf8buffer({
e: this.clientProfilePubKey['e'],
kty: this.clientProfilePubKey['kty'],
n: this.clientProfilePubKey['n']
}
)
let hash = crypto.createHash('sha256').update(jwk.toString('utf8'), 'utf8').digest()
let ACCOUNT_KEY = base64url.default.encode(hash) // create base64 encoded hash of account key
let token = challenge['token']
// dereference
challenge = null
jwk = null
return token + '.' + ACCOUNT_KEY
}
} else {
return '' // return default (for writing to file)
}
}
/**
* Make ACME-Request: Challenge-Response - Object: resource, keyAuthorization
* @param {Object} challenge
* @return {{resource: string, keyAuthorization: string}}
*/
makeChallengeResponse(challenge) {
return {
'resource': 'challenge',
'keyAuthorization': this.makeKeyAuthorization(challenge)
}
}
/**
* Make ACME-Request: CSR - Object: resource, csr, notBefore, notAfter
* @param {string} csr
* @param {number} days_valid
* @return {{resource: string, csr: string, notBefore: string, notAfter: string}}
*/
makeCertRequest(csr, DAYS_VALID: number) {
if (typeof csr !== 'string' && !(csr instanceof Buffer)) {
csr = '' // default string for CSR
}
if ((typeof DAYS_VALID !== 'number') || (isNaN(DAYS_VALID)) || (DAYS_VALID === 0)) {
DAYS_VALID = 1 // default validity duration (1 day)
}
let DOMAIN_CSR_DER = base64url.default.encode(csr) // create base64 encoded CSR
let CURRENT_DATE = (new Date()).toISOString() // set start date to current date
// set end date to current date + days_valid
let NOTAFTER_DATE = (new Date((+new Date()) + 1000 * 60 * 60 * 24 * Math.abs(DAYS_VALID))).toISOString()
return {
'resource': 'new-cert',
'csr': DOMAIN_CSR_DER,
'notBefore': CURRENT_DATE,
'notAfter': NOTAFTER_DATE
}
}
}

View File

@ -0,0 +1,294 @@
import * as plugins from './smartacme.plugins'
import * as base64url from 'base64url'
import * as https from 'https'
let jwa = require('jwa')
import * as url from 'url'
/**
* json_to_utf8base64url
* @private
* @description convert JSON to base64-url encoded string using UTF-8 encoding
* @param {Object} obj
* @return {string}
* @throws Exception if object cannot be stringified or contains cycle
*/
let json_to_utf8base64url = function (obj) {
return base64url.default.encode(new Buffer(JSON.stringify(obj), 'utf8'))
}
/**
* @class JWebClient
* @constructor
* @description Implementation of HTTPS-based JSON-Web-Client
*/
export class JWebClient {
key_pair: any
last_nonce: string
verbose: boolean
constructor() {
/**
* @member {Object} module:JWebClient~JWebClient#key_pair
* @desc User account key pair
*/
this.key_pair = null // {Object}
/**
* @member {string} module:JWebClient~JWebClient#last_nonce
* @desc Cached nonce returned with last request
*/
this.last_nonce = null // {string}
/**
* @member {boolean} module:JWebClient~JWebClient#verbose
* @desc Determines verbose mode
*/
this.verbose = false // {boolean}
}
/**
* createJWT
* @description create JSON-Web-Token signed object
* @param {string|undefined} nonce
* @param {Object|string|number|boolean} payload
* @param {string} alg
* @param {Object|string} key
* @param {Object} jwk
* @return {string}
*/
createJWT(nonce, payload, alg, key, jwk) {
/*jshint -W069 */
// prepare key
if (key instanceof Object) {
key = base64url.default.toBuffer(key['k'])
}
// prepare header
let header = {
typ: 'JWT',
alg: alg,
jwk: jwk,
nonce: null
}
if (nonce !== void 0) {
header.nonce = nonce
}
// concatenate header and payload
let input = [
json_to_utf8base64url(header),
json_to_utf8base64url(payload)
].join('.')
// sign input
let hmac = jwa(alg)
let sig = hmac.sign(input, key)
// concatenate input and signature
let output = [
input,
sig
].join('.')
// dereference
header = null
hmac = null
input = null
jwk = null
key = null
payload = null
// output
return output
}
/**
* request
* @description make GET or POST request over HTTPS and use JOSE as payload type
* @param {string} query
* @param {string} payload
* @param {function} callback
* @param {function} errorCallback
*/
request(query, payload, callback, errorCallback) {
/*jshint -W069 */
if (typeof query !== 'string') {
query = '' // ensure query is string
}
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
if (typeof errorCallback !== 'function') {
errorCallback = this.emptyCallback // ensure callback is function
}
// prepare options
let uri = url.parse(query)
let options = {
hostname: uri.hostname,
port: parseInt(uri.port, 10),
path: uri.path,
method: null,
headers: {}
}
if (typeof payload === 'string') {
options.method = 'POST'
options.headers = {
'Content-Type': 'application/jose',
'Content-Length': payload.length
}
} else {
options.method = 'GET'
}
// prepare request
let req = https.request(options, function (res) {
// receive data
let data = []
res.on('data', function (block) {
if (block instanceof Buffer) {
data.push(block)
}
})
res.on('end', function () {
let buf = Buffer.concat(data)
let isJSON = (
(res instanceof Object)
&& (res['headers'] instanceof Object)
&& (typeof res.headers['content-type'] === 'string')
&& (res.headers['content-type'].indexOf('json') > -1)
)
if (isJSON && buf.length > 0) {
try {
// convert to JSON
let json = JSON.parse(buf.toString('utf8'))
callback(json, res)
} catch (e) {
// error (if empty or invalid JSON)
errorCallback(void 0, e)
}
} else {
callback(buf, res)
}
})
}).on('error', function (e) {
console.error('Error occured', e)
// error
errorCallback(void 0, e)
})
// write POST body if payload was specified
if (typeof payload === 'string') {
req.write(payload)
}
// make request
req.end()
}
/**
* get
* @description make GET request
* @param {string} uri
* @param {function} callback
* @param {function} errorCallback
*/
get(uri, callback, errorCallback) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
this.request(uri, void 0, function (ans, res) {
ctx.evaluateStatus(uri, null, ans, res)
// save replay nonce for later requests
if ((res instanceof Object) && (res['headers'] instanceof Object)) {
ctx.last_nonce = res.headers['replay-nonce']
}
callback(ans, res)
// dereference
ans = null
callback = null
ctx = null
res = null
}, errorCallback)
// dereference
errorCallback = null
}
/**
* post
* @description make POST request
* @param {string} uri
* @param {Object|string|number|boolean} payload
* @param {function} callback
* @param {function} errorCallback
*/
post(uri, payload, callback, errorCallback) {
/*jshint -W069 */
let ctx = this
if (typeof callback !== 'function') {
callback = this.emptyCallback // ensure callback is function
}
let key_pair = this.key_pair
if (!(key_pair instanceof Object)) {
key_pair = {} // ensure key pair is object
}
let jwt = this.createJWT(this.last_nonce, payload, 'RS256', key_pair['private_pem'], key_pair['public_jwk'])
this.request(uri, jwt, (ans, res) => {
ctx.evaluateStatus(uri, payload, ans, res)
// save replay nonce for later requests
if ((res instanceof Object) && (res['headers'] instanceof Object)) {
ctx.last_nonce = res.headers['replay-nonce']
}
callback(ans, res)
// dereference
ans = null
callback = null
ctx = null
key_pair = null
payload = null
res = null
}, errorCallback)
// dereference
errorCallback = null
}
/**
* evaluateStatus
* @description check if status is expected and log errors
* @param {string} uri
* @param {Object|string|number|boolean} payload
* @param {Object|string} ans
* @param {Object} res
*/
evaluateStatus(uri, payload, ans, res) {
/*jshint -W069 */
if (this.verbose) {
if (
(payload instanceof Object)
|| (typeof payload === 'string')
|| (typeof payload === 'number')
|| (typeof payload === 'boolean')
) {
console.error('Send :', payload) // what has been sent
}
}
let uri_parsed = url.parse(uri)
if (res['statusCode'] >= 100 && res['statusCode'] < 400) {
console.error('HTTP :', res['statusCode'], uri_parsed.path) // response code if successful
}
if (res['statusCode'] >= 400 && res['statusCode'] < 500) {
console.error('HTTP :', res['statusCode'], uri_parsed.path) // response code if error
if (ans instanceof Object) {
if (typeof ans['detail'] === 'string') {
console.error('Message:', ans.detail.split(' :: ').pop()) // error message if any
}
}
}
if (this.verbose) {
console.error('Receive:', res['headers']) // received headers
console.error('Receive:', ans) // received data
}
// dereference
ans = null
payload = null
res = null
uri_parsed = null
}
/**
* Helper: Empty callback
*/
emptyCallback() {
// nop
}
}

View File

@ -0,0 +1,9 @@
import * as plugins from './smartacme.plugins'
import * as acmeclient from './smartacme.classes.acmeclient'
export class SmartAcme {
acmeClient: acmeclient.AcmeClient
constructor(directoryUrlArg: string = 'https://acme-staging.api.letsencrypt.org/directory') {
this.acmeClient = new acmeclient.AcmeClient(directoryUrlArg)
}
}

6
ts/smartacme.plugins.ts Normal file
View File

@ -0,0 +1,6 @@
import 'typings-global'
import * as path from 'path'
export {
path
}

3
tslint.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "tslint-config-standard"
}