UNPKG

@peculiar/fortify-client-core

Version:

Fortify client core API for quickly connect to Fortify App

594 lines (593 loc) 22.2 kB
/** * @license * Copyright (c) Peculiar Ventures, LLC. * * This source code is licensed under the BSD 3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import isMobile from 'ismobilejs'; import { Convert } from 'pvtsutils'; import { Pkcs10CertificateRequestGenerator, X509CertificateGenerator, BasicConstraintsExtension, KeyUsagesExtension, ExtendedKeyUsageExtension, ExtendedKeyUsage, KeyUsageFlags, Name, } from '@peculiar/x509'; import { X509Schema } from './crypto'; import { polyfillInit } from './polyfill'; export var ESignatureAlgorithm; (function (ESignatureAlgorithm) { ESignatureAlgorithm["RSA2048"] = "RSA-2048"; ESignatureAlgorithm["RSA4096"] = "RSA-4096"; ESignatureAlgorithm["ECp256"] = "EC-P256"; ESignatureAlgorithm["ECp384"] = "EC-P384"; ESignatureAlgorithm["ECp521"] = "EC-P521"; })(ESignatureAlgorithm || (ESignatureAlgorithm = {})); export var EHashAlgorithm; (function (EHashAlgorithm) { EHashAlgorithm["SHA_256"] = "SHA-256"; EHashAlgorithm["SHA_384"] = "SHA-384"; EHashAlgorithm["SHA_512"] = "SHA-512"; })(EHashAlgorithm || (EHashAlgorithm = {})); export class FortifyAPI { server; FORTIFY_URL = '127.0.0.1:31337'; CONNECTION_CHECK_TIME = 10000; options = { onDebug: () => { }, onClose: () => { }, onProvidersAdded: () => { }, onProvidersRemoved: () => { }, filters: {}, }; #storage; #isMobile = isMobile(window.navigator); #connectionDetectTimeout; constructor(options) { Object.assign(this.options, options); this.debug({ type: 'initialize', data: this.options, }); } debug(event) { const { onDebug } = this.options; if (onDebug) { onDebug({ time: new Date(), ...event, }); } } isConnectionSupported() { return !this.#isMobile.phone; } async isConnectionDetected() { try { const response = await fetch(`https://${this.FORTIFY_URL}/.well-known/webcrypto-socket`); return response.ok; } catch (error) { return false; } } async isConnectionDetectedAuto() { return new Promise((resolve) => { const tick = async () => { const isConnectionDetected = await this.isConnectionDetected(); if (isConnectionDetected) { resolve(); } else { this.#connectionDetectTimeout = window.setTimeout(tick, this.CONNECTION_CHECK_TIME); } }; tick(); }); } async start() { if (!this.isConnectionSupported()) { this.debug({ type: 'connection_not_supported', }); throw new Error('connection_not_supported'); } if (!await this.isConnectionDetected()) { this.debug({ type: 'connection_not_detected', }); throw new Error('connection_not_detected'); } await this.connect(); } finish() { if (this.server) { this.server.client.close(); this.server = null; } if (this.#connectionDetectTimeout) { clearTimeout(this.#connectionDetectTimeout); this.#connectionDetectTimeout = null; } } async challenge() { const isLoggedIn = await this.server.isLoggedIn(); // request approve connection window if (!isLoggedIn) { return this.server.challenge(); } return undefined; } async login() { try { await this.server.login(); } catch (error) { this.debug({ type: 'connection_key_not_approved', }); throw new Error('connection_key_not_approved'); } } async connect() { await polyfillInit(); if (!this.#storage) { try { this.#storage = await WebcryptoSocket.BrowserStorage.create(); } catch (error) { this.#storage = new WebcryptoSocket.MemoryStorage(); } } return new Promise((resolve, reject) => { if (this.server) { resolve(); } this.server = new WebcryptoSocket.SocketProvider({ storage: this.#storage, }) .connect(this.FORTIFY_URL) .on('error', (error) => { reject(error); }) .on('listening', async () => { resolve(); }) .on('token', async (event) => { if (event.added.length) { this.debug({ type: 'token_added', data: event.added, }); const providers = event.added.filter((provider) => this.providerFilter(provider)); this.options.onProvidersAdded(providers); } if (event.removed.length) { this.debug({ type: 'token_removed', data: event.removed, }); this.options.onProvidersRemoved(event.removed); } }) .on('disconnect', () => { console.log('disconnect'); }) .on('close', () => { this.debug({ type: 'connection_close', }); this.options.onClose(); }); }); } providerFilter(provider) { const { filters } = this.options; // use smartcards only if (filters.onlySmartcards && !provider.isRemovable) { return false; } // use with provider name match only if (filters.providerNameMatch) { if (typeof filters.providerNameMatch === 'string' && !new RegExp(filters.providerNameMatch, 'i').test(provider.name)) { return false; } if (filters.providerNameMatch instanceof RegExp && !filters.providerNameMatch.test(provider.name)) { return false; } } // use with provider ATR value match only if (filters.providerATRMatch) { if (typeof filters.providerATRMatch === 'string' && !new RegExp(filters.providerATRMatch, 'i').test(provider.atr)) { return false; } if (filters.providerATRMatch instanceof RegExp && !filters.providerATRMatch.test(provider.atr)) { return false; } } return true; } async getProviders() { const info = await this.server.info(); this.debug({ type: 'providers', data: { providers: info.providers.map((provider) => ({ id: provider.id, isRemovable: provider.isRemovable, name: provider.name, readOnly: provider.readOnly, version: provider.version, algorithms: provider.algorithms, })), }, }); const providers = info.providers.filter((provider) => this.providerFilter(provider)); this.debug({ type: 'providers_after_filters', data: { providers: providers.map((provider) => provider.id), }, }); return providers; } async getProviderById(providerId, needLogin) { const provider = await this.server.getCrypto(providerId); if (needLogin) { const isLoggedIn = await provider.isLoggedIn(); // request provider for PIN window if (!isLoggedIn) { await provider.login(); } } return provider; } async getCertificatesByProviders(providers) { const certificates = []; // eslint-disable-next-line for (const provider of providers) { const providerCertificates = await this.getCertificatesByProviderId(provider.id); certificates.push(...providerCertificates); } return certificates; } certificatePreFilter(certIndex) { const { filters } = this.options; // use x509 only if (certIndex.split('-')[0] !== 'x509') { return false; } // use with index match only if (filters.certificateIdMatch) { let regExp; if (typeof filters.certificateIdMatch === 'string') { regExp = new RegExp(filters.certificateIdMatch, 'i'); } else if (filters.certificateIdMatch instanceof RegExp) { regExp = filters.certificateIdMatch; } if (regExp && !regExp.test(certIndex)) { return false; } } return true; } async certificateFilter(cert) { const { filters } = this.options; // use not expired only if (!filters.expired && (cert.notAfter.getTime() < Date.now())) { return false; } // use with subject name match only if (filters.subjectDNMatch) { let regExp; if (typeof filters.subjectDNMatch === 'string') { regExp = new RegExp(filters.subjectDNMatch, 'i'); } else if (filters.subjectDNMatch instanceof RegExp) { regExp = filters.subjectDNMatch; } if (regExp && !regExp.test(cert.subjectName)) { return false; } } // use with issuer name match only if (filters.issuerDNMatch) { let regExp; if (typeof filters.issuerDNMatch === 'string') { regExp = new RegExp(filters.issuerDNMatch, 'i'); } else if (filters.issuerDNMatch instanceof RegExp) { regExp = filters.issuerDNMatch; } if (regExp && !regExp.test(cert.issuerName)) { return false; } } const hasKeyUsageFilter = filters.keyUsage && filters.keyUsage.length; if (hasKeyUsageFilter || filters.onlyQualified || !filters.ca) { const x509 = new X509Schema(cert.raw); if (filters.onlyQualified) { const isQualified = x509.isQualified(filters.qualifiedCertificateStatements); if (!isQualified) { return false; } } if (hasKeyUsageFilter) { const hasKeyUsage = x509.hasKeyUsage(filters.keyUsage); if (!hasKeyUsage) { return false; } } if (!filters.ca) { const isCA = x509.isCA(); return !isCA; } } return true; } // eslint-disable-next-line class-methods-use-this async getCertificateByIndex(certIndex, provider, privateKeyId) { let certificate; let raw; try { certificate = (await provider.certStorage.getItem(certIndex)); raw = await provider.certStorage.exportCert('raw', certificate); } catch (error) { console.warn(`Can't read certificate: ${certIndex}`); return undefined; } certificate.raw = raw; const passedFilter = await this.certificateFilter(certificate); if (!passedFilter) { return undefined; } certificate.index = certIndex; certificate.subject = FortifyAPI.getDNValue(certificate.subjectName); certificate.issuer = FortifyAPI.getDNValue(certificate.issuerName); certificate.privateKeyId = privateKeyId; return certificate; } async getCertificatesByProviderId(providerId) { const { filters } = this.options; let provider; try { provider = await this.getProviderById(providerId, filters.onlyWithPrivateKey); } catch (error) { // TODO: Call `onError` with error.message. return []; } const certificates = []; const keyIndexes = await provider.keyStorage.keys(); const certIndexes = await provider.certStorage.keys(); this.debug({ type: 'provider_certificates', data: { providerId, keyIndexes, certIndexes, }, }); // eslint-disable-next-line for (const certIndex of certIndexes) { const passedPreFilter = this.certificatePreFilter(certIndex); if (!passedPreFilter) { continue; } const privateKeyId = FortifyAPI.getCertificatePrivateKeyByIndex(certIndex, keyIndexes); if (filters.onlyWithPrivateKey && !privateKeyId) { continue; } certificates.push(this.getCertificateByIndex(certIndex, provider, privateKeyId)); } const result = await Promise.all(certificates); this.debug({ type: 'provider_certificates_after_filters', data: { certificates: result.map((certificate) => { if (!certificate) { return null; } return { index: certificate.index, providerID: certificate.providerID, issuerName: certificate.issuerName, subjectName: certificate.subjectName, serialNumber: certificate.serialNumber, notAfter: certificate.notAfter, notBefore: certificate.notBefore, privateKeyId: certificate.privateKeyId, raw: Convert.ToBase64(certificate.raw), }; }), }, }); return result.filter((certificate) => !!certificate); } // eslint-disable-next-line class-methods-use-this async getCertificateRequestByIndex(certIndex, provider, privateKeyId) { let certificate; let raw; try { certificate = (await provider.certStorage.getItem(certIndex)); raw = await provider.certStorage.exportCert('raw', certificate); } catch (error) { console.warn(`Can't read certificate request: ${certIndex}`); return undefined; } certificate.raw = raw; certificate.index = certIndex; certificate.subject = FortifyAPI.getDNValue(certificate.subjectName); certificate.privateKeyId = privateKeyId; return certificate; } async getCertificateRequestsByProviderId(providerId) { const { filters } = this.options; let provider; try { provider = await this.getProviderById(providerId, filters.onlyWithPrivateKey); } catch (error) { return []; } const certificates = []; const keyIndexes = await provider.keyStorage.keys(); const certIndexes = await provider.certStorage.keys(); this.debug({ type: 'provider_certificate_requests', data: { providerId, keyIndexes, certIndexes, }, }); // eslint-disable-next-line for (const certIndex of certIndexes) { // use "request" only if (certIndex.split('-')[0] !== 'request') { continue; } const privateKeyId = FortifyAPI.getCertificatePrivateKeyByIndex(certIndex, keyIndexes); if (filters.onlyWithPrivateKey && !privateKeyId) { continue; } certificates.push(this.getCertificateRequestByIndex(certIndex, provider, privateKeyId)); } const result = await Promise.all(certificates); return result.filter((certificate) => !!certificate); } async getCertificateBodyById(providerId, certIndex) { const { filters } = this.options; const provider = await this.getProviderById(providerId, filters.onlyWithPrivateKey); const cert = await provider.certStorage.getItem(certIndex); const rawData = await provider.certStorage.exportCert('raw', cert); return Convert.ToBase64(rawData); } static getCertificatePrivateKeyByIndex(certIndex, keyIndexes) { const certIndexParts = certIndex.split('-'); for (let i = 0; i < keyIndexes.length; i += 1) { const keyIndex = keyIndexes[i]; const keyIndexParts = keyIndex.split('-'); const hasPrivateKey = keyIndexParts[0] === 'private' && keyIndexParts[2] === certIndexParts[2]; if (hasPrivateKey) { return keyIndex; } } return undefined; } static getDNValue(value) { const jsonName = new Name(value).toJSON(); return jsonName.reduce((result, currentValue) => { Object.keys(currentValue).forEach((keyName) => { if (!result[keyName]) { // eslint-disable-next-line no-param-reassign result[keyName] = currentValue[keyName]; } else { result[keyName].push(...currentValue[keyName]); } }); return result; }, {}); } // eslint-disable-next-line class-methods-use-this defineKeysAlgorithm(signatureAlgorithm, hashAlgorithm) { if (signatureAlgorithm.startsWith('EC-')) { return { hash: hashAlgorithm, name: 'ECDSA', namedCurve: signatureAlgorithm.replace('EC-P', 'P-'), }; } if (signatureAlgorithm.startsWith('RSA')) { return { hash: hashAlgorithm, name: 'RSASSA-PKCS1-V1_5', publicExponent: new Uint8Array([1, 0, 1]), modulusLength: Number(signatureAlgorithm.replace('RSA-', '')), }; } throw new Error(`Unsupported signature algorithm name: ${signatureAlgorithm}`); } async createX509(providerId, data) { this.debug({ type: 'create_x509', data: { providerId, ...data, }, }); const keyUsages = ['sign', 'verify']; const now = new Date(); const provider = await this.getProviderById(providerId, true); provider.exportKey = provider.subtle.exportKey.bind(provider.subtle); provider.sign = provider.subtle.sign.bind(provider.subtle); provider.digest = provider.subtle.digest.bind(provider.subtle); const signingAlgorithm = this.defineKeysAlgorithm(data.signatureAlgorithm, data.hashAlgorithm); const keys = await provider.subtle.generateKey(signingAlgorithm, false, keyUsages); const x509 = await X509CertificateGenerator.createSelfSigned({ name: data.subjectName, notBefore: new Date(now.getFullYear(), now.getMonth(), now.getDate()), notAfter: new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()), signingAlgorithm, keys, extensions: [ new BasicConstraintsExtension(false, 0, true), new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true), new ExtendedKeyUsageExtension(data.extensions?.extendedKeyUsage || [ ExtendedKeyUsage.clientAuth, ExtendedKeyUsage.emailProtection, ], true), ], }, provider); const request = await provider.certStorage.importCert('raw', x509.rawData, signingAlgorithm, keyUsages); await provider.keyStorage.setItem(keys.privateKey); await provider.keyStorage.setItem(keys.publicKey); await provider.certStorage.setItem(request); return { der: x509.rawData, pem: x509.toString('pem'), privateKey: keys.privateKey, publicKey: keys.publicKey, }; } async createPKCS10(providerId, data) { this.debug({ type: 'create_pkcs10', data: { providerId, ...data, }, }); const keyUsages = ['sign', 'verify']; const provider = await this.getProviderById(providerId, true); provider.exportKey = provider.subtle.exportKey.bind(provider.subtle); provider.sign = provider.subtle.sign.bind(provider.subtle); provider.digest = provider.subtle.digest.bind(provider.subtle); const signingAlgorithm = this.defineKeysAlgorithm(data.signatureAlgorithm, data.hashAlgorithm); const keys = await provider.subtle.generateKey(signingAlgorithm, false, keyUsages); const pkcs10 = await Pkcs10CertificateRequestGenerator.create({ name: data.subjectName, keys, signingAlgorithm, extensions: [], attributes: [], }, provider); const request = await provider.certStorage.importCert('raw', pkcs10.rawData, signingAlgorithm, keyUsages); await provider.keyStorage.setItem(keys.privateKey); await provider.keyStorage.setItem(keys.publicKey); await provider.certStorage.setItem(request); return { der: pkcs10.rawData, pem: pkcs10.toString('pem'), privateKey: keys.privateKey, publicKey: keys.publicKey, }; } async removeCertificateById(providerId, certIndex) { const provider = await this.getProviderById(providerId); return provider.certStorage.removeItem(certIndex); } }