Skip to content

Commit

Permalink
certificate: prefer natural language terms vs code names
Browse files Browse the repository at this point in the history
  • Loading branch information
clshortfuse committed Feb 27, 2023
1 parent 25b4b66 commit 7fa14be
Show file tree
Hide file tree
Showing 12 changed files with 558 additions and 393 deletions.
16 changes: 8 additions & 8 deletions helpers/jwkImporter.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { ASN_OID, decodeDER } from '../utils/asn1.js';
import { derFromPKCS8 } from '../utils/pkcs.js';
import { derFromPrivateKeyInformation } from '../utils/certificate.js';

/**
* Automatically suggest an importKey algorithm
* @see https://datatracker.ietf.org/doc/html/rfc5208#section-5
* @see https://datatracker.ietf.org/doc/html/rfc2313#section-11
* @param {string|Uint8Array} pkcs8
* @param {string|Uint8Array} privateKeyInformation pkcs8
* @return {Parameters<SubtleCrypto['importKey']>[2]}
*/
export function suggestImportKeyAlgorithm(pkcs8) {
const der = derFromPKCS8(pkcs8);
export function suggestImportKeyAlgorithm(privateKeyInformation) {
const der = derFromPrivateKeyInformation(privateKeyInformation);
const [
[privateKeyInfoType, [
[versionType, version],
algorithmIdentifierSequence,
[privateKeyType, privateKey], // Skip validation
]],
] = decodeDER(der);
if (privateKeyInfoType !== 'SEQUENCE') throw new Error('Invalid PKCS8');
if (versionType !== 'INTEGER') throw new Error('Invalid PKCS8');
if (version !== 0) throw new Error('Unsupported PKCS8 Version');
if (privateKeyInfoType !== 'SEQUENCE') throw new Error('Invalid Private Key Information');
if (versionType !== 'INTEGER') throw new Error('Invalid Private Key Information');
if (version !== 0) throw new Error('Unsupported Private Key Information Version');
const [algorithmIdentifierSequenceType, algorithmIdentifierSequenceValues] = algorithmIdentifierSequence;
if (algorithmIdentifierSequenceType !== 'SEQUENCE') throw new Error('Invalid PKCS8');
if (algorithmIdentifierSequenceType !== 'SEQUENCE') throw new Error('Invalid Private Key Information');

/** @type {Set<string>} */
const objectIdentifiers = new Set();
Expand Down
17 changes: 8 additions & 9 deletions helpers/quickOrder.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/* eslint-disable no-await-in-loop */
import ACMEAgent from '../lib/ACMEAgent.js';
import { encodeBase64UrlAsString } from '../utils/base64.js';
import { jwkFromPKCS8 } from '../utils/crypto.js';
import { createCSR, derFromPrivateKeyInformation, jwkFromPrivateKeyInformation } from '../utils/certificate.js';
import { checkDnsTxt } from '../utils/dns.js';
import { dispatchEvent, dispatchExtendableEvent } from '../utils/events.js';
import { createPKCS10, derFromPKCS8 } from '../utils/pkcs.js';

import { suggestImportKeyAlgorithm } from './jwkImporter.js';

Expand Down Expand Up @@ -93,7 +92,7 @@ export async function authorizeOrder({ order, agent, eventTarget }) {
* @param {Object} options
* @param {boolean} options.tosAgreed
* @param {string} options.email
* @param {JWK|string|Uint8Array} options.jwk Account JWK or PKCS8
* @param {JWK|string|Uint8Array} options.jwk Account JWK or PrivateKeyInformation (PKCS8)
* @param {string} options.domain
* @param {string} [options.orderUrl] existing order URL (blank for new)
* @param {string} [options.directoryUrl] defaults to LetsEncrypt Production
Expand All @@ -104,7 +103,7 @@ export async function authorizeOrder({ order, agent, eventTarget }) {
* @param {string} [options.csr.localityName]
* @param {string} [options.csr.stateOrProvinceName]
* @param {string} [options.csr.countryName]
* @param {JWK|string|Uint8Array} options.csr.jwk CSR JWK or PKCS8
* @param {JWK|string|Uint8Array} options.csr.jwk CSR JWK or PrivateKeyInformation (PKCS8)
* @return {Promise<any>}
*/
export async function getWildcardCertificate(options) {
Expand All @@ -114,15 +113,15 @@ export async function getWildcardCertificate(options) {
let csrJWK;

if (typeof options.jwk === 'string' || options.jwk instanceof Uint8Array) {
const der = derFromPKCS8(options.jwk);
accountJWK = await jwkFromPKCS8(der, suggestImportKeyAlgorithm(der));
const der = derFromPrivateKeyInformation(options.jwk);
accountJWK = await jwkFromPrivateKeyInformation(der, suggestImportKeyAlgorithm(der));
} else {
accountJWK = options.jwk;
}

if (typeof options.csr.jwk === 'string' || options.csr.jwk instanceof Uint8Array) {
const der = derFromPKCS8(options.csr.jwk);
csrJWK = await jwkFromPKCS8(der, suggestImportKeyAlgorithm(der));
const der = derFromPrivateKeyInformation(options.csr.jwk);
csrJWK = await jwkFromPrivateKeyInformation(der, suggestImportKeyAlgorithm(der));
} else {
csrJWK = options.csr.jwk;
}
Expand Down Expand Up @@ -163,7 +162,7 @@ export async function getWildcardCertificate(options) {
order = await agent.fetchOrder(orderUrl);

if (order.status === 'ready') {
const csrDER = await createPKCS10({
const csrDER = await createCSR({
commonName: `*.${options.domain}`,
altNames: [`*.${options.domain}`, options.domain],
...options.csr,
Expand Down
25 changes: 19 additions & 6 deletions lib/jwa.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,27 @@ export function createSymmetricKey({ k }) {
/**
* @see https://www.rfc-editor.org/rfc/rfc7518#section-3.1
* @see https://w3c.github.io/webcrypto/#jwk-mapping-alg
* @param {JWK} jwk
* @param {JWK|string|Algorithm} input
* @return {Parameters<SubtleCrypto['sign']>[0]|Parameters<SubtleCrypto['importKey']>[2]}
*/
export function parseAlgorithmIdentifier(jwk) {
let keySize;
if (jwk.alg.startsWith('PS')) {
keySize = jwk.n ? decodeBase64AsArray(jwk.n).length * 8 : 2048;
export function parseAlgorithmIdentifier(input) {
let keySize = 2048;
/** @type {string} */
let alg;
/** @type {string} */
let n;
if (typeof input === 'string') {
alg = input;
} else if ('alg' in input) {
({ alg, n } = input);
} else {
return /** @type {Algorithm} */ (input);
}

if (alg.startsWith('PS') && n) {
keySize = decodeBase64AsArray(n).length * 8;
}
switch (jwk.alg) {
switch (alg) {
case 'HS256': return { name: 'HMAC', hash: { name: 'SHA-256' } };
case 'HS384': return { name: 'HMAC', hash: { name: 'SHA-384' } };
case 'HS512': return { name: 'HMAC', hash: { name: 'SHA-512' } };
Expand All @@ -93,6 +105,7 @@ export function parseAlgorithmIdentifier(jwk) {
case 'PS512': return { name: 'RSA-PSS', saltLength: Math.ceil((keySize - 1) / 8) - (512 / 8) - 2, hash: { name: 'SHA-512' } };
default:
}

throw new Error('Unknown `alg` value.');
}

Expand Down
6 changes: 6 additions & 0 deletions utils/certificate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './certificate/format.js';
export * from './certificate/ecPrivateKey.js';
export * from './certificate/rsaPrivateKey.js';
export * from './certificate/privateKeyInformation.js';
export * from './certificate/csr.js';
export * from './certificate/publicKey.js';
123 changes: 123 additions & 0 deletions utils/certificate/csr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { extractPublicJWK, parseAlgorithmIdentifier } from '../../lib/jwa.js';
import {
ASN_CLASS, ASN_CONSTRUCTED,
encodeAttribute, encodeBitString, encodeDER, encodeDNSName, encodeExtension,
encodeExtensions, encodeInteger, encodeObjectIdentifier, encodePrintableString,
encodeSequence, encodeSet, encodeSubjectAltNameValue, encodeUTF8String,
parseSignatureAlgorithm,
} from '../asn1.js';
import { importJWK, sign } from '../crypto.js';

import { derFromPEM, pemFromDER } from './format.js';
import { derFromSPKI, spkiFromJWK } from './publicKey.js';

const CSR_HEADER = '-----BEGIN CERTIFICATE REQUEST-----';
const CSR_FOOTER = '-----END CERTIFICATE REQUEST-----';

/**
* @param {string|Uint8Array} csr
* @return {Uint8Array}
*/
export function derFromCSR(csr) {
if (typeof csr !== 'string') return csr;
return derFromPEM(csr, CSR_HEADER, CSR_FOOTER);
}

/**
* @param {string|Uint8Array} csr
* @return {string}
*/
export function pemFromCSR(csr) {
return pemFromDER(derFromCSR(csr), CSR_HEADER, CSR_FOOTER);
}

/**
* @see https://datatracker.ietf.org/doc/html/rfc2986
* @see https://datatracker.ietf.org/doc/html/rfc2985#section-5.4 (Attributes)
* @see https://www.rfc-editor.org/rfc/rfc5280#appendix-A.1 (Extensions)
* @param {Object} options
* @param {string} options.commonName
* @param {string} [options.organizationName]
* @param {string} [options.organizationalUnitName]
* @param {string} [options.localityName]
* @param {string} [options.stateOrProvinceName]
* @param {string} [options.countryName]
* @param {string[]} [options.altNames]
* @param {JWK} options.jwk
* @return {Promise<Uint8Array>}
*/
export async function createCSR(options) {
/** @type {[string,string,(input: string) => number[]][]} */
const oidMappings = [
['2.5.4.3', options.commonName, encodeUTF8String],
['2.5.4.6', options.countryName, encodePrintableString],
['2.5.4.7', options.localityName, encodeUTF8String],
['2.5.4.8', options.stateOrProvinceName, encodeUTF8String],
['2.5.4.10', options.organizationName, encodeUTF8String],
['2.5.4.11', options.organizationalUnitName, encodeUTF8String],
];
// dn are composed as sets
const dnSets = oidMappings
.filter(([oid, value]) => value)
.map(([oid, value, encoder]) => encodeSet(
encodeSequence(
encodeObjectIdentifier(oid),
encoder(value),
),
));
const subject = encodeSequence(...dnSets);

const algorithmIdentifier = parseAlgorithmIdentifier(options.jwk);

// Note: Could use ASN1 to compile SPKI, but using platform implementation is safer
const spki = await spkiFromJWK(extractPublicJWK(options.jwk), algorithmIdentifier);
const SubjectPKInfo = derFromSPKI(spki);

let attributes;
if (options.altNames?.length) {
const extensionRequestAttributeOID = '1.2.840.113549.1.9.14';
const subjectAltNameOID = '2.5.29.17';

const attributesTag = 0;
attributes = encodeDER(
// eslint-disable-next-line no-bitwise
ASN_CLASS.CONTEXT_SPECIFIC | ASN_CONSTRUCTED | attributesTag,
encodeAttribute(
extensionRequestAttributeOID,
encodeExtensions(
encodeExtension(
subjectAltNameOID,
encodeSubjectAltNameValue(
...options.altNames.map((dnsName) => encodeDNSName(dnsName)),
),
),
),
),
);
}

const certificationRequestInfo = encodeSequence(
encodeInteger(0), // Version
subject, // Subject
SubjectPKInfo, // SubjectPKInfo
attributes ?? [], // Attributes,
);

const key = await importJWK({ ...options.jwk, key_ops: ['sign'] }, algorithmIdentifier);
const signedCRI = await sign(algorithmIdentifier, key, Uint8Array.from(certificationRequestInfo));
const dataArray = new Uint8Array(signedCRI);
const signature = encodeBitString(dataArray, signedCRI.byteLength);
const certificationRequest = encodeSequence(
certificationRequestInfo,
parseSignatureAlgorithm(options.jwk),
signature,
);
return Uint8Array.from(certificationRequest);
}

/** @alias */
export const createPKCS10 = createCSR;
/** @alias */
export const derFromPKCS10 = derFromCSR;
/** @alias */
export const pemFromPKCS10 = pemFromCSR;
22 changes: 22 additions & 0 deletions utils/certificate/ecPrivateKey.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { derFromPEM, pemFromDER } from './format.js';

// https://datatracker.ietf.org/doc/html/rfc5915
const EC_PRIVATE_KEY_HEADER = '-----BEGIN EC PRIVATE KEY-----';
const EC_PRIVATE_KEY_FOOTER = '-----END EC PRIVATE KEY-----';

/**
* @param {string|Uint8Array} ecPrivateKey
* @return {Uint8Array}
*/
export function derFromECPrivateKey(ecPrivateKey) {
if (typeof ecPrivateKey !== 'string') return ecPrivateKey;
return derFromPEM(ecPrivateKey, EC_PRIVATE_KEY_HEADER, EC_PRIVATE_KEY_FOOTER);
}

/**
* @param {string|Uint8Array} ecPrivateKey
* @return {string}
*/
export function pemFromECPrivateKey(ecPrivateKey) {
return pemFromDER(derFromECPrivateKey(ecPrivateKey), EC_PRIVATE_KEY_HEADER, EC_PRIVATE_KEY_FOOTER);
}
81 changes: 81 additions & 0 deletions utils/certificate/format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { decodeBase64AsArray, encodeBase64AsString } from '../base64.js';

/**
* @param {string} pem
* @param {string} [header]
* @param {string} [footer]
* @return {Uint8Array}
*/
export function derFromPEM(pem, header, footer) {
let content;
if (!header || !footer) {
const splits = pem.split('-----');
if (splits.length === 1) {
// No header, assume encoded DER
content = pem;
} else if (splits.length === 5) {
content = splits[2];
} else {
throw new Error('Invalid PEM');
}
} else {
const indexOfHeader = pem.indexOf(header);
const indexOfFooter = pem.indexOf(footer);
if (indexOfHeader !== -1 && indexOfFooter !== -1) {
content = pem.slice(
pem.indexOf(header) + header.length,
pem.indexOf(footer),
);
} else if (indexOfHeader === -1 && indexOfHeader === -1) {
// No header, assume encoded DER
content = pem;
} else {
throw new Error('Invalid PEM!');
}
}
return decodeBase64AsArray(content.replaceAll(/\s/g, ''));
}

/**
* @param {Uint8Array} der
* @param {string} header
* @param {string} footer
*/
export function pemFromDER(der, header, footer) {
return `${header}\n${encodeBase64AsString(der).replaceAll(/(.{64})/g, '$1\n')}\n${footer}`;
}

/**
* Reconstructs PEM with line breaks and 64 char limit
* @param {string} pem
* @param {string} [header]
* @param {string} [footer]
* @return {string}
*/
export function formatPEM(pem, header, footer) {
let content;
if (!header || !footer) {
const splits = pem.split('-----');
if (splits.length !== 5) throw new Error('Invalid PEM');

header = `-----${splits[1]}-----`;
content = splits[2];
footer = `-----${splits[3]}-----`;
} else {
const indexOfHeader = pem.indexOf(header);
const indexOfFooter = pem.indexOf(footer);

if (indexOfHeader !== -1 && indexOfFooter !== -1) {
content = pem.slice(
pem.indexOf(header) + header.length,
pem.indexOf(footer),
);
} else if (indexOfHeader === -1 && indexOfHeader === -1) {
// No header, assume encoded DER
content = pem;
} else {
throw new Error('Invalid PEM!');
}
}
return `${header}\n${content.replaceAll(/\s/g, '').replaceAll(/(.{64})/g, '$1\n')}\n${footer}`;
}
Loading

0 comments on commit 7fa14be

Please sign in to comment.