keyUtils.js

import { coerceToArrayBuffer, coerceToBase64Url, isPem, pemToBase64, abToPem, tools } from "./utils.js";

/**
 * Main COSE labels
 * defined here: https://tools.ietf.org/html/rfc8152#section-7.1
 * used by {@link fromCose}
 * 
 * @private
 */
const coseLabels = {
	1: {
		name: "kty",
		values: {
			1: "OKP",
			2: "EC",
			3: "RSA",
		},
	},
	2: {
		name: "kid",
		values: {},
	},
	3: {
		name: "alg",
		values: {
			"-7": "ECDSA_w_SHA256",
			/* "-8": "EdDSA", */
			"-35": "ECDSA_w_SHA384",
			"-36": "ECDSA_w_SHA512",
			/*"-37": "RSASSA-PSS_w_SHA-256",
			"-38": "RSASSA-PSS_w_SHA-384",
			"-39": "RSASSA-PSS_w_SHA-512",*/
			"-257": "RSASSA-PKCS1-v1_5_w_SHA256",
			"-258": "RSASSA-PKCS1-v1_5_w_SHA384",
			"-259": "RSASSA-PKCS1-v1_5_w_SHA512",
			"-65535": "RSASSA-PKCS1-v1_5_w_SHA1",
		},
	},
	4: {
		name: "key_ops",
		values: {},
	},
	5: {
		name: "base_iv",
		values: {},
	},
};

/**
 * Key specific COSE parameters
 * used by {@link fromCose}
 * 
 * @private
 */
const coseKeyParamList = {
	// ECDSA key parameters
	// defined here: https://tools.ietf.org/html/rfc8152#section-13.1.1
	EC: {
		"-1": {
			name: "crv",
			values: {
				1: "P-256",
				2: "P-384",
				3: "P-521",
			},
		},
		// value = Buffer
		"-2": { name: "x" },
		"-3": { name: "y" },
		"-4": { name: "d" },
	},
	// Octet Key Pair key parameters
	// defined here: https://datatracker.ietf.org/doc/html/rfc8152#section-13.2
	OKP: {
		"-1": {
			name: "crv",
			values: {
				4: "X25519",
				5: "X448",
				6: "Ed25519",
				7: "Ed448",
			},
		},
		// value = Buffer
		"-2": { name: "x" },
		"-4": { name: "d" },
	},
	// RSA key parameters
	// defined here: https://tools.ietf.org/html/rfc8230#section-4
	RSA: {
		// value = Buffer
		"-1": { name: "n" },
		"-2": { name: "e" },
		"-3": { name: "d" },
		"-4": { name: "p" },
		"-5": { name: "q" },
		"-6": { name: "dP" },
		"-7": { name: "dQ" },
		"-8": { name: "qInv" },
		"-9": { name: "other" },
		"-10": { name: "r_i" },
		"-11": { name: "d_i" },
		"-12": { name: "t_i" },
	},
};

/**
 * Maps COSE algorithm identifier to JWK alg
 * used by {@link fromCose}
 * 
 * @private
 */
const algToJWKAlg = {
	"RSASSA-PKCS1-v1_5_w_SHA256": "RS256",
	"RSASSA-PKCS1-v1_5_w_SHA384": "RS384",
	"RSASSA-PKCS1-v1_5_w_SHA512": "RS512",
	"RSASSA-PKCS1-v1_5_w_SHA1": "RS256",
	/*
	PS256-512 is untested 
	"RSASSA-PSS_w_SHA-256": "PS256",
	"RSASSA-PSS_w_SHA-384": "PS384",
	"RSASSA-PSS_w_SHA-512": "PS512",*/
	"ECDSA_w_SHA256": "ES256",
	"ECDSA_w_SHA384": "ES384",
	"ECDSA_w_SHA512": "ES512",
	/*
	EdDSA is untested and unfinished
	"EdDSA": "EdDSA" */
};

/**
 * Maps Cose algorithm identifier or JWK.alg to webcrypto algorithm identifier
 * used by {@link setAlgorithm}
 * 
 * @private
 */
const algorithmInputMap = {
	/* Cose Algorithm identifier to Webcrypto algorithm name */
	"RSASSA-PKCS1-v1_5_w_SHA256": "RSASSA-PKCS1-v1_5",
	"RSASSA-PKCS1-v1_5_w_SHA384": "RSASSA-PKCS1-v1_5",
	"RSASSA-PKCS1-v1_5_w_SHA512": "RSASSA-PKCS1-v1_5",
	"RSASSA-PKCS1-v1_5_w_SHA1": "RSASSA-PKCS1-v1_5",
	/*"RSASSA-PSS_w_SHA-256": "RSASSA-PSS",
	"RSASSA-PSS_w_SHA-384": "RSASSA-PSS",
	"RSASSA-PSS_w_SHA-512": "RSASSA-PSS",*/
	"ECDSA_w_SHA256": "ECDSA",
	"ECDSA_w_SHA384": "ECDSA",
	"ECDSA_w_SHA512": "ECDSA",
	/*"EdDSA": "EdDSA",*/

	/* JWK alg to Webcrypto algorithm name */
	"RS256": "RSASSA-PKCS1-v1_5",
	"RS384": "RSASSA-PKCS1-v1_5",
	"RS512": "RSASSA-PKCS1-v1_5",
	/*"PS256": "RSASSA-PSS",
	"PS384": "RSASSA-PSS",
	"PS512": "RSASSA-PSS",*/
	"ES384": "ECDSA",
	"ES256": "ECDSA",
	"ES512": "ECDSA",
	/*"EdDSA": "EdDSA",*/
};

/**
 * Maps Cose algorithm identifier webcrypto hash name
 * used by {@link setAlgorithm}
 * 
 * @private
 */
const inputHashMap = {
	/* Cose Algorithm identifier to Webcrypto hash name */
	"RSASSA-PKCS1-v1_5_w_SHA256": "SHA-256",
	"RSASSA-PKCS1-v1_5_w_SHA384": "SHA-384",
	"RSASSA-PKCS1-v1_5_w_SHA512": "SHA-512",
	"RSASSA-PKCS1-v1_5_w_SHA1": "SHA-1",
	/*"RSASSA-PSS_w_SHA256": "SHA-256",
	"RSASSA-PSS_w_SHA384": "SHA-384",
	"RSASSA-PSS_w_SHA512": "SHA-512",*/
	"ECDSA_w_SHA256": "SHA-256",
	"ECDSA_w_SHA384": "SHA-384",
	"ECDSA_w_SHA512": "SHA-512",
	/* "EdDSA": "EdDSA", */
};

/** 
 * Class representing a generic public key, 
 * with utility functions to convert between different formats
 * using Webcrypto
 * 
 * @package
 * 
 */
class PublicKey {

	/**
	 * Create a empty public key
	 * 
	 * @returns {CryptoKey}
	 */
	constructor() {
		/**
		 * Internal reference to imported PEM string
		 * @type {string}
		 * @private
		 */
		this._original_pem = undefined;

		/**
		 * Internal reference to imported JWK object
		 * @type {object}
		 * @private
		 */
		this._original_jwk = undefined;

		/**
		 * Internal reference to imported Cose data
		 * @type {object}
		 * @private
		 */
		this._original_cose = undefined;

		/**
		 * Internal reference to algorithm, should be of RsaHashedImportParams or EcKeyImportParams format
		 * @type {object}
		 * @private
		 */
		this._alg = undefined;

		/**
		 * Internal reference to a CryptoKey object
		 * @type {object}
		 * @private
		 */
		this._key = undefined;
	}

	/**
	 * Import a CryptoKey, makes basic checks and throws on failure
	 * 
	 * @public
	 * @param {CryptoKey} key - CryptoKey to import
	 * @param {object} [alg] - Algorithm override
	 * 
	 * @returns {CryptoKey} - Returns this for chaining
	 */
	fromCryptoKey(key, alg) {
		
		// Throw on missing key
		if (!key) {
			throw new TypeError("No key passed");
		}

		// Allow a CryptoKey to be passed through the constructor
		if (key && (!key.type || key.type !== "public")) {
			throw new TypeError("Invalid argument passed to fromCryptoKey, should be instance of CryptoKey with type public");
		}

		// Store key
		this._key = key;

		// Store internal representation of algorithm
		this.setAlgorithm(key.algorithm);

		// Update algorithm if passed
		if (alg) {
			this.setAlgorithm(alg);
		}

		return this;

	}

	/**
	 * Import public key from SPKI PEM. Throws on any type of failure.
	 *
	 * @async
	 * @public
	 * @param {string} pem - PEM formatted string
	 * @return {Promise<PublicKey>} - Returns itself for chaining
	 */
	async fromPem(pem, hashName) {

		// Convert PEM to Base64
		let base64ber,
			ber;

		// Clean up base64 string
		if (typeof pem === "string" || pem instanceof String) {
			pem = pem.replace(/\r/g, "");
		}

		if (isPem(pem)) {
			base64ber = pemToBase64(pem);
			ber = coerceToArrayBuffer(base64ber, "base64ber");
		} else {
			throw new Error("Supplied key is not in PEM format");
		}

		if (ber.byteLength === 0) {
			throw new Error("Supplied key ber was empty (0 bytes)");
		}

		// Extract x509 information
		const asn1 = tools.fromBER(ber);
		if (asn1.offset === -1) {
			throw new Error("error parsing ASN.1");
		}
		let keyInfo = new tools.pkijs.PublicKeyInfo({ schema: asn1.result });
		const algorithm = {};

		// Extract algorithm from key info
		if (keyInfo.algorithm.algorithmId === "1.2.840.10045.2.1") {
			algorithm.name = "ECDSA";

			// Use parsedKey to extract namedCurve if present, else default to P-256
			const parsedKey = keyInfo.parsedKey;
			if (parsedKey && parsedKey.namedCurve === "1.2.840.10045.3.1.7") {  // NIST P-256, secp256r1
				algorithm.namedCurve = "P-256";
			} else if (parsedKey && parsedKey.namedCurve === "1.3.132.0.34") {  // NIST P-384, secp384r1
				algorithm.namedCurve = "P-384";
			} else if (parsedKey && parsedKey.namedCurve === "1.3.132.0.35") {  // NIST P-512, secp521r1
				algorithm.namedCurve = "P-512";
			} else {
				algorithm.namedCurve = "P-256";
			}

			// Handle RSA
		} else if (keyInfo.algorithm.algorithmId === "1.2.840.113549.1.1.1") {
			algorithm.name = "RSASSA-PKCS1-v1_5";

			// Default hash to SHA-256
			algorithm.hash = hashName || "SHA-256";
		}
		this.setAlgorithm(algorithm);

		// Import key using webcrypto
		let importSPKIResult;
		try {
			importSPKIResult = await tools.webcrypto.subtle.importKey("spki", ber, algorithm, true, ["verify"]);
		} catch (_e1) {
			throw new Error("Unsupported key format", _e1);
		}

		// Store references
		this._original_pem = pem;
		this._key = importSPKIResult;
		
		return this;
	}

	
	/**
	 * Import public key from JWK. Throws on any type of failure.
	 *
	 * @async
	 * @public
	 * @param {object} jwk - JWK object
	 * @return {Promise<PublicKey>} - Returns itself for chaining
	 */
	async fromJWK(jwk, extractable) {

		// Copy JWK
		const jwkCopy = JSON.parse(JSON.stringify(jwk));
		// Force extractable flag if specified
		if (
			typeof extractable !== "undefined" &&
			typeof extractable === "boolean"
		) {
			jwkCopy.ext = extractable;
		}

		// Store alg
		this.setAlgorithm(jwkCopy);

		// Import jwk with Jose
		this._original_jwk = jwk;

		const generatedKey = await tools.webcrypto.subtle.importKey(
			"jwk",
			jwkCopy,
			this.getAlgorithm(),
			true,
			["verify"]
		);
		this._key = generatedKey;
		return this;
	}

	/**
	 * Import public key from COSE data. Throws on any type of failure.
	 * 
	 * Internally this function converts COSE to a JWK, then calls .fromJwk() to import key to CryptoKey
	 *
	 * @async
	 * @public
	 * @param {object} cose - COSE data
	 * @return {Promise<PublicKey>} - Returns itself for chaining
	 */
	async fromCose(cose) {
		if (typeof cose !== "object") {
			throw new TypeError(
				"'cose' argument must be an object, probably an Buffer conatining valid COSE",
			);
		}

		this._cose = coerceToArrayBuffer(cose, "coseToJwk");

		let parsedCose;
		try {
			// In the current state, the "cose" parameter can contain not only the actual cose (= public key) but also extensions.
			// Both are CBOR encoded entries, so you can treat and evaluate the "cose" parameter accordingly.
			// "fromCose" is called from a context that contains an active AT flag (attestation), so the first CBOR entry is the actual cose.
			// "tools.cbor.decode" will fail when multiple entries are provided (e.g. cose + at least one extension), so "decodeMultiple" is the sollution.
			tools.cbor.decodeMultiple(
				new Uint8Array(cose),
				cborObject => {
					parsedCose = cborObject;
					return false;
				}
			);
		} catch (err) {
			throw new Error(
				"couldn't parse authenticator.authData.attestationData CBOR: " +
					err,
			);
		}
		if (typeof parsedCose !== "object") {
			throw new Error(
				"invalid parsing of authenticator.authData.attestationData CBOR",
			);
		}
		const coseMap = new Map(Object.entries(parsedCose));
		const extraMap = new Map();
		const retKey = {};
		// parse main COSE labels
		for (const kv of coseMap) {
			const key = kv[0].toString();
			let value = kv[1].toString();

			if (!coseLabels[key]) {
				extraMap.set(kv[0], kv[1]);
				continue;
			}

			const name = coseLabels[key].name;
			if (coseLabels[key].values[value]) {
				value = coseLabels[key].values[value];
			}
			retKey[name] = value;
		}
		const keyParams = coseKeyParamList[retKey.kty];

		// parse key-specific parameters
		for (const kv of extraMap) {
			const key = kv[0].toString();
			let value = kv[1];

			if (!keyParams[key]) {
				throw new Error(
					"unknown COSE key label: " + retKey.kty + " " + key,
				);
			}
			const name = keyParams[key].name;

			if (keyParams[key].values) {
				value = keyParams[key].values[value.toString()];
			}
			value = coerceToBase64Url(value, "coseToJwk");

			retKey[name] = value;
		}

		// Store reference to original cose object
		this._original_cose = cose;

		// Set algorithm from cose JWK-like
		this.setAlgorithm(retKey);

		// Convert cose algorithm identifier to jwk algorithm name
		retKey.alg = algToJWKAlg[retKey.alg];

		await this.fromJWK(retKey, true);
		return this;
	}

	/**
	 * Exports public key to PEM. 
	 * - Reuses original PEM string if present.
	 * - Possible to force regeneration of PEM string by setting 'forcedExport' parameter to true
	 * - Throws on any kind of failure
	 *
	 * @async
	 * @public
	 * @param {boolean} [forcedExport] - Force regeneration of PEM string even if original PEM-string is available
	 * @return {Promise<string>} - Returns PEM string
	 */
	async toPem(forcedExport) {
		if (this._original_pem && !forcedExport) {
			return this._original_pem;
		} else if (this.getKey()) {
			let pemResult = abToPem("PUBLIC KEY",await tools.webcrypto.subtle.exportKey("spki", this.getKey()));

			return pemResult;
		} else {
			throw new Error("No key information available");
		}
	}

	/**
	 * Exports public key to JWK. 
	 * - Only works if original jwk from 'fromJwk()' is available
	 * - Throws on any kind of failure
	 *
	 * @public
	 * @return {object} - Returns JWK object
	 */
	toJwk() {
		if (this._original_jwk) {
			return this._original_jwk;
		} else {
			throw new Error("No usable key information available");
		}
	}

	/**
	 * Exports public key to COSE data 
	 * - Only works if original cose data from 'fromCose()' is available
	 * - Throws on any kind of failure
	 *
	 * @public
	 * @return {object} - Returns COSE data object
	 */
	toCose() {
		if (this._original_cose) {
			return this._original_cose;
		} else {
			throw new Error("No usable key information available");
		}
	}

	/**
	 * Returns internal key in CryptoKey format
	 * - Mainly intended for internal use
	 * - Throws if internal CryptoKey does not exist
	 *
	 * @public
	 * @return {CryptoKey} - Internal CryptoKey instance, or undefined
	 */
	getKey() {
		if (this._key) {
			return this._key;
		} else {
			throw new Error("Key data not available");
		}
	}

	/**
	 * Returns internal algorithm, which should be of one of the following formats 
	 * - RsaHashedImportParams
	 * - EcKeyImportParams
	 * - undefined
	 *
	 * @public
	 * @return {object|undefined} - Internal algorithm representation, or undefined
	 */
	getAlgorithm() {
		return this._alg;
	}

	/**
	 * Sets internal algorithm identifier in format used by webcrypto, should be one of
	 * - Allows adding missing properties
	 * - Makes sure `alg.hash` is is `{ hash: { name: 'foo'} }` format
	 * - Syncs back updated algorithm to this._key
	 *
	 * @public
	 * @param {object} - RsaHashedImportParams, EcKeyImportParams, JWK or JWK-like
	 * @return {object|undefined} - Internal algorithm representation, or undefined
	 */
	setAlgorithm(algorithmInput) {

		let algorithmOutput = this._alg || {};

		// Check for name if not already present
		// From Algorithm object
		if (algorithmInput.name) {
			algorithmOutput.name = algorithmInput.name;
			// JWK or JWK-like
		} else if (algorithmInput.alg) {
			const algMapResult = algorithmInputMap[algorithmInput.alg];
			if (algMapResult) {
				algorithmOutput.name = algMapResult;	
			}
		}

		// Check for hash if not already present
		// From Algorithm object
		if (algorithmInput.hash) {
			if (algorithmInput.hash.name) {
				algorithmOutput.hash = algorithmInput.hash;
			} else {
				algorithmOutput.hash = { name: algorithmInput.hash };;
			}
			// Try to extract hash from JWK-like .alg
		} else if (algorithmInput.alg) {
			let hashMapResult = inputHashMap[algorithmInput.alg];
			if (hashMapResult) {
				algorithmOutput.hash = { name: hashMapResult };
			}

		}

		// Try to extract namedCurve if not already present
		if (algorithmInput.namedCurve) {
			algorithmOutput.namedCurve = algorithmInput.namedCurve;
		} else if (algorithmInput.crv) {
			algorithmOutput.namedCurve = algorithmInput.crv;
		}

		// Set this._alg if any algorithm properties existed, or were added
		if (Object.keys(algorithmOutput).length > 0) {
			this._alg = algorithmOutput;

			// Sync algorithm hash to CryptoKey
			if (this._alg.hash && this._key) {
				this._key.algorithm.hash = this._alg.hash;
			}
		}

	}

}

/** 
 * Utility function to convert a cose algorithm to string
 * 
 * @package
 * 
 * @param {string|number} - Cose algorithm
*/
function coseAlgToStr(alg) {
	if (typeof alg !== "number") {
		throw new TypeError("expected 'alg' to be a number, got: " + alg);
	}

	const algValues = coseLabels["3"].values;

	const mapResult = algValues[alg];
	if (!mapResult) {
		throw new Error("'alg' is not a valid COSE algorithm number");
	}

	return algValues[alg];
}


/** 
 * Utility function to convert a cose hashing algorithm to string
 * 
 * @package
 * 
 * @param {string|number} - Cose algorithm
 */
function coseAlgToHashStr(alg) {
	if (typeof alg === "number") alg = coseAlgToStr(alg);

	if (typeof alg !== "string") {
		throw new Error("'alg' is not a string or a valid COSE algorithm number");
	}

	const mapResult = inputHashMap[alg];
	if (!mapResult) {
		throw new Error("'alg' is not a valid COSE algorithm");
	}

	return inputHashMap[alg];
}


export { PublicKey, coseAlgToStr, coseAlgToHashStr };