@peculiar/fortify-client-core
Version:
Fortify client core API for quickly connect to Fortify App
594 lines (593 loc) • 22.2 kB
JavaScript
/**
* @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);
}
}