Home Reference Source

src/PFX.js

import * as asn1js from "asn1js";
import { getParametersValue, utilConcatBuf, clearProps } from "pvutils";
import { getCrypto, getEngine, getRandomValues, getOIDByAlgorithm, getAlgorithmByOID } from "./common.js";
import ContentInfo from "./ContentInfo.js";
import MacData from "./MacData.js";
import DigestInfo from "./DigestInfo.js";
import AlgorithmIdentifier from "./AlgorithmIdentifier.js";
import SignedData from "./SignedData.js";
import EncapsulatedContentInfo from "./EncapsulatedContentInfo.js";
import Attribute from "./Attribute.js";
import SignerInfo from "./SignerInfo.js";
import IssuerAndSerialNumber from "./IssuerAndSerialNumber.js";
import SignedAndUnsignedAttributes from "./SignedAndUnsignedAttributes.js";
import AuthenticatedSafe from "./AuthenticatedSafe.js";
//**************************************************************************************
/**
 * Class from RFC7292
 */
export default class PFX 
{
	//**********************************************************************************
	/**
	 * Constructor for PFX class
	 * @param {Object} [parameters={}]
	 * @property {Object} [schema] asn1js parsed value
	 */
	constructor(parameters = {})
	{
		//region Internal properties of the object
		/**
		 * @type {number}
		 * @description version
		 */
		this.version = getParametersValue(parameters, "version", PFX.defaultValues("version"));
		/**
		 * @type {ContentInfo}
		 * @description authSafe
		 */
		this.authSafe = getParametersValue(parameters, "authSafe", PFX.defaultValues("authSafe"));
		
		if("macData" in parameters)
			/**
			 * @type {MacData}
			 * @description macData
			 */
			this.macData = getParametersValue(parameters, "macData", PFX.defaultValues("macData"));
		
		if("parsedValue" in parameters)
			/**
			 * @type {*}
			 * @description parsedValue
			 */
			this.parsedValue = getParametersValue(parameters, "parsedValue", PFX.defaultValues("parsedValue"));
		//endregion
		
		//region If input argument array contains "schema" for this object
		if("schema" in parameters)
			this.fromSchema(parameters.schema);
		//endregion
	}
	//**********************************************************************************
	/**
	 * Return default values for all class members
	 * @param {string} memberName String name for a class member
	 */
	static defaultValues(memberName)
	{
		switch(memberName)
		{
			case "version":
				return 3;
			case "authSafe":
				return (new ContentInfo());
			case "macData":
				return (new MacData());
			case "parsedValue":
				return {};
			default:
				throw new Error(`Invalid member name for PFX class: ${memberName}`);
		}
	}
	//**********************************************************************************
	/**
	 * Compare values with default values for all class members
	 * @param {string} memberName String name for a class member
	 * @param {*} memberValue Value to compare with default value
	 */
	static compareWithDefault(memberName, memberValue)
	{
		switch(memberName)
		{
			case "version":
				return (memberValue === PFX.defaultValues(memberName));
			case "authSafe":
				return ((ContentInfo.compareWithDefault("contentType", memberValue.contentType)) &&
				(ContentInfo.compareWithDefault("content", memberValue.content)));
			case "macData":
				return ((MacData.compareWithDefault("mac", memberValue.mac)) &&
				(MacData.compareWithDefault("macSalt", memberValue.macSalt)) &&
				(MacData.compareWithDefault("iterations", memberValue.iterations)));
			case "parsedValue":
				return ((memberValue instanceof Object) && (Object.keys(memberValue).length === 0));
			default:
				throw new Error(`Invalid member name for PFX class: ${memberName}`);
		}
	}
	//**********************************************************************************
	/**
	 * Return value of asn1js schema for current class
	 * @param {Object} parameters Input parameters for the schema
	 * @returns {Object} asn1js schema object
	 */
	static schema(parameters = {})
	{
		//PFX ::= SEQUENCE {
		//    version		INTEGER {v3(3)}(v3,...),
		//    authSafe	ContentInfo,
		//    macData    	MacData OPTIONAL
		//}
		
		/**
		 * @type {Object}
		 * @property {string} [blockName]
		 * @property {string} [version]
		 * @property {string} [authSafe]
		 * @property {string} [macData]
		 */
		const names = getParametersValue(parameters, "names", {});
		
		return (new asn1js.Sequence({
			name: (names.blockName || ""),
			value: [
				new asn1js.Integer({ name: (names.version || "version") }),
				ContentInfo.schema(names.authSafe || {
					names: {
						blockName: "authSafe"
					}
				}),
				MacData.schema(names.macData || {
					names: {
						blockName: "macData",
						optional: true
					}
				})
			]
		}));
	}
	//**********************************************************************************
	/**
	 * Convert parsed asn1js object into current class
	 * @param {!Object} schema
	 */
	fromSchema(schema)
	{
		//region Clear input data first
		clearProps(schema, [
			"version",
			"authSafe",
			"macData"
		]);
		//endregion
		
		//region Check the schema is valid
		const asn1 = asn1js.compareSchema(schema,
			schema,
			PFX.schema({
				names: {
					version: "version",
					authSafe: {
						names: {
							blockName: "authSafe"
						}
					},
					macData: {
						names: {
							blockName: "macData"
						}
					}
				}
			})
		);
		
		if(asn1.verified === false)
			throw new Error("Object's schema was not verified against input data for PFX");
		//endregion
		
		//region Get internal properties from parsed schema
		this.version = asn1.result.version.valueBlock.valueDec;
		this.authSafe = new ContentInfo({ schema: asn1.result.authSafe });
		
		if("macData" in asn1.result)
			this.macData = new MacData({ schema: asn1.result.macData });
		//endregion
	}
	//**********************************************************************************
	/**
	 * Convert current object to asn1js object and set correct values
	 * @returns {Object} asn1js object
	 */
	toSchema()
	{
		//region Construct and return new ASN.1 schema for this object
		const outputArray = [
			new asn1js.Integer({ value: this.version }),
			this.authSafe.toSchema()
		];
		
		if("macData" in this)
			outputArray.push(this.macData.toSchema());
		
		return (new asn1js.Sequence({
			value: outputArray
		}));
		//endregion
	}
	//**********************************************************************************
	/**
	 * Convertion for the class to JSON object
	 * @returns {Object}
	 */
	toJSON()
	{
		const output = {
			version: this.version,
			authSafe: this.authSafe.toJSON()
		};
		
		if("macData" in this)
			output.macData = this.macData.toJSON();
		
		return output;
	}
	//**********************************************************************************
	/**
	 * Making ContentInfo from "parsedValue" object
	 * @param {Object} parameters Parameters, specific to each "integrity mode"
	 */
	makeInternalValues(parameters = {})
	{
		//region Check mandatory parameter
		if((parameters instanceof Object) === false)
			return Promise.reject("The \"parameters\" must has \"Object\" type");
		
		if(("parsedValue" in this) === false)
			return Promise.reject("Please call \"parseValues\" function first in order to make \"parsedValue\" data");
		
		if(("integrityMode" in this.parsedValue) === false)
			return Promise.reject("Absent mandatory parameter \"integrityMode\" inside \"parsedValue\"");
		//endregion
		
		//region Initial variables
		let sequence = Promise.resolve();
		//endregion
		
		//region Get a "crypto" extension
		const crypto = getCrypto();
		if(typeof crypto === "undefined")
			return Promise.reject("Unable to create WebCrypto object");
		//endregion
		
		//region Makes values for each particular integrity mode
		//region Check that we do have neccessary fields in "parsedValue" object
		if(("authenticatedSafe" in this.parsedValue) === false)
			return Promise.reject("Absent mandatory parameter \"authenticatedSafe\" in \"parsedValue\"");
		//endregion
		
		switch(this.parsedValue.integrityMode)
		{
			//region HMAC-based integrity
			case 0:
				{
					//region Check additional mandatory parameters
					if(("iterations" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"iterations\"");
				
					if(("pbkdf2HashAlgorithm" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"pbkdf2HashAlgorithm\"");
				
					if(("hmacHashAlgorithm" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"hmacHashAlgorithm\"");
				
					if(("password" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"password\"");
					//endregion
				
					//region Initial variables
					const saltBuffer = new ArrayBuffer(64);
					const saltView = new Uint8Array(saltBuffer);
				
					getRandomValues(saltView);
					
					const data = this.parsedValue.authenticatedSafe.toSchema().toBER(false);

					this.authSafe = new ContentInfo({
						contentType: "1.2.840.113549.1.7.1",
						content: new asn1js.OctetString({ valueHex: data })
					});
					//endregion
					
					//region Call current crypto engine for making HMAC-based data stamp
					const engine = getEngine();
					
					if(("stampDataWithPassword" in engine.subtle) === false)
						return Promise.reject(`No support for "stampDataWithPassword" in current engine "${engine.name}"`);
					
					sequence = sequence.then(() =>
						engine.subtle.stampDataWithPassword({
							password: parameters.password,
							hashAlgorithm: parameters.hmacHashAlgorithm,
							salt: saltBuffer,
							iterationCount: parameters.iterations,
							contentToStamp: data
						})
					);
					//endregion
					
					//region Make "MacData" values
					sequence = sequence.then(
						result =>
						{
							this.macData = new MacData({
								mac: new DigestInfo({
									digestAlgorithm: new AlgorithmIdentifier({
										algorithmId: getOIDByAlgorithm({ name: parameters.hmacHashAlgorithm })
									}),
									digest: new asn1js.OctetString({ valueHex: result })
								}),
								macSalt: new asn1js.OctetString({ valueHex: saltBuffer }),
								iterations: parameters.iterations
							});
						},
						error => Promise.reject(error)
					);
					//endregion
					//endregion
				}
				break;
			//endregion
			//region publicKey-based integrity
			case 1:
				{
					//region Check additional mandatory parameters
					if(("signingCertificate" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"signingCertificate\"");
				
					if(("privateKey" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"privateKey\"");
				
					if(("hashAlgorithm" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"hashAlgorithm\"");
					//endregion
				
					//region Making data to be signed
					// NOTE: all internal data for "authenticatedSafe" must be already prepared.
					// Thus user must call "makeValues" for all internal "SafeContent" value with appropriate parameters.
					// Or user can choose to use values from initial parsing of existing PKCS#12 data.
				
					const toBeSigned = this.parsedValue.authenticatedSafe.toSchema().toBER(false);
					//endregion
					
					//region Initial variables
					const cmsSigned = new SignedData({
						version: 1,
						encapContentInfo: new EncapsulatedContentInfo({
							eContentType: "1.2.840.113549.1.7.1", // "data" content type
							eContent: new asn1js.OctetString({ valueHex: toBeSigned })
						}),
						certificates: [parameters.signingCertificate]
					});
					//endregion
					
					//region Making additional attributes for CMS Signed Data
					//region Create a message digest
					sequence = sequence.then(
						() => crypto.digest({ name: parameters.hashAlgorithm }, new Uint8Array(toBeSigned))
					);
					//endregion
				
					//region Combine all signed extensions
					sequence = sequence.then(
						result =>
						{
							//region Initial variables
							const signedAttr = [];
							//endregion
							
							//region contentType
							signedAttr.push(new Attribute({
								type: "1.2.840.113549.1.9.3",
								values: [
									new asn1js.ObjectIdentifier({ value: "1.2.840.113549.1.7.1" })
								]
							}));
							//endregion
							//region signingTime
							signedAttr.push(new Attribute({
								type: "1.2.840.113549.1.9.5",
								values: [
									new asn1js.UTCTime({ valueDate: new Date() })
								]
							}));
							//endregion
							//region messageDigest
							signedAttr.push(new Attribute({
								type: "1.2.840.113549.1.9.4",
								values: [
									new asn1js.OctetString({ valueHex: result })
								]
							}));
							//endregion
							
							//region Making final value for "SignerInfo" type
							cmsSigned.signerInfos.push(new SignerInfo({
								version: 1,
								sid: new IssuerAndSerialNumber({
									issuer: parameters.signingCertificate.issuer,
									serialNumber: parameters.signingCertificate.serialNumber
								}),
								signedAttrs: new SignedAndUnsignedAttributes({
									type: 0,
									attributes: signedAttr
								})
							}));
							//endregion
						},
						error => Promise.reject(`Error during making digest for message: ${error}`)
					);
					//endregion
					//endregion
				
					//region Signing CMS Signed Data
					sequence = sequence.then(
						() => cmsSigned.sign(parameters.privateKey, 0, parameters.hashAlgorithm)
					);
					//endregion
				
					//region Making final CMS_CONTENT_INFO type
					sequence = sequence.then(
						() =>
						{
							this.authSafe = new ContentInfo({
								contentType: "1.2.840.113549.1.7.2",
								content: cmsSigned.toSchema(true)
							});
						},
						error => Promise.reject(`Error during making signature: ${error}`)
					);
					//endregion
				}
				break;
			//endregion
			//region default
			default:
				return Promise.reject(`Parameter "integrityMode" has unknown value: ${parameters.integrityMode}`);
			//endregion
		}
		//endregion
		
		return sequence;
	}
	//**********************************************************************************
	parseInternalValues(parameters)
	{
		//region Check input data from "parameters" 
		if((parameters instanceof Object) === false)
			return Promise.reject("The \"parameters\" must has \"Object\" type");
		
		if(("checkIntegrity" in parameters) === false)
			parameters.checkIntegrity = true;
		//endregion 
		
		//region Initial variables 
		let sequence = Promise.resolve();
		//endregion 
		
		//region Get a "crypto" extension 
		const crypto = getCrypto();
		if(typeof crypto === "undefined")
			return Promise.reject("Unable to create WebCrypto object");
		//endregion 
		
		//region Create value for "this.parsedValue.authenticatedSafe" and check integrity 
		this.parsedValue = {};
		
		switch(this.authSafe.contentType)
		{
			//region data 
			case "1.2.840.113549.1.7.1":
				{
					//region Check additional mandatory parameters
					if(("password" in parameters) === false)
						return Promise.reject("Absent mandatory parameter \"password\"");
					//endregion
				
					//region Integrity based on HMAC
					this.parsedValue.integrityMode = 0;
					//endregion
				
					//region Check that we do have OCTETSTRING as "content"
					if((this.authSafe.content instanceof asn1js.OctetString) === false)
						return Promise.reject("Wrong type of \"this.authSafe.content\"");
					//endregion
					
					//region Check we have "constructive encoding" for AuthSafe content
					let authSafeContent = new ArrayBuffer(0);
					
					if(this.authSafe.content.valueBlock.isConstructed)
					{
						for(const contentValue of this.authSafe.content.valueBlock.value)
							authSafeContent = utilConcatBuf(authSafeContent, contentValue.valueBlock.valueHex);
					}
					else
						authSafeContent = this.authSafe.content.valueBlock.valueHex;
					//endregion
					
					//region Parse internal ASN.1 data
					const asn1 = asn1js.fromBER(authSafeContent);
					if(asn1.offset === (-1))
						return Promise.reject("Error during parsing of ASN.1 data inside \"this.authSafe.content\"");
					//endregion
				
					//region Set "authenticatedSafe" value
					this.parsedValue.authenticatedSafe = new AuthenticatedSafe({ schema: asn1.result });
					//endregion
				
					//region Check integrity
					if(parameters.checkIntegrity)
					{
						//region Check that "MacData" exists
						if(("macData" in this) === false)
							return Promise.reject("Absent \"macData\" value, can not check PKCS#12 data integrity");
						//endregion
						
						//region Initial variables
						const hashAlgorithm = getAlgorithmByOID(this.macData.mac.digestAlgorithm.algorithmId);
						if(("name" in hashAlgorithm) === false)
							return Promise.reject(`Unsupported digest algorithm: ${this.macData.mac.digestAlgorithm.algorithmId}`);
						//endregion
						
						//region Call current crypto engine for verifying HMAC-based data stamp
						const engine = getEngine();
						
						sequence = sequence.then(() =>
							engine.subtle.verifyDataStampedWithPassword({
								password: parameters.password,
								hashAlgorithm: hashAlgorithm.name,
								salt: this.macData.macSalt.valueBlock.valueHex,
								iterationCount: this.macData.iterations,
								contentToVerify: authSafeContent,
								signatureToVerify: this.macData.mac.digest.valueBlock.valueHex
							})
						);
						//endregion

						//region Verify HMAC signature
						sequence = sequence.then(
							result =>
							{
								if(result === false)
									return Promise.reject("Integrity for the PKCS#12 data is broken!");
								
								return Promise.resolve();
							},
							error => Promise.reject(error)
						);
						//endregion
					}
					//endregion
				}
				break;
			//endregion 
			//region signedData 
			case "1.2.840.113549.1.7.2":
				{
					//region Integrity based on signature using public key
					this.parsedValue.integrityMode = 1;
					//endregion
				
					//region Parse CMS Signed Data
					const cmsSigned = new SignedData({ schema: this.authSafe.content });
					//endregion
				
					//region Check that we do have OCTETSTRING as "content"
					if(("eContent" in cmsSigned.encapContentInfo) === false)
						return Promise.reject("Absent of attached data in \"cmsSigned.encapContentInfo\"");
				
					if((cmsSigned.encapContentInfo.eContent instanceof asn1js.OctetString) === false)
						return Promise.reject("Wrong type of \"cmsSigned.encapContentInfo.eContent\"");
					//endregion
				
					//region Create correct data block for verification
					let data = new ArrayBuffer(0);
				
					if(cmsSigned.encapContentInfo.eContent.idBlock.isConstructed === false)
						data = cmsSigned.encapContentInfo.eContent.valueBlock.valueHex;
					else
					{
						for(let i = 0; i < cmsSigned.encapContentInfo.eContent.valueBlock.value.length; i++)
							data = utilConcatBuf(data, cmsSigned.encapContentInfo.eContent.valueBlock.value[i].valueBlock.valueHex);
					}
					//endregion
				
					//region Parse internal ASN.1 data
					const asn1 = asn1js.fromBER(data);
					if(asn1.offset === (-1))
						return Promise.reject("Error during parsing of ASN.1 data inside \"this.authSafe.content\"");
					//endregion
				
					//region Set "authenticatedSafe" value
					this.parsedValue.authenticatedSafe = new AuthenticatedSafe({ schema: asn1.result });
					//endregion
				
					//region Check integrity
					sequence = sequence.then(
						() => cmsSigned.verify({ signer: 0, checkChain: false })
					).then(
						result =>
						{
							if(result === false)
								return Promise.reject("Integrity for the PKCS#12 data is broken!");
							
							return Promise.resolve();
						},
						error => Promise.reject(`Error during integrity verification: ${error}`)
					);
					//endregion
				}
				break;
			//endregion   
			//region default 
			default:
				return Promise.reject(`Incorrect value for "this.authSafe.contentType": ${this.authSafe.contentType}`);
			//endregion 
		}
		//endregion 
		
		//region Return result of the function 
		return sequence.then(
			() => this,
			error => Promise.reject(`Error during parsing: ${error}`)
		);
		//endregion   
	}
	//**********************************************************************************
}
//**************************************************************************************