Skip to content

Commit

Permalink
checkpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
zsteinkamp committed Jun 15, 2023
1 parent 4525c7b commit 4d6ce14
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 60 deletions.
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export class HttpClient {
// TODO: refactor maybe
const respData = await resp.json();
/* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */
if (respData && respData.type && (respData.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) {
if (respData?.type === 'urn:ietf:params:acme:error:badNonce' && (attempts < this.maxBadNonceRetries)) {
nonce = resp.headers.get('replay-nonce') || null;
attempts += 1;

Expand Down
125 changes: 70 additions & 55 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, splitPemChain, pemToBuffer, readCsrDomainNames } from './utils'
import { HttpClient, directories } from './api'
import { toPEM, readOrCreateAccountKey, generateKey, createCsr, readCertificateInfo, getAcmeServerNames, getVariable, joinPaths, acmeDir } from './utils'
import { HttpClient } from './api'
import { AcmeClient } from './client'
var fs = require('fs');
import fs from 'fs';

const KEY_SUFFIX = '.key';
const CERTIFICATE_SUFFIX = '.crt';
Expand Down Expand Up @@ -39,7 +39,7 @@ function stringToBoolean(stringValue: String, val = false) {
*/
async function clientNewAccount(r: NginxHTTPRequest) {
const accountKey = await readOrCreateAccountKey(NJS_ACME_ACCOUNT_PRIVATE_JWK);
// /* Create a new ACME account */
// Create a new ACME account
let client = new AcmeClient({
directoryUrl: DIRECTORY_URL,
accountKey: accountKey
Expand All @@ -49,11 +49,15 @@ async function clientNewAccount(r: NginxHTTPRequest) {
// do not validate ACME provider cert
client.api.setVerify(false);

const account = await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:[email protected]']
});
return r.return(200, JSON.stringify(account));
try {
const account = await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:[email protected]']
});
return r.return(200, JSON.stringify(account));
} catch (e) {
ngx.log(ngx.ERR, `Error creating ACME account. Error=${e}`)

This comment has been minimized.

Copy link
@ivanitskiy

ivanitskiy Jun 15, 2023

Contributor

it is a NGINX request handler (js_content) we need to do something like r.return(500, "details") to notify the client

}
}

/**
Expand All @@ -63,15 +67,19 @@ async function clientNewAccount(r: NginxHTTPRequest) {
* @returns void
*/
async function clientAutoMode(r: NginxHTTPRequest) {
const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR;
const commonName = r.variables.server_name?.toLowerCase() || r.variables.njs_acme_server_name;
const pkeyPath = prefix + commonName + KEY_SUFFIX;
const csrPath = prefix + commonName + '.csr';
const certPath = prefix + commonName + CERTIFICATE_SUFFIX;

const email = r.variables.njs_acme_account_email || process.env.NJS_ACME_ACCOUNT_EMAIL;
if (email.length === 0) {
r.return(500, "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set");
const prefix = acmeDir(r);
const serverNames = getAcmeServerNames(r);

const commonName = serverNames[0];
const pkeyPath = joinPaths(prefix, commonName + KEY_SUFFIX);
const csrPath = joinPaths(prefix, commonName + '.csr');
const certPath = joinPaths(prefix, commonName + CERTIFICATE_SUFFIX);

let email
try {
email = getVariable(r, 'njs_acme_account_email');
} catch {
return r.return(500, "Nginx variable 'njs_acme_account_email' or 'NJS_ACME_ACCOUNT_EMAIL' environment variable must be set");
}

let certificatePem;
Expand Down Expand Up @@ -120,16 +128,25 @@ async function clientAutoMode(r: NginxHTTPRequest) {

const result = await createCsr(params);
fs.writeFileSync(csrPath, toPEM(result.pkcs10Ber, "CERTIFICATE REQUEST"), 'utf-8');

const privKey = await crypto.subtle.exportKey("pkcs8", result.keys.privateKey);
pkeyPem = toPEM(privKey, "PRIVATE KEY");
fs.writeFileSync(pkeyPath, pkeyPem, 'utf-8');
r.log(`njs-acme: [auto] Wrote Private key to ${pkeyPath}`);
ngx.log(ngx.INFO, `njs-acme: [auto] Wrote Private key to ${pkeyPath}`);

const challengePath = r.variables.njs_acme_challenge_dir!;
// default challengePath = acmeDir/challenge
const challengePath = getVariable(r, 'njs_acme_challenge_dir', joinPaths(acmeDir(r), 'challenge'));
if (challengePath === undefined || challengePath.length === 0) {
r.return(500, "Nginx variable 'njs_acme_challenge_dir' must be set");
return r.return(500, "Nginx variable 'njs_acme_challenge_dir' must be set");
}
ngx.log(ngx.INFO, `njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}`);
const fullChallengePath = joinPaths(challengePath, '.well-known/acme-challenge');
try {
fs.mkdirSync(fullChallengePath, { recursive: true });
} catch (e) {
ngx.log(ngx.ERR, `Error creating directory to store challenges at ${fullChallengePath}. Ensure the ${challengePath} directory is writable by the nginx user.`)
return r.return(500, "Cannot create challenge directory");
}
r.log(`njs-acme: [auto] Issuing a new Certificate: ${JSON.stringify(params)}`);

certificatePem = await client.auto({
csr: result.pkcs10Ber,
Expand All @@ -138,11 +155,11 @@ async function clientAutoMode(r: NginxHTTPRequest) {
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
ngx.log(ngx.INFO, `njs-acme: [auto] Challenge Create (authz='${JSON.stringify(authz)}', challenge='${JSON.stringify(challenge)}', keyAuthorization='${keyAuthorization}')`);
ngx.log(ngx.INFO, `njs-acme: [auto] Writing challenge file so nginx can serve it via .well-known/acme-challenge/${challenge.token}`);
const path = `${challengePath}/.well-known/acme-challenge/${challenge.token}`;
fs.writeFileSync(path, keyAuthorization, 'utf8');
const path = joinPaths(fullChallengePath, challenge.token);
fs.writeFileSync(path, keyAuthorization);
},
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
const path = `${challengePath}/.well-known/acme-challenge/${challenge.token}`;
const path = joinPaths(fullChallengePath, challenge.token);
try {
fs.unlinkSync(path);
ngx.log(ngx.INFO, `njs-acme: [auto] removed challenge ${path}`);
Expand All @@ -152,7 +169,7 @@ async function clientAutoMode(r: NginxHTTPRequest) {
}
});
certInfo = await readCertificateInfo(certificatePem);
fs.writeFileSync(certPath, certificatePem, 'utf-8');
fs.writeFileSync(certPath, certificatePem);
r.log(`njs-acme: wrote certificate to ${certPath}`);
}

Expand All @@ -167,17 +184,17 @@ async function clientAutoMode(r: NginxHTTPRequest) {
async function persistGeneratedKeys(keys: CryptoKeyPair) {
crypto.subtle.exportKey("pkcs8", keys.privateKey).then(key => {
const pemExported = toPEM(key as ArrayBuffer, "PRIVATE KEY");
fs.writeFileSync(NJS_ACME_DIR + "/account.private.key", pemExported, 'utf8');
fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.key"), pemExported);
});
crypto.subtle.exportKey("spki", keys.publicKey).then(key => {
const pemExported = toPEM(key as ArrayBuffer, "PUBLIC KEY");
fs.writeFileSync(NJS_ACME_DIR + "/account.public.key", pemExported, 'utf8');
fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.key"), pemExported);
});
crypto.subtle.exportKey("jwk", keys.privateKey).then(key => {
fs.writeFileSync(NJS_ACME_DIR + "/account.private.json", JSON.stringify(key), 'utf8');
fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.private.json"), JSON.stringify(key));
});
crypto.subtle.exportKey("jwk", keys.publicKey).then(key => {
fs.writeFileSync(NJS_ACME_DIR + "/account.public.json", JSON.stringify(key), 'utf8');
fs.writeFileSync(joinPaths(NJS_ACME_DIR, "account.public.json"), JSON.stringify(key));
});
}

Expand All @@ -187,13 +204,9 @@ async function persistGeneratedKeys(keys: CryptoKeyPair) {
* @returns
*/
async function acmeNewAccount(r: NginxHTTPRequest) {

ngx.log(ngx.ERR, `process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS: ${process.env.NJS_ACME_VERIFY_PROVIDER_HTTPS}`);
ngx.log(ngx.ERR, `VERIFY_PROVIDER_HTTPS: ${VERIFY_PROVIDER_HTTPS}`);




/* Generate a new RSA key pair for ACME account */
const keys = (await generateKey()) as Required<CryptoKeyPair>;

Expand Down Expand Up @@ -224,12 +237,13 @@ async function acmeNewAccount(r: NginxHTTPRequest) {
}

/**
* Create a new certificate Signing Request
* Create a new certificate Signing Request - Example implementation
* @param r
* @returns
*/
async function createCsrHandler(r: NginxHTTPRequest) {
const { pkcs10Ber, keys } = await createCsr({
// EXAMPLE VALUES BELOW
altNames: ["proxy1.f5.com", "proxy2.f5.com"],
commonName: "proxy.f5.com",
state: "WA",
Expand All @@ -245,57 +259,58 @@ async function createCsrHandler(r: NginxHTTPRequest) {
return r.return(200, result);
}

/** Retrieves the cert based on the Nginx HTTP request.
*
* @param {NginxHTTPRequest} r - The Nginx HTTP request object.
* @returns {string, string} - The path and cert associated with the server name.
*/
/**
* Retrieves the cert based on the Nginx HTTP request.
* @param {NginxHTTPRequest} r - The Nginx HTTP request object.
* @returns {string, string} - The path and cert associated with the server name.
*/
function js_cert(r: NginxHTTPRequest) {
const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR;
const prefix = acmeDir(r);
let { path, data } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', CERTIFICATE_SUFFIX);
// r.log(`njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`);
// ngx.log(ngx.INFO, `njs-acme: Loaded cert for ${r.variables.ssl_server_name} from path: ${path}`);
if (data.length == 0) {
r.log(`njs-acme: seems there is no cert for ${r.variables.ssl_server_name} from path: ${path}`);
/*
// FIXME: is there a way to send a subrequest so we kick in auto mode to issue a new one?
r.subrequest('http://localhost:8000/acme/auto',
{detached: true, method: 'GET', body: undefined});
r.log(`njs-acme: notified /acme/auto`);
*/
}
return path;
}

/** Retrieves the key based on the Nginx HTTP request.
*
* @param {NginxHTTPRequest} r - The Nginx HTTP request object.
* @returns {string} - The path and key associated with the server name.
*/
/**
* Retrieves the key based on the Nginx HTTP request.
* @param {NginxHTTPRequest} r - The Nginx HTTP request object.
* @returns {string} - The path and key associated with the server name.
*/
function js_key(r: NginxHTTPRequest) {
const prefix = r.variables.njs_acme_dir || NJS_ACME_DIR;
let { path, data } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', KEY_SUFFIX);
const prefix = acmeDir(r);
const { path } = read_cert_or_key(prefix, r.variables.ssl_server_name?.toLowerCase() || '', KEY_SUFFIX);
// r.log(`njs-acme: loaded key for ${r.variables.ssl_server_name} from path: ${path}`);
return path
}

function read_cert_or_key(prefix: string, domain: string, suffix: string) {
var none_wildcard_path = String.prototype.concat(prefix, domain, suffix);
var wildcard_path = String.prototype.concat(prefix, domain.replace(/.*?\./, '*.'), suffix);
var data = '';
const none_wildcard_path = joinPaths(prefix, domain + suffix);
const wildcard_path = joinPaths(prefix, domain.replace(/.*?\./, '*.') + suffix);

let data = '';
var path = '';

try {
data = fs.readFileSync(none_wildcard_path);
data = fs.readFileSync(none_wildcard_path, 'utf8');
path = none_wildcard_path;
} catch (e) {
try {
data = fs.readFileSync(wildcard_path);
data = fs.readFileSync(wildcard_path, 'utf8');
path = wildcard_path;
} catch (e) {
data = '';
}
}

return { path, data };
}

Expand Down
60 changes: 56 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import x509 from './x509.js'
import * as pkijs from 'pkijs';
import * as asn1js from 'asn1js';
import fs from 'fs';

const DEFAULT_ACCOUNT_KEY_PATH = `${ngx.conf_prefix || '/etc/nginx'}/account_private_key.json`;

Expand Down Expand Up @@ -132,9 +133,8 @@ export async function generateKey() {
* @throws {Error} - If the account key cannot be read or generated.
*/
export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_PATH): Promise<CryptoKey> {
const fs = require('fs');
try {
const accountKeyJWK = fs.readFileSync(path);
const accountKeyJWK = fs.readFileSync(path, 'utf8');
ngx.log(ngx.INFO, `acme-njs: [utils] Using account key from ${path}`);
return await crypto.subtle.importKey('jwk', JSON.parse(accountKeyJWK), ACCOUNT_KEY_ALG_IMPORT, true, ["sign"]);
} catch (e) {
Expand All @@ -143,7 +143,7 @@ export async function readOrCreateAccountKey(path: string = DEFAULT_ACCOUNT_KEY_
/* Generate a new RSA key pair for ACME account */
const keys = (await generateKey()) as Required<CryptoKeyPair>;
const jwkFormated = await crypto.subtle.exportKey("jwk", keys.privateKey)
fs.writeFileSync(path, JSON.stringify(jwkFormated), 'utf8');
fs.writeFileSync(path, JSON.stringify(jwkFormated));
ngx.log(ngx.INFO, `acme-njs: [utils] Generated a new account key and saved it to ${path}`);
return keys.privateKey;
}
Expand Down Expand Up @@ -703,9 +703,10 @@ export function splitPemChain(chainPem: Buffer | string) {
.map((pem) => pem.match(/\s*-----BEGIN ([A-Z0-9- ]+)-----\r?\n?([\S\s]+)\r?\n?-----END \1-----/))
/* Filter out non-matches or empty bodies */
.filter((pem) => pem && pem[2] && pem[2].replace(/[\r\n]+/g, '').trim())
.map(([pem, header]) => pem);
.map(([pem, _]) => pem);
}


/**
* Reads the common name and alternative names from a CSR (Certificate Signing Request).
* @param csrPem The PEM-encoded CSR string or a Buffer containing the CSR.
Expand All @@ -722,3 +723,54 @@ export function readCsrDomainNames(csrPem: string | Buffer): { commonName: strin
altNames: x509.get_oid_value(csr, "2.5.29.17")
};
}


/**
* Convenience method to return the value of a given environment variable or
* nginx variable. Will return the environment variable if that is found first.
* Requires that env vars be the uppercase version of nginx vars.
* If no default is given and the variable is not found, throws an error.
* @param r Nginx HTTP Request
* @param varname Name of the variable
* @returns value of the variable
*/
export function getVariable(r: NginxHTTPRequest, varname: string, defaultVal?: string) {
const retval = process.env[varname.toUpperCase()] || r.variables[varname] || defaultVal
if (retval === undefined) {
throw new Error(`Variable ${varname} not found and no default value given.`)
}
return retval
}


/**
* Return an array of hostnames specified in the njs_acme_server_names variable
* @param r request
* @returns array of hostnames
*/
export function getAcmeServerNames(r: NginxHTTPRequest) {
const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory
// split string value on comma and/or whitespace and lowercase each element
return nameStr.split(/[,\s]+/).map((n) => n.toLocaleLowerCase())
}


/**
* Return the path where ACME magic happens
* @param r request
* @returns configured path or default
*/
export function acmeDir(r: NginxHTTPRequest) {
return getVariable(r, 'njs_acme_dir', '/etc/acme');
}


/**
* Joins args with slashes and removes duplicate slashes
* @param args path fragments to join
* @returns joined path string
*/
export function joinPaths(...args: string[]) {
// join args with a slash remove duplicate slashes
return args.join('/').replace(/\/+/g, '/')
}

0 comments on commit 4d6ce14

Please sign in to comment.