import { expect } from 'chai' import { ParsedUrl, UrlMatcher, UrlClientCertificates, ClientCertificateStore, ClientCertificates, loadClientCertificateConfig } from '../../lib/client-certificates' import { clientCertificateStore } from '../../lib/agent' import urllib from 'url' import fs from 'fs-extra' import os from 'os' import path from 'path' import Forge from 'node-forge' import { v4 as uuidv4 } from 'uuid' const { pki, pkcs12, asn1 } = Forge function urlShouldMatch (url: string, matcher: string) { let rule = UrlMatcher.buildMatcherRule(matcher) let parsedUrl = new ParsedUrl(url) expect(UrlMatcher.matchUrl(parsedUrl.host, parsedUrl.path, parsedUrl.port, rule), `'${url}' should match '${matcher}' (rule: ${JSON.stringify(rule)})`).to.be.true } function urlShouldNotMatch (url: string, matcher: string) { let rule = UrlMatcher.buildMatcherRule(matcher) let parsedUrl = new ParsedUrl(url) expect(UrlMatcher.matchUrl(parsedUrl.host, parsedUrl.path, parsedUrl.port, rule), `'${url}' should not match '${matcher}' (rule: ${JSON.stringify(rule)})`).to.be.false } function checkParsed (parsed: ParsedUrl, host: string, path: string | undefined, port: number | undefined) { expect(parsed.host, `'host ${parsed.host}' should be '${host}'`).to.eq(host) expect(parsed.path, `'path ${parsed.path}' should be '${path}'`).to.eq(path) expect(parsed.port, `'port ${parsed.port}' should be '${port}'`).to.eq(port) } describe('lib/client-certificates', () => { context('ParsedUrl', () => { it('parses clean URLs', () => { let parsed = new ParsedUrl('https://a.host.com') checkParsed(parsed, 'a.host.com', undefined, undefined) parsed = new ParsedUrl('https://a.host.com:1234') expect(parsed.host).to.eq('a.host.com') expect(parsed.port).to.eq(1234) parsed = new ParsedUrl('https://a.host.com/a/path/') expect(parsed.host).to.eq('a.host.com') expect(parsed.path).to.eq('/a/path/') }) it('parses wildcard URLs', () => { let parsed = new ParsedUrl('https://a.host.*') expect(parsed.host).to.eq('a.host.*') parsed = new ParsedUrl('https://*.host.com') expect(parsed.host).to.eq('*.host.com') parsed = new ParsedUrl('https://a.host.com/a/path/*') expect(parsed.host).to.eq('a.host.com') expect(parsed.path).to.eq('/a/path/*') parsed = new ParsedUrl('https://a.host.com/*/path/') expect(parsed.host).to.eq('a.host.com') expect(parsed.path).to.eq('/*/path/') parsed = new ParsedUrl('*') expect(parsed.host).to.eq('*') expect(parsed.path).to.eq(undefined) }) }) context('ClientCertificateUrlMatcher', () => { it('matches basic hostnames', () => { let matcher = 'https://a.host.com' urlShouldMatch('https://a.host.com', matcher) urlShouldMatch('https://a.host.com/a/path', matcher) urlShouldNotMatch('https://a.host.co.uk', matcher) }) it('matches wildcard hostnames', () => { let matcher1 = 'https://a.host.*' urlShouldMatch('https://a.host.com', matcher1) urlShouldMatch('https://a.host.com/a/path', matcher1) urlShouldMatch('https://a.host.co.uk', matcher1) urlShouldNotMatch('https://a.b.host.co.uk', matcher1) matcher1 = 'https://a.*.host.*' urlShouldNotMatch('https://a.host.com', matcher1) urlShouldNotMatch('https://z.a.host.com', matcher1) urlShouldMatch('https://a.b.host.com', matcher1) urlShouldMatch('https://a.b.c.host.com', matcher1) urlShouldMatch('https://a.b.c.host.co.uk', matcher1) matcher1 = '*' urlShouldMatch('https://a.host.com', matcher1) urlShouldMatch('https://a.b.c.d.e.f.host.co.uk', matcher1) }) it('matches basic paths', () => { let matcher = 'https://a.path.com/a' urlShouldMatch('https://a.path.com/a', matcher) urlShouldNotMatch('https://a.path.com', matcher) urlShouldNotMatch('https://a.path.com/a/b', matcher) }) it('matches wildcard paths', () => { let matcher = 'https://a.path2.com/**' urlShouldMatch('https://a.path2.com/a', matcher) urlShouldMatch('https://a.path2.com/a/b', matcher) }) }) context('UrlClientCertificates', () => { it('constructs, populates default properties', () => { let url = 'http://a.host.com/home' let certs = new UrlClientCertificates(url) expect(certs.url).to.eq(url) expect(certs.pathnameLength).to.eq(5) expect(certs) }) }) context('ClientCertificateStore', () => { it('adds and retrieves certs for urls', () => { const url1 = urllib.parse('https://host.com') const url2 = urllib.parse('https://company.com') const store = new ClientCertificateStore() expect(store.getCertCount()).to.eq(0) let options = store.getClientCertificateAgentOptionsForUrl(url1) expect(options).to.eq(null) const certs1 = new UrlClientCertificates(url1.href) certs1.clientCertificates = new ClientCertificates() certs1.clientCertificates.ca.push(Buffer.from([1, 2, 3, 4])) const certs2 = new UrlClientCertificates(url2.href) certs2.clientCertificates = new ClientCertificates() certs2.clientCertificates.ca.push(Buffer.from([4, 3, 2, 1])) store.addClientCertificatesForUrl(certs1) expect(store.getCertCount()).to.eq(1) store.addClientCertificatesForUrl(certs2) expect(store.getCertCount()).to.eq(2) const act = () => { store.addClientCertificatesForUrl(certs2) } expect(act).to.throw('ClientCertificateStore::addClientCertificatesForUrl: Url https://company.com/ already in store') const options1 = store.getClientCertificateAgentOptionsForUrl(url1) const options2 = store.getClientCertificateAgentOptionsForUrl(url2) expect(options1.ca).to.eq(certs1.clientCertificates.ca) expect(options2.ca).to.eq(certs2.clientCertificates.ca) }) }) }) // The following testing covers the areas: // PEM: // Valid crt/key, no passphrase // Valid crt/key, passphrase // Valid crt/key, relative pathing // Valid crt/key, invalid (and not there) passphrase // Multiple crt/key // Invalid crt // Invalid key // Invalid ca // Missing crt // Missing key // Missing ca // PFX: // Valid pfx and passphrase // Valid pfx, passphrase // Valid pfx, INVALID passphrase (invalid and not there) // Invalid pfx // Missing pfx // Missing passphrase // // Neither PEM nor PFX supplied function createCertAndKey (): [object, object] { let keys = pki.rsa.generateKeyPair(2048) let cert = pki.createCertificate() cert.publicKey = keys.publicKey cert.serialNumber = '01' cert.validity.notBefore = new Date() cert.validity.notAfter = new Date() cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1) let attrs = [ { name: 'commonName', value: 'example.org', }, { name: 'countryName', value: 'US', }, { shortName: 'ST', value: 'California', }, { name: 'localityName', value: 'San Fran', }, { name: 'organizationName', value: 'Test', }, { shortName: 'OU', value: 'Test', }, ] cert.setSubject(attrs) cert.setIssuer(attrs) cert.sign(keys.privateKey) return [cert, keys.privateKey] } function createPemFiles ( certFilepath: string, keyFilepath: string, passphraseFilepath: string | undefined, passphrase: string | undefined, ) { const certInfo = createCertAndKey() fs.writeFileSync(certFilepath, pki.certificateToPem(certInfo[0])) const key = passphrase ? pki.encryptRsaPrivateKey(certInfo[1], passphrase) : pki.privateKeyToPem(certInfo[1]) fs.writeFileSync(keyFilepath, key) if (passphraseFilepath) { fs.writeFileSync(passphraseFilepath, passphrase) } } function createPfxFiles ( certFilepath: string, passphraseFilepath: string | undefined, passphrase: string | undefined, ) { const certInfo = createCertAndKey() let p12Asn1 = pkcs12.toPkcs12Asn1(certInfo[1], [certInfo[0]], passphrase) fs.writeFileSync(certFilepath, asn1.toDer(p12Asn1).getBytes(), { encoding: 'binary' }) if (passphraseFilepath) { fs.writeFileSync(passphraseFilepath, passphrase) } } function createCaFile (filepath: string) { const certInfo = createCertAndKey() fs.writeFileSync(filepath, pki.certificateToPem(certInfo[0])) } function createUniqueUrl (): string { return `http://${uuidv4()}` } function createSinglePemConfig ( url, caFilepath, pemFilepath, pemKeyFilepath, pemPassphraseFilepath, ) { return { projectRoot: __dirname, clientCertificates: [ { url, ca: [caFilepath], certs: [ { cert: pemFilepath, key: pemKeyFilepath, passphrase: pemPassphraseFilepath, }, ], }, ], } } function createSinglePfxConfig ( url, caFilepath, pfxFilepath, pfxPassphraseFilepath, ) { return { projectRoot: __dirname, clientCertificates: [ { url, ca: [caFilepath], certs: [ { pfx: pfxFilepath, passphrase: pfxPassphraseFilepath, }, ], }, ], } } const tempDirName = 'server-pki-tests' const caFilename = 'testca.crt' const pemFilename = 'testpem.crt' const pemKeyFilename = 'testpem.key' const pemPassphraseFilename = 'testpem.pass' const pfxFilename = 'testpfx.p12' const pfxPassphraseFilename = 'testpfx.pass' const tempDirPath = path.join(os.tmpdir(), tempDirName) const caFilepath = path.join(tempDirPath, caFilename) const pemFilepath = path.join(tempDirPath, pemFilename) const pemKeyFilepath = path.join(tempDirPath, pemKeyFilename) const pemPassphraseFilepath = path.join(tempDirPath, pemPassphraseFilename) const pfxFilepath = path.join(tempDirPath, pfxFilename) const pfxPassphraseFilepath = path.join(tempDirPath, pfxPassphraseFilename) describe('lib/client-certificates', () => { before(() => { if (!fs.existsSync(tempDirPath)) { fs.mkdirSync(tempDirPath) } }) after(() => { fs.rmdirSync(tempDirPath, { recursive: true }) }) context('loads cert files', () => { it('loads valid single PEM (no passphrase) and CA via absolute pathing', () => { createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) createCaFile(caFilepath) const url = createUniqueUrl() const config = createSinglePemConfig( url, caFilepath, pemFilepath, pemKeyFilepath, undefined, ) const pemFileData = fs.readFileSync(pemFilepath) const keyFileData = fs.readFileSync(pemKeyFilepath) const caFileData = fs.readFileSync(caFilepath) loadClientCertificateConfig(config) const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( urllib.parse(url), ) expect(options).not.to.be.null expect(options.ca.length).to.eq(1) expect(options.ca[0]).to.deep.equal(caFileData) expect(options.pfx).to.be.empty expect(options.cert.length).to.eq(1) expect(options.cert[0]).to.deep.equal(pemFileData) expect(options.key.length).to.eq(1) expect(options.key[0].passphrase).to.be.undefined expect(options.key[0].pem).to.deep.equal(keyFileData) }) it('loads valid multiple PEMs (no passphrase) and CAs', () => { const pemFilepath1 = path.join(tempDirPath, 'testpem1.crt') const keyFilepath1 = path.join(tempDirPath, 'testpem1.key') const caFilepath1 = path.join(tempDirPath, 'testca1.crt') const pemFilepath2 = path.join(tempDirPath, 'testpem2.crt') const keyFilepath2 = path.join(tempDirPath, 'testpem2.key') const caFilepath2 = path.join(tempDirPath, 'testca2.crt') const pemFilepath3 = path.join(tempDirPath, 'testpem3.crt') const keyFilepath3 = path.join(tempDirPath, 'testpem3.key') const caFilepath3 = path.join(tempDirPath, 'testca3.crt') createPemFiles(pemFilepath1, keyFilepath1, undefined, undefined) createPemFiles(pemFilepath2, keyFilepath2, undefined, undefined) createPemFiles(pemFilepath3, keyFilepath3, undefined, undefined) createCaFile(caFilepath1) createCaFile(caFilepath2) createCaFile(caFilepath3) const url = createUniqueUrl() const config = { projectRoot: __dirname, clientCertificates: [ { url, ca: [caFilepath1, caFilepath2, caFilepath3], certs: [ { cert: pemFilepath1, key: keyFilepath1, }, { cert: pemFilepath2, key: keyFilepath2, }, { cert: pemFilepath3, key: keyFilepath3, }, ], }, ], } const pemFileData1 = fs.readFileSync(pemFilepath1) const keyFileData1 = fs.readFileSync(keyFilepath1) const caFileData1 = fs.readFileSync(caFilepath1) const pemFileData2 = fs.readFileSync(pemFilepath2) const keyFileData2 = fs.readFileSync(keyFilepath2) const caFileData2 = fs.readFileSync(caFilepath2) const pemFileData3 = fs.readFileSync(pemFilepath3) const keyFileData3 = fs.readFileSync(keyFilepath3) const caFileData3 = fs.readFileSync(caFilepath3) loadClientCertificateConfig(config) const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( urllib.parse(url), ) expect(options).not.to.be.null expect(options.ca.length).to.eq(3) expect(options.ca[0]).to.deep.equal(caFileData1) expect(options.ca[1]).to.deep.equal(caFileData2) expect(options.ca[2]).to.deep.equal(caFileData3) expect(options.pfx).to.be.empty expect(options.cert.length).to.eq(3) expect(options.cert[0]).to.deep.equal(pemFileData1) expect(options.cert[1]).to.deep.equal(pemFileData2) expect(options.cert[2]).to.deep.equal(pemFileData3) expect(options.key.length).to.eq(3) expect(options.key[0].passphrase).to.be.undefined expect(options.key[0].pem).to.deep.equal(keyFileData1) expect(options.key[1].pem).to.deep.equal(keyFileData2) expect(options.key[2].pem).to.deep.equal(keyFileData3) }) it('loads valid single PEM (with passphrase)', () => { const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) const url = createUniqueUrl() const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, pemPassphraseFilepath, ) const pemFileData = fs.readFileSync(pemFilepath) const keyFileData = fs.readFileSync(pemKeyFilepath) loadClientCertificateConfig(config) const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( urllib.parse(url), ) expect(options).not.to.be.null expect(options.ca.length).to.eq(0) expect(options.pfx).to.be.empty expect(options.cert.length).to.eq(1) expect(options.cert[0]).to.deep.equal(pemFileData) expect(options.key.length).to.eq(1) expect(options.key[0].passphrase).to.equal(passphrase) expect(options.key[0].pem).to.deep.equal(keyFileData) }) it('loads valid single PEM and CA via relative pathing', () => { createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) createCaFile(caFilepath) const relativeCaFilepath = path.relative(__dirname, caFilepath) const relativePemFilepath = path.relative(__dirname, pemFilepath) const relativeKeyFilepath = path.relative(__dirname, pemKeyFilepath) const url = createUniqueUrl() const config = createSinglePemConfig( url, relativeCaFilepath, relativePemFilepath, relativeKeyFilepath, undefined, ) loadClientCertificateConfig(config) const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( urllib.parse(url), ) expect(options).not.to.be.null expect(options.ca.length).to.eq(1) expect(options.pfx).to.be.empty expect(options.cert.length).to.eq(1) expect(options.key.length).to.eq(1) expect(options.key[0].passphrase).to.be.undefined }) // TODO: fix this flaky test it.skip('detects invalid PEM key passphrase', () => { const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) fs.writeFileSync(pemPassphraseFilepath, 'not-the-passphrase') const url = createUniqueUrl() const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, pemPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw( 'Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn\'t have unexpected whitespace at the end)', ) }) it('detects invalid PEM key file (no passphrase)', () => { createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined) fs.writeFileSync(pemKeyFilepath, 'not-a-key') const url = createUniqueUrl() const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, undefined, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Invalid PEM formatted message') }) it('detects invalid PEM key file (with passphrase)', () => { const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) fs.writeFileSync(pemKeyFilepath, 'not-a-key') const url = createUniqueUrl() const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, pemPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Invalid PEM formatted message') }) it('detects invalid PEM cert file', () => { fs.writeFileSync(pemFilepath, 'not-a-cert') const url = createUniqueUrl() const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, undefined, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Cannot parse PEM cert') }) it('detects invalid CA file', () => { fs.writeFileSync(caFilepath, 'not-a-cert') const url = createUniqueUrl() const config = createSinglePemConfig( url, caFilepath, pemFilepath, pemKeyFilepath, undefined, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Cannot parse CA cert') }) it('detects missing PEM cert file', () => { const url = createUniqueUrl() const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) const config = createSinglePemConfig( url, undefined, 'not-a-path', pemKeyFilepath, pemPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('no such file or directory') }) it('detects missing PEM key file', () => { const url = createUniqueUrl() const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) const config = createSinglePemConfig( url, undefined, pemFilepath, 'not-a-path', pemPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('no such file or directory') }) it('detects missing PEM passphrase file', () => { const url = createUniqueUrl() const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) const config = createSinglePemConfig( url, undefined, pemFilepath, pemKeyFilepath, 'not-a-path', ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('no such file or directory') }) it('detects missing CA file', () => { const url = createUniqueUrl() const passphrase = 'a_phrase' createPemFiles( pemFilepath, pemKeyFilepath, pemPassphraseFilepath, passphrase, ) createCaFile(caFilepath) const config = createSinglePemConfig( url, 'not-a-path', pemFilepath, pemKeyFilepath, pemPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('no such file or directory') }) it('loads valid single PFX', () => { const passphrase = 'a_passphrase' createPfxFiles(pfxFilepath, pfxPassphraseFilepath, passphrase) const url = createUniqueUrl() const config = createSinglePfxConfig( url, undefined, pfxFilepath, pfxPassphraseFilepath, ) const pfxFileData = fs.readFileSync(pfxFilepath) loadClientCertificateConfig(config) const options = clientCertificateStore.getClientCertificateAgentOptionsForUrl( urllib.parse(url), ) expect(options).not.to.be.null expect(options.cert).to.be.empty expect(options.pfx.length).to.eq(1) expect(options.pfx[0].buf).to.deep.equal(pfxFileData) expect(options.pfx[0].passphrase).to.equal(passphrase) }) it('detects invalid PFX passphrase', () => { const passphrase = 'a_passphrase' createPfxFiles(pfxFilepath, undefined, passphrase) fs.writeFileSync(pfxPassphraseFilepath, 'not-a-passphrase') const config = createSinglePfxConfig( createUniqueUrl(), undefined, pfxFilepath, pfxPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Invalid password?') }) it('detects missing PFX passphrase file', () => { const passphrase = 'a_passphrase' createPfxFiles(pfxFilepath, undefined, passphrase) const config = createSinglePfxConfig( createUniqueUrl(), undefined, pfxFilepath, pfxPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Invalid password?') }) it('detects invalid PFX file', () => { fs.writeFileSync(pfxFilepath, 'not-a-pfx') const config = createSinglePfxConfig( createUniqueUrl(), undefined, pfxFilepath, pfxPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Unable to load PFX file: Too few bytes to read ASN.1 value') }) it('detects missing PFX file', () => { const config = createSinglePfxConfig( createUniqueUrl(), undefined, pfxFilepath, pfxPassphraseFilepath, ) const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Unable to load PFX file: Too few bytes to read ASN.1 value') }) it('detects neither PEM nor PFX supplied', () => { const config = { projectRoot: __dirname, clientCertificates: [ { url: createUniqueUrl(), ca: [], certs: [ ], }, ], } const act = () => { loadClientCertificateConfig(config) } expect(act).to.throw('Either PEM or PFX must be supplied') }) }) })