import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { parseCertificate, parseExtensions, parseSubject, formatValues } from 'vault/utils/parse-pki-cert'; import * as asn1js from 'asn1js'; import { fromBase64, stringToArrayBuffer } from 'pvutils'; import { Certificate } from 'pkijs'; import { addHours, fromUnixTime, isSameDay } from 'date-fns'; import errorMessage from 'vault/utils/error-message'; import { SAN_TYPES } from 'vault/utils/parse-pki-cert-oids'; import { certWithoutCN, loadedCert, pssTrueCert, skeletonCert, unsupportedOids, } from 'vault/tests/helpers/pki/values'; module('Integration | Util | parse pki certificate', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { this.getErrorMessages = (certErrors) => certErrors.map((error) => errorMessage(error)); this.certSchema = (cert) => { const cert_base64 = cert.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, ''); const cert_der = fromBase64(cert_base64); const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der)); return new Certificate({ schema: cert_asn1.result }); }; this.parsableLoadedCert = this.certSchema(loadedCert); this.parsableUnsupportedCert = this.certSchema(unsupportedOids); }); test('it parses a certificate with supported values', async function (assert) { assert.expect(2); // certificate contains all allowable params const parsedCert = parseCertificate(loadedCert); assert.propEqual( parsedCert, { alt_names: 'altname1, altname2', can_parse: true, common_name: 'common-name.com', country: 'France', exclude_cn_from_sans: true, expiry_date: {}, ip_sans: 'OCTET STRING : C09E0126', // when parsed, should be 192.158.1.38 issue_date: {}, locality: 'Paris', max_path_length: 17, not_valid_after: 1989622380, not_valid_before: 1674262350, organization: 'Widget', ou: 'Finance', parsing_errors: [], permitted_dns_domains: 'dnsname1.com, dsnname2.com', postal_code: '123456', province: 'Champagne', serial_number: 'cereal1292', signature_bits: '512', street_address: '234 sesame', ttl: '87600h', uri_sans: 'testuri1, testuri2', use_pss: false, }, 'it contains expected attrs, cn is excluded from alt_names (exclude_cn_from_sans: true)' ); assert.ok( isSameDay( addHours(fromUnixTime(parsedCert.not_valid_before), Number(parsedCert.ttl.split('h')[0])), fromUnixTime(parsedCert.not_valid_after), 'ttl value is correct' ) ); }); test('it parses a certificate with use_pass=true and exclude_cn_from_sans=false', async function (assert) { assert.expect(2); const parsedPssCert = parseCertificate(pssTrueCert); assert.propContains( parsedPssCert, { signature_bits: '256', ttl: '768h', use_pss: true }, 'returns signature_bits value and use_pss is true' ); assert.propContains( parsedPssCert, { alt_names: 'common-name.com', can_parse: true, common_name: 'common-name.com', exclude_cn_from_sans: false, }, 'common name is included in alt_names' ); }); test('it returns parsing_errors when certificate has unsupported values', async function (assert) { assert.expect(2); const parsedCert = parseCertificate(unsupportedOids); // contains unsupported subject and extension OIDs const parsingErrors = this.getErrorMessages(parsedCert.parsing_errors); assert.propContains( parsedCert, { alt_names: 'dns-NameSupported', common_name: 'fancy-cert-unsupported-subj-and-ext-oids', ip_sans: 'OCTET STRING : C09E0126', // when parsed, should be 192.158.1.38 parsing_errors: [{}, {}, {}], uri_sans: 'uriSupported', }, 'supported values are present when unsupported values exist' ); assert.propEqual( parsingErrors, [ 'certificate contains unsupported subject OIDs', 'certificate contains unsupported extension OIDs', 'subjectAltName contains unsupported types', ], 'it contains expected error messages' ); }); test('it returns attr with a null value if nonexistent', async function (assert) { assert.expect(1); const onlyHasCommonName = parseCertificate(skeletonCert); assert.propContains( onlyHasCommonName, { alt_names: 'common-name.com', common_name: 'common-name.com', country: null, ip_sans: null, locality: null, max_path_length: undefined, organization: null, ou: null, postal_code: null, province: null, serial_number: null, street_address: null, uri_sans: null, }, 'it contains expected attrs' ); }); test('the helper parseSubject returns object with correct key/value pairs', async function (assert) { assert.expect(3); const supportedSubj = parseSubject(this.parsableLoadedCert.subject.typesAndValues); assert.propEqual( supportedSubj, { subjErrors: [], subjValues: { common_name: 'common-name.com', country: 'France', locality: 'Paris', organization: 'Widget', ou: 'Finance', postal_code: '123456', province: 'Champagne', serial_number: 'cereal1292', street_address: '234 sesame', }, }, 'it returns supported subject values' ); const unsupportedSubj = parseSubject(this.parsableUnsupportedCert.subject.typesAndValues); assert.propEqual( this.getErrorMessages(unsupportedSubj.subjErrors), ['certificate contains unsupported subject OIDs'], 'it returns subject errors' ); assert.ok( unsupportedSubj.subjErrors.every((e) => e instanceof Error), 'subjErrors contain error objects' ); }); test('the helper parseExtensions returns object with correct key/value pairs', async function (assert) { assert.expect(9); // assert supported extensions return correct type const supportedExtensions = parseExtensions(this.parsableLoadedCert.extensions); let { extValues, extErrors } = supportedExtensions; for (const keyName in SAN_TYPES) { assert.ok(Array.isArray(extValues[keyName]), `${keyName} is an array`); } assert.ok(Array.isArray(extValues.permitted_dns_domains), 'permitted_dns_domains is an array'); assert.ok(Number.isInteger(extValues.max_path_length), 'max_path_length is an integer'); // TODO add assertion for key_usage assert.strictEqual(extErrors.length, 0, 'no extension errors'); // assert unsupported extensions return errors const unsupportedExt = parseExtensions(this.parsableUnsupportedCert.extensions); ({ extValues, extErrors } = unsupportedExt); assert.propEqual( this.getErrorMessages(extErrors), ['certificate contains unsupported extension OIDs', 'subjectAltName contains unsupported types'], 'it returns extension errors' ); assert.ok( extErrors.every((e) => e instanceof Error), 'subjErrors contain error objects' ); assert.ok(Number.isInteger(extValues.max_path_length), 'max_path_length is an integer'); }); test('the helper formatValues returns object with correct types', async function (assert) { assert.expect(1); const supportedSubj = parseSubject(this.parsableLoadedCert.subject.typesAndValues); const supportedExtensions = parseExtensions(this.parsableLoadedCert.extensions); assert.propContains( formatValues(supportedSubj, supportedExtensions), { alt_names: 'altname1, altname2', ip_sans: 'OCTET STRING : C09E0126', // when parsed, should be 192.158.1.38 permitted_dns_domains: 'dnsname1.com, dsnname2.com', uri_sans: 'testuri1, testuri2', parsing_errors: [], exclude_cn_from_sans: true, }, `values for ${Object.keys(SAN_TYPES).join(', ')} are comma separated strings (and no longer arrays)` ); }); test('it fails silently when passed null', async function (assert) { assert.expect(3); const parsedCert = parseCertificate(certWithoutCN); assert.propEqual( parsedCert, { can_parse: true, common_name: null, country: null, exclude_cn_from_sans: false, expiry_date: {}, issue_date: {}, locality: null, max_path_length: 10, not_valid_after: 1989876490, not_valid_before: 1674516490, organization: null, ou: null, parsing_errors: [{}, {}], postal_code: null, province: null, serial_number: null, signature_bits: '256', street_address: null, ttl: '87600h', use_pss: false, }, 'it parses a cert without CN' ); const parsingErrors = this.getErrorMessages(parsedCert.parsing_errors); assert.propEqual( parsingErrors, ['certificate contains unsupported subject OIDs', 'certificate contains unsupported extension OIDs'], 'it returns correct errors' ); assert.propEqual( formatValues(null, null), { parsing_errors: [Error('error parsing certificate')] }, 'it returns error if unable to format values' ); }); });