UNPKG

@turnkey/crypto

Version:

Encryption, decryption, and key related utility functions

466 lines (463 loc) 21.3 kB
import { p256 } from '@noble/curves/p256'; import * as hkdf from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha256'; import { gcm } from '@noble/ciphers/aes'; import { randomBytes } from '@noble/hashes/utils'; import { uint8ArrayFromHexString, uint8ArrayToHexString, normalizePadding } from '@turnkey/encoding'; import { modSqrt, testBit } from './math.mjs'; import { LABEL_EAE_PRK, SUITE_ID_1, LABEL_SHARED_SECRET, LABEL_SECRET, SUITE_ID_2, AES_KEY_INFO, IV_INFO, HPKE_VERSION } from './constants.mjs'; /// <reference lib="dom" /> /** * Get PublicKey function * Derives public key from Uint8Array or hexstring private key * * @param {Uint8Array | string} privateKey - The Uint8Array or hexstring representation of a compressed private key. * @param {boolean} isCompressed - Specifies whether to return a compressed or uncompressed public key. Defaults to true. * @returns {Uint8Array} - The public key in Uin8Array representation. */ const getPublicKey = (privateKey, isCompressed = true) => { return p256.getPublicKey(privateKey, isCompressed); }; /** * HPKE Encrypt Function * Encrypts data using Hybrid Public Key Encryption (HPKE) standard https://datatracker.ietf.org/doc/rfc9180/. * * @param {HpkeEncryptParams} params - The encryption parameters including plain text, encapsulated key, and sender private key. * @returns {Uint8Array} - The encrypted data. */ const hpkeEncrypt = ({ plainTextBuf, targetKeyBuf, }) => { try { // Standard HPKE Mode (Ephemeral Key Pair) const ephemeralKeyPair = generateP256KeyPair(); const senderPrivBuf = uint8ArrayFromHexString(ephemeralKeyPair.privateKey); const senderPubBuf = uint8ArrayFromHexString(ephemeralKeyPair.publicKeyUncompressed); const aad = buildAdditionalAssociatedData(senderPubBuf, targetKeyBuf); // Step 1: Generate Shared Secret const ss = deriveSS(targetKeyBuf, uint8ArrayToHexString(senderPrivBuf)); // Step 2: Generate the KEM context const kemContext = getKemContext(senderPubBuf, uint8ArrayToHexString(targetKeyBuf)); // Step 3: Build the HKDF inputs for key derivation let ikm = buildLabeledIkm(LABEL_EAE_PRK, ss, SUITE_ID_1); let info = buildLabeledInfo(LABEL_SHARED_SECRET, kemContext, SUITE_ID_1, 32); const sharedSecret = extractAndExpand(new Uint8Array([]), ikm, info, 32); // Step 4: Derive the AES key ikm = buildLabeledIkm(LABEL_SECRET, new Uint8Array([]), SUITE_ID_2); info = AES_KEY_INFO; const key = extractAndExpand(sharedSecret, ikm, info, 32); // Step 5: Derive the initialization vector info = IV_INFO; const iv = extractAndExpand(sharedSecret, ikm, info, 12); // Step 6: Encrypt the data using AES-GCM const encryptedData = aesGcmEncrypt(plainTextBuf, key, iv, aad); // Step 7: Concatenate the encapsulated key and the encrypted data for output const compressedSenderBuf = compressRawPublicKey(senderPubBuf); const result = new Uint8Array(compressedSenderBuf.length + encryptedData.length); result.set(compressedSenderBuf, 0); result.set(encryptedData, compressedSenderBuf.length); return result; } catch (error) { throw new Error(`Unable to perform hpkeEncrypt: ${error}`); } }; /** * HPKE Encrypt Function * Encrypts data using Authenticated ,Hybrid Public Key Encryption (HPKE) standard https://datatracker.ietf.org/doc/rfc9180/. * * @param {HpkeAuthEncryptParams} params - The encryption parameters including plain text, encapsulated key, and sender private key. * @returns {Uint8Array} - The encrypted data. */ const hpkeAuthEncrypt = ({ plainTextBuf, targetKeyBuf, senderPriv, }) => { try { // Authenticated HPKE Mode const senderPrivBuf = uint8ArrayFromHexString(senderPriv); const senderPubBuf = getPublicKey(senderPriv, false); const aad = buildAdditionalAssociatedData(senderPubBuf, targetKeyBuf); // Step 1: Generate Shared Secret const ss = deriveSS(targetKeyBuf, uint8ArrayToHexString(senderPrivBuf)); // Step 2: Generate the KEM context const kemContext = getKemContext(senderPubBuf, uint8ArrayToHexString(targetKeyBuf)); // Step 3: Build the HKDF inputs for key derivation let ikm = buildLabeledIkm(LABEL_EAE_PRK, ss, SUITE_ID_1); let info = buildLabeledInfo(LABEL_SHARED_SECRET, kemContext, SUITE_ID_1, 32); const sharedSecret = extractAndExpand(new Uint8Array([]), ikm, info, 32); // Step 4: Derive the AES key ikm = buildLabeledIkm(LABEL_SECRET, new Uint8Array([]), SUITE_ID_2); info = AES_KEY_INFO; const key = extractAndExpand(sharedSecret, ikm, info, 32); // Step 5: Derive the initialization vector info = IV_INFO; const iv = extractAndExpand(sharedSecret, ikm, info, 12); // Step 6: Encrypt the data using AES-GCM const encryptedData = aesGcmEncrypt(plainTextBuf, key, iv, aad); // Step 7: Concatenate the encapsulated key and the encrypted data for output const compressedSenderBuf = compressRawPublicKey(senderPubBuf); const result = new Uint8Array(compressedSenderBuf.length + encryptedData.length); result.set(compressedSenderBuf, 0); result.set(encryptedData, compressedSenderBuf.length); return result; } catch (error) { throw new Error(`Unable to perform hpkeEncrypt: ${error}`); } }; /** * Format HPKE Buffer Function * Returns a JSON string of an encrypted bundle, separating out the cipher text and the sender public key * * @param {Uint8Array} encryptedBuf - The result of hpkeAuthEncrypt or hpkeEncrypt * @returns {string} - A JSON string with "encappedPublic" and "ciphertext" */ const formatHpkeBuf = (encryptedBuf) => { const compressedSenderBuf = encryptedBuf.slice(0, 33); const encryptedData = encryptedBuf.slice(33); const encappedKeyBufHex = uint8ArrayToHexString(uncompressRawPublicKey(compressedSenderBuf)); const ciphertextHex = uint8ArrayToHexString(encryptedData); return JSON.stringify({ encappedPublic: encappedKeyBufHex, ciphertext: ciphertextHex, }); }; /** * HPKE Decrypt Function * Decrypts data using Hybrid Public Key Encryption (HPKE) standard https://datatracker.ietf.org/doc/rfc9180/. * * @param {HpkeDecryptParams} params - The decryption parameters including ciphertext, encapsulated key, and receiver private key. * @returns {Uint8Array} - The decrypted data. */ const hpkeDecrypt = ({ ciphertextBuf, encappedKeyBuf, receiverPriv, }) => { try { let ikm; let info; const receiverPubBuf = getPublicKey(uint8ArrayFromHexString(receiverPriv), false); const aad = buildAdditionalAssociatedData(encappedKeyBuf, receiverPubBuf); // Eventually we want users to be able to pass in aad as optional // Step 1: Generate Shared Secret const ss = deriveSS(encappedKeyBuf, receiverPriv); // Step 2: Generate the KEM context const kemContext = getKemContext(encappedKeyBuf, uint8ArrayToHexString(receiverPubBuf)); // Step 3: Build the HKDF inputs for key derivation ikm = buildLabeledIkm(LABEL_EAE_PRK, ss, SUITE_ID_1); info = buildLabeledInfo(LABEL_SHARED_SECRET, kemContext, SUITE_ID_1, 32); const sharedSecret = extractAndExpand(new Uint8Array([]), ikm, info, 32); // Step 4: Derive the AES key ikm = buildLabeledIkm(LABEL_SECRET, new Uint8Array([]), SUITE_ID_2); info = AES_KEY_INFO; const key = extractAndExpand(sharedSecret, ikm, info, 32); // Step 5: Derive the initialization vector info = IV_INFO; const iv = extractAndExpand(sharedSecret, ikm, info, 12); // Step 6: Decrypt the data using AES-GCM const decryptedData = aesGcmDecrypt(ciphertextBuf, key, iv, aad); return decryptedData; } catch (error) { throw new Error(`Unable to perform hpkeDecrypt: ${error} `); } }; /** * Generate a P-256 key pair. Contains the hexed privateKey, publicKey, and Uncompressed publicKey * * @returns {KeyPair} - The generated key pair. */ const generateP256KeyPair = () => { const privateKey = randomBytes(32); const publicKey = getPublicKey(privateKey, true); const publicKeyUncompressed = uint8ArrayToHexString(uncompressRawPublicKey(publicKey)); return { privateKey: uint8ArrayToHexString(privateKey), publicKey: uint8ArrayToHexString(publicKey), publicKeyUncompressed, }; }; /** * Create additional associated data (AAD) for AES-GCM decryption. * * @param {Uint8Array} senderPubBuf * @param {Uint8Array} receiverPubBuf * @return {Uint8Array} - The resulting concatenation of sender and receiver pubkeys. */ const buildAdditionalAssociatedData = (senderPubBuf, receiverPubBuf) => { return new Uint8Array([ ...Array.from(senderPubBuf), ...Array.from(receiverPubBuf), ]); }; /** * Accepts a private key Uint8Array in the PKCS8 format, and returns the encapsulated private key. * * @param {Uint8Array} privateKey - A PKCS#8 private key structured with the key data at a specific position. The actual key starts at byte 36 and is 32 bytes long. * @return {Uint8Array} - The private key. */ const extractPrivateKeyFromPKCS8Bytes = (privateKey) => { return privateKey.slice(36, 36 + 32); }; /** * Accepts a public key Uint8Array, and returns a Uint8Array with the compressed version of the public key. * * @param {Uint8Array} rawPublicKey - The raw public key. * @return {Uint8Array} – The compressed public key. */ const compressRawPublicKey = (rawPublicKey) => { const len = rawPublicKey.byteLength; // Drop the y coordinate // Uncompressed key is in the form 0x04||x||y // `len >>> 1` is a more concise way to write `floor(len/2)` var compressedBytes = rawPublicKey.slice(0, (1 + len) >>> 1); // Encode the parity of `y` in first bit // `BYTE & 0x01` tests for parity and returns 0x00 when even, or 0x01 when odd // Then `0x02 | <parity test result>` yields either 0x02 (even case) or 0x03 (odd). compressedBytes[0] = 0x02 | (rawPublicKey[len - 1] & 0x01); return compressedBytes; }; /** * Accepts a public key array buffer, and returns a buffer with the uncompressed version of the public key * @param {Uint8Array} rawPublicKey - The public key. * @return {Uint8Array} - The uncompressed public key. */ const uncompressRawPublicKey = (rawPublicKey) => { if (rawPublicKey.length !== 33) { throw new Error("failed to uncompress raw public key: invalid length"); } if (!(rawPublicKey[0] === 2 || rawPublicKey[0] === 3)) { throw new Error("failed to uncompress raw public key: invalid prefix"); } // point[0] must be 2 (false) or 3 (true). // this maps to the initial "02" or "03" prefix const lsb = rawPublicKey[0] === 3; const x = BigInt("0x" + uint8ArrayToHexString(rawPublicKey.subarray(1))); // https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf (Appendix D). const p = BigInt("115792089210356248762697446949407573530086143415290314195533631308867097853951"); const b = BigInt("0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b "); const a = p - BigInt(3); // Now compute y based on x const rhs = ((x * x + a) * x + b) % p; let y = modSqrt(rhs, p); if (lsb !== testBit(y, 0)) { y = (p - y) % p; } if (x < BigInt(0) || x >= p) { throw new Error("x is out of range"); } if (y < BigInt(0) || y >= p) { throw new Error("y is out of range"); } var uncompressedHexString = "04" + bigIntToHex(x, 64) + bigIntToHex(y, 64); return uint8ArrayFromHexString(uncompressedHexString); }; /** * Build labeled Initial Key Material (IKM). * * @param {Uint8Array} label - The label to use. * @param {Uint8Array} ikm - The input key material. * @param {Uint8Array} suiteId - The suite identifier. * @returns {Uint8Array} - The labeled IKM. */ const buildLabeledIkm = (label, ikm, suiteId) => { const combinedLength = HPKE_VERSION.length + suiteId.length + label.length + ikm.length; const ret = new Uint8Array(combinedLength); let offset = 0; ret.set(HPKE_VERSION, offset); offset += HPKE_VERSION.length; ret.set(suiteId, offset); offset += suiteId.length; ret.set(label, offset); offset += label.length; ret.set(ikm, offset); return ret; }; /** * Build labeled info for HKDF operations. * * @param {Uint8Array} label - The label to use. * @param {Uint8Array} info - Additional information. * @param {Uint8Array} suiteId - The suite identifier. * @param {number} len - The output length. * @returns {Uint8Array} - The labeled info. */ const buildLabeledInfo = (label, info, suiteId, len) => { const suiteIdStartIndex = 9; // first two are reserved for length bytes (unused in this case), the next 7 are for the HPKE_VERSION, then the suiteId starts at 9 const ret = new Uint8Array(suiteIdStartIndex + suiteId.byteLength + label.byteLength + info.byteLength); ret.set(new Uint8Array([0, len]), 0); // this isn’t an error, we’re starting at index 2 because the first two bytes should be 0. See <https://github.com/dajiaji/hpke-js/blob/1e7fb1372fbcdb6d06bf2f4fa27ff676329d633e/src/kdfs/hkdf.ts#L41> for reference. ret.set(HPKE_VERSION, 2); ret.set(suiteId, suiteIdStartIndex); ret.set(label, suiteIdStartIndex + suiteId.byteLength); ret.set(info, suiteIdStartIndex + suiteId.byteLength + label.byteLength); return ret; }; /** * Perform HKDF extract and expand operations. */ const extractAndExpand = (sharedSecret, ikm, info, len) => { const prk = hkdf.extract(sha256, ikm, sharedSecret); const resp = hkdf.expand(sha256, prk, info, len); return new Uint8Array(resp); }; /** * Derive the Diffie-Hellman shared secret using ECDH. */ const deriveSS = (encappedKeyBuf, priv) => { const ss = p256.getSharedSecret(uint8ArrayFromHexString(priv), encappedKeyBuf); return ss.slice(1); }; /** * Encrypt data using AES-GCM. */ const aesGcmEncrypt = (plainTextData, key, iv, aad) => { const aes = gcm(key, iv, aad); const data = aes.encrypt(plainTextData); return data; }; /** * Decrypt data using AES-GCM. */ const aesGcmDecrypt = (encryptedData, key, iv, aad) => { const aes = gcm(key, iv, aad); const data = aes.decrypt(encryptedData); return data; }; /** * Generate a Key Encapsulation Mechanism (KEM) context. */ const getKemContext = (encappedKeyBuf, publicKey) => { const encappedKeyArray = new Uint8Array(encappedKeyBuf); const publicKeyArray = uint8ArrayFromHexString(publicKey); const kemContext = new Uint8Array(encappedKeyArray.length + publicKeyArray.length); kemContext.set(encappedKeyArray); kemContext.set(publicKeyArray, encappedKeyArray.length); return kemContext; }; /** * Convert a BigInt to a hexadecimal string of a specific length. */ const bigIntToHex = (num, length) => { const hexString = num.toString(16); if (hexString.length > length) { throw new Error(`number cannot fit in a hex string of ${length} characters`); } return hexString.padStart(length, "0"); }; /** * Converts an ASN.1 DER-encoded ECDSA signature to the raw format used for verification. * * @param {string} derSignature - The DER-encoded signature. * @returns {Uint8Array} - The raw signature. */ const fromDerSignature = (derSignature) => { const derSignatureBuf = uint8ArrayFromHexString(derSignature); // Check minimum length if (derSignatureBuf.length < 2) { throw new Error("failed to convert DER-encoded signature: insufficient length"); } // Check SEQUENCE tag (0x30 at first byte) if (derSignatureBuf[0] !== 0x30) { throw new Error("failed to convert DER-encoded signature: invalid format (missing SEQUENCE tag)"); } // Check second byte, start of length field let index = 1; const lengthByte = derSignatureBuf[index]; if (lengthByte <= 0x7f) { // Short form: single byte length // directly take the consumed value as length and check against buffer // buffer length: initial header bytes + claimed remaining length if (derSignatureBuf.length < 1 + 1 + lengthByte) { throw new Error("failed to convert DER-encoded signature: inconsistent message length header"); } // continue parsing index += 1; } else { // Multi-byte DER length header // Invalid DER values: lengthByte 0x80 and 0xff // Valid DER values: lengthByte > 0x80, < 0xff // // We do not expect signature data in the Long form notation // -> reject all such inputs // // More complex parsing for longer signature sequences can be implemented once needed throw new Error("failed to convert DER-encoded signature: unexpectedly large or invalid signature length"); } // Parse 'r' and check for integer tag (0x02) if (derSignatureBuf[index] !== 0x02) { throw new Error("failed to convert DER-encoded signature: invalid tag for r"); } index++; // Move past the INTEGER tag const rLength = derSignatureBuf[index]; // Allow up to 32 data bytes + 1 byte 0-padding prefix if (rLength > 33) { throw new Error("failed to convert DER-encoded signature: unexpected length for r"); } index++; // Move past the length byte const r = derSignatureBuf.slice(index, index + rLength); index += rLength; // Move to the start of s // Parse 's' and check for integer tag (0x02) if (derSignatureBuf[index] !== 0x02) { throw new Error("failed to convert DER-encoded signature: invalid tag for s"); } index++; // Move past the INTEGER tag const sLength = derSignatureBuf[index]; // Allow up to 32 data bytes + 1 byte 0-padding prefix if (sLength > 33) { throw new Error("failed to convert DER-encoded signature: unexpected length for s"); } index++; // Move past the length byte const s = derSignatureBuf.slice(index, index + sLength); // Normalize 'r' and 's' to 32 bytes each const rPadded = normalizePadding(r, 32); const sPadded = normalizePadding(s, 32); // Concatenate and return the raw signature return new Uint8Array([...rPadded, ...sPadded]); }; /** * Converts a raw ECDSA signature to DER-encoded format. * * This function takes a raw ECDSA signature, which is a concatenation of two 32-byte integers (r and s), * and converts it into the DER-encoded format. DER (Distinguished Encoding Rules) is a binary encoding * for data structures described by ASN.1. * * @param {string} rawSignature - The raw signature in hexadecimal string format. * @returns {string} - The DER-encoded signature in hexadecimal string format. * * @throws {Error} - Throws an error if the input signature is invalid or if the encoding process fails. * * @example * // Example usage: * const rawSignature = "0x487cdb8a88f2f4044b701cbb116075c4cabe5fe4657a6358b395c0aab70694db3453a8057e442bd1aff0ecabe8a82c831f0edd7f2158b7c1feb3de9b1f20309b1c"; * const derSignature = toDerSignature(rawSignature); * console.log(derSignature); // Outputs the DER-encoded signature as a hex string * // "30440220487cdb8a88f2f4044b701cbb116075c4cabe5fe4657a6358b395c0aab70694db02203453a8057e442bd1aff0ecabe8a82c831f0edd7f2158b7c1feb3de9b1f20309b" */ const toDerSignature = (rawSignature) => { const rawSignatureBuf = uint8ArrayFromHexString(rawSignature); // Split raw signature into r and s, each 32 bytes const r = rawSignatureBuf.slice(0, 32); const s = rawSignatureBuf.slice(32, 64); // Helper function to encode an integer with DER structure const encodeDerInteger = (integer) => { // Check if integer is defined and has at least one byte if (integer === undefined || integer.length === 0 || integer[0] === undefined) { throw new Error("Invalid integer: input is undefined or empty."); } // Add a leading zero if the integer's most significant byte is >= 0x80 const needsPadding = integer[0] & 0x80; const paddedInteger = needsPadding ? new Uint8Array([0x00, ...integer]) : integer; // Prepend the integer tag (0x02) and length return new Uint8Array([0x02, paddedInteger.length, ...paddedInteger]); }; // DER encode r and s const rEncoded = encodeDerInteger(r); const sEncoded = encodeDerInteger(s); // Combine as a DER sequence: 0x30, total length, rEncoded, sEncoded const derSignature = new Uint8Array([ 0x30, rEncoded.length + sEncoded.length, ...rEncoded, ...sEncoded, ]); return uint8ArrayToHexString(derSignature); }; export { buildAdditionalAssociatedData, compressRawPublicKey, extractPrivateKeyFromPKCS8Bytes, formatHpkeBuf, fromDerSignature, generateP256KeyPair, getPublicKey, hpkeAuthEncrypt, hpkeDecrypt, hpkeEncrypt, toDerSignature, uncompressRawPublicKey }; //# sourceMappingURL=crypto.mjs.map