@turnkey/crypto
Version:
Encryption, decryption, and key related utility functions
466 lines (463 loc) • 21.3 kB
JavaScript
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