@turnkey/crypto
Version:
Encryption, decryption, and key related utility functions
393 lines (389 loc) • 22.1 kB
JavaScript
;
var encoding = require('@turnkey/encoding');
var constants = require('./constants.js');
var crypto = require('./crypto.js');
var p256 = require('@noble/curves/p256');
var ed25519 = require('@noble/curves/ed25519');
var sha256 = require('@noble/hashes/sha256');
/// <reference lib="dom" />
// Turnkey-specific cryptographic utilities
exports.Enclave = void 0;
(function (Enclave) {
Enclave["NOTARIZER"] = "notarizer";
Enclave["SIGNER"] = "signer";
Enclave["EVM_PARSER"] = "evm-parser";
Enclave["TLS_FETCHER"] = "tls-fetcher";
Enclave["UMP"] = "ump";
})(exports.Enclave || (exports.Enclave = {}));
/**
* Decrypt an encrypted email auth/recovery or oauth credential bundle.
*
* @param {string} credentialBundle - The encrypted credential bundle.
* @param {string} embeddedKey - The private key for decryption.
* @returns {string} - The decrypted data or null if decryption fails.
* @throws {Error} - If unable to decrypt the credential bundle
*/
const decryptCredentialBundle = (credentialBundle, embeddedKey) => {
try {
const bundleBytes = encoding.bs58check.decode(credentialBundle);
if (bundleBytes.byteLength <= 33) {
throw new Error(`Bundle size ${bundleBytes.byteLength} is too low. Expecting a compressed public key (33 bytes) and an encrypted credential.`);
}
const compressedEncappedKeyBuf = bundleBytes.slice(0, 33);
const ciphertextBuf = bundleBytes.slice(33);
const encappedKeyBuf = crypto.uncompressRawPublicKey(compressedEncappedKeyBuf);
const decryptedData = crypto.hpkeDecrypt({
ciphertextBuf,
encappedKeyBuf,
receiverPriv: embeddedKey,
});
return encoding.uint8ArrayToHexString(decryptedData);
}
catch (error) {
throw new Error(`"Error decrypting bundle:", ${error}`);
}
};
/**
* Decrypt an encrypted export bundle (such as a private key or wallet account bundle).
*
* This function verifies the enclave signature to ensure the authenticity of the encrypted data.
* It uses HPKE (Hybrid Public Key Encryption) to decrypt the contents of the bundle and returns
* either the decrypted mnemonic or the decrypted data in hexadecimal format, based on the
* `returnMnemonic` flag.
*
* @param {DecryptExportBundleParams} params - An object containing the following properties:
* - exportBundle {string}: The encrypted export bundle in JSON format.
* - organizationId {string}: The expected organization ID to verify against the signed data.
* - embeddedKey {string}: The private key used for decrypting the data.
* - dangerouslyOverrideSignerPublicKey {string} [Optional]: Optionally override the default signer public key used for verifying the signature. This should only be done for testing
* - returnMnemonic {boolean}: If true, returns the decrypted data as a mnemonic string; otherwise, returns it in hexadecimal format.
* @returns {Promise<string>} - A promise that resolves to the decrypted mnemonic or decrypted hexadecimal data.
* @throws {Error} - If decryption or signature verification fails, throws an error with details.
*/
const decryptExportBundle = async ({ exportBundle, embeddedKey, organizationId, dangerouslyOverrideSignerPublicKey, keyFormat, returnMnemonic, }) => {
try {
const parsedExportBundle = JSON.parse(exportBundle);
const verified = await verifyEnclaveSignature(parsedExportBundle.enclaveQuorumPublic, parsedExportBundle.dataSignature, parsedExportBundle.data, dangerouslyOverrideSignerPublicKey);
if (!verified) {
throw new Error(`failed to verify enclave signature: ${parsedExportBundle}`);
}
const signedData = JSON.parse(new TextDecoder().decode(encoding.uint8ArrayFromHexString(parsedExportBundle.data)));
if (!signedData.organizationId ||
signedData.organizationId !== organizationId) {
throw new Error(`organization id does not match expected value. Expected: ${organizationId}. Found: ${signedData.organizationId}.`);
}
if (!signedData.encappedPublic) {
throw new Error('missing "encappedPublic" in bundle signed data');
}
const encappedKeyBuf = encoding.uint8ArrayFromHexString(signedData.encappedPublic);
const ciphertextBuf = encoding.uint8ArrayFromHexString(signedData.ciphertext);
const decryptedData = crypto.hpkeDecrypt({
ciphertextBuf,
encappedKeyBuf,
receiverPriv: embeddedKey,
});
if (keyFormat === "SOLANA" && !returnMnemonic) {
if (decryptedData.length !== 32) {
throw new Error(`invalid private key length. Expected 32 bytes. Got ${decryptedData.length}.`);
}
const publicKeyBytes = ed25519.ed25519.getPublicKey(decryptedData);
if (publicKeyBytes.length !== 32) {
throw new Error(`invalid public key length. Expected 32 bytes. Got ${publicKeyBytes.length}.`);
}
const concatenatedBytes = new Uint8Array(64);
concatenatedBytes.set(decryptedData, 0);
concatenatedBytes.set(publicKeyBytes, 32);
return encoding.bs58.encode(concatenatedBytes);
}
const decryptedDataHex = encoding.uint8ArrayToHexString(decryptedData);
return returnMnemonic ? encoding.hexToAscii(decryptedDataHex) : decryptedDataHex;
}
catch (error) {
throw new Error(`Error decrypting bundle: ${error}`);
}
};
/**
* Verifies a signature from a Turnkey stamp using ECDSA and SHA-256.
*
* @param {string} publicKey - The public key of the authenticator (e.g. WebAuthn or P256 API key).
* @param {string} signature - The ECDSA signature in DER format.
* @param {string} signedData - The data that was signed (e.g. JSON-stringified Turnkey request body).
* @returns {Promise<boolean>} - Returns true if the signature is valid, otherwise throws an error.
*
* @example
*
* const stampedRequest = await turnkeyClient.stampGetWhoami(...);
* const decodedStampContents = atob(stampedRequest.stamp.stampHeaderValue);
* const parsedStampContents = JSON.parse(decodedStampContents);
* const signature = parsedStampContents.signature;
*
* await verifyStampSignature(publicKey, signature, stampedRequest.body)
*/
const verifyStampSignature = async (publicKey, signature, signedData) => {
const publicKeyBuffer = encoding.uint8ArrayFromHexString(publicKey);
const loadedPublicKey = loadPublicKey(publicKeyBuffer);
if (!loadedPublicKey) {
throw new Error("failed to load public key");
}
// Convert the ASN.1 DER-encoded signature for verification
const publicSignatureBuf = crypto.fromDerSignature(signature);
const signedDataBuf = new TextEncoder().encode(signedData);
const hashedData = sha256.sha256(signedDataBuf);
return p256.p256.verify(publicSignatureBuf, hashedData, loadedPublicKey.toHex());
};
/**
* Verifies a signature from a Turnkey enclave using ECDSA and SHA-256.
*
* @param {string} enclaveQuorumPublic - The public key of the enclave signer.
* @param {string} publicSignature - The ECDSA signature in DER format.
* @param {string} signedData - The data that was signed.
* @param {Environment} dangerouslyOverrideSignerPublicKey - (optional) an enum (PROD or PREPROD) to verify against the correct signer enclave key.
* @returns {Promise<boolean>} - Returns true if the signature is valid, otherwise throws an error.
*/
const verifyEnclaveSignature = async (enclaveQuorumPublic, publicSignature, signedData, dangerouslyOverrideSignerPublicKey) => {
const expectedSignerPublicKey = dangerouslyOverrideSignerPublicKey || constants.PRODUCTION_SIGNER_SIGN_PUBLIC_KEY;
if (enclaveQuorumPublic != expectedSignerPublicKey) {
throw new Error(`expected signer key ${dangerouslyOverrideSignerPublicKey ?? constants.PRODUCTION_SIGNER_SIGN_PUBLIC_KEY} does not match signer key from bundle: ${enclaveQuorumPublic}`);
}
const encryptionQuorumPublicBuf = new Uint8Array(encoding.uint8ArrayFromHexString(enclaveQuorumPublic));
const quorumKey = loadPublicKey(encryptionQuorumPublicBuf);
if (!quorumKey) {
throw new Error("failed to load quorum key");
}
// Convert the ASN.1 DER-encoded signature for verification
const publicSignatureBuf = crypto.fromDerSignature(publicSignature);
const signedDataBuf = encoding.uint8ArrayFromHexString(signedData);
const hashedData = sha256.sha256(signedDataBuf);
return p256.p256.verify(publicSignatureBuf, hashedData, quorumKey.toHex());
};
/**
* Loads an ECDSA public key from a raw format for signature verification.
*
* @param {Uint8Array} publicKey - The raw P-256 public key bytes.
* @returns {ProjPointType<bigint>} - The parsed ECDSA public key.
* @throws {Error} - If the public key is invalid.
*/
const loadPublicKey = (publicKey) => {
return p256.p256.ProjectivePoint.fromHex(encoding.uint8ArrayToHexString(publicKey));
};
/**
* Decodes a private key based on the specified format.
*
* @param {string} privateKey - The private key to decode.
* @param {string} keyFormat - The format of the private key (e.g., "SOLANA", "HEXADECIMAL").
* @returns {Uint8Array} - The decoded private key.
*/
const decodeKey = (privateKey, keyFormat) => {
switch (keyFormat) {
case "SOLANA":
const decodedKeyBytes = encoding.bs58.decode(privateKey);
if (decodedKeyBytes.length !== 64) {
throw new Error(`invalid key length. Expected 64 bytes. Got ${decodedKeyBytes.length}.`);
}
return decodedKeyBytes.subarray(0, 32);
case "HEXADECIMAL":
if (privateKey.startsWith("0x")) {
return encoding.uint8ArrayFromHexString(privateKey.slice(2));
}
return encoding.uint8ArrayFromHexString(privateKey);
default:
console.warn(`invalid key format: ${keyFormat}. Defaulting to HEXADECIMAL.`);
if (privateKey.startsWith("0x")) {
return encoding.uint8ArrayFromHexString(privateKey.slice(2));
}
return encoding.uint8ArrayFromHexString(privateKey);
}
};
/**
* Encrypts a private key bundle using HPKE and verifies the enclave signature.
*
* @param {EncryptPrivateKeyToBundleParams} params - An object containing the private key, key format, bundle, user, and organization details. Optionally, you can override the default signer key (for testing purposes)
* @returns {Promise<string>} - A promise that resolves to a JSON string representing the encrypted bundle.
* @throws {Error} - If enclave signature verification or any other validation fails.
*/
const encryptPrivateKeyToBundle = async ({ privateKey, keyFormat, importBundle, userId, organizationId, dangerouslyOverrideSignerPublicKey, }) => {
const parsedImportBundle = JSON.parse(importBundle);
const plainTextBuf = decodeKey(privateKey, keyFormat);
const verified = await verifyEnclaveSignature(parsedImportBundle.enclaveQuorumPublic, parsedImportBundle.dataSignature, parsedImportBundle.data, dangerouslyOverrideSignerPublicKey);
if (!verified) {
throw new Error(`failed to verify enclave signature: ${importBundle}`);
}
const signedData = JSON.parse(new TextDecoder().decode(encoding.uint8ArrayFromHexString(parsedImportBundle.data)));
if (!signedData.organizationId ||
signedData.organizationId !== organizationId) {
throw new Error(`organization id does not match expected value. Expected: ${organizationId}. Found: ${signedData.organizationId}.`);
}
if (!signedData.userId || signedData.userId !== userId) {
throw new Error(`user id does not match expected value. Expected: ${userId}. Found: ${signedData.userId}.`);
}
if (!signedData.targetPublic) {
throw new Error('missing "targetPublic" in bundle signed data');
}
// Load target public key generated from enclave
const targetKeyBuf = encoding.uint8ArrayFromHexString(signedData.targetPublic);
const privateKeyBundle = crypto.hpkeEncrypt({ plainTextBuf, targetKeyBuf });
return crypto.formatHpkeBuf(privateKeyBundle);
};
/**
/**
* Encrypts a mnemonic wallet bundle using HPKE and verifies the enclave signature.
*
* @param {EncryptWalletToBundleParams} params - An object containing the mnemonic, bundle, user, and organization details. Optionally, you can override the default signer key (for testing purposes).
* @returns {Promise<string>} - A promise that resolves to a JSON string representing the encrypted wallet bundle.
* @throws {Error} - If enclave signature verification or any other validation fails.
*/
const encryptWalletToBundle = async ({ mnemonic, importBundle, userId, organizationId, dangerouslyOverrideSignerPublicKey, }) => {
const parsedImportBundle = JSON.parse(importBundle);
const plainTextBuf = new TextEncoder().encode(mnemonic);
const verified = await verifyEnclaveSignature(parsedImportBundle.enclaveQuorumPublic, parsedImportBundle.dataSignature, parsedImportBundle.data, dangerouslyOverrideSignerPublicKey);
if (!verified) {
throw new Error(`failed to verify enclave signature: ${importBundle}`);
}
const signedData = JSON.parse(new TextDecoder().decode(encoding.uint8ArrayFromHexString(parsedImportBundle.data)));
if (!signedData.organizationId ||
signedData.organizationId !== organizationId) {
throw new Error(`organization id does not match expected value. Expected: ${organizationId}. Found: ${signedData.organizationId}.`);
}
if (!signedData.userId || signedData.userId !== userId) {
throw new Error(`user id does not match expected value. Expected: ${userId}. Found: ${signedData.userId}.`);
}
if (!signedData.targetPublic) {
throw new Error('missing "targetPublic" in bundle signed data');
}
// Load target public key generated from enclave
const targetKeyBuf = encoding.uint8ArrayFromHexString(signedData.targetPublic);
const privateKeyBundle = crypto.hpkeEncrypt({ plainTextBuf, targetKeyBuf });
return crypto.formatHpkeBuf(privateKeyBundle);
};
/**
* Verifies that a **session JWT** was signed by Turnkey’s
* notarizer key (P-256 / ES256, compact 64-byte r‖s signature).
*
* How it works
* ------------
* 1. Split the JWT into `header.payload.signature`.
* 2. **Double-hash** the string `"header.payload"`:
* `h1 = sha256(header.payload)`
* `msg = sha256(h1)`
* (The Rust signer feeds `h1` into `SigningKey::sign`, which hashes once
* more internally, yielding `msg`.)
* 3. Base64-URL-decode the signature (`r||s`, 64 bytes).
* 4. Import the notarizer public key (hex `04‖X‖Y` → `Uint8Array`).
* 5. Call `p256.verify(signature, msg, publicKey)`; noble treats the 32-byte
* `msg` as a pre-hashed digest and performs ECDSA verification.
*
* @param jwt The session JWT to validate.
* @param dangerouslyOverrideNotarizerPublicKey *(optional)* Hex-encoded
* uncompressed P-256 public key to verify against (use only in
* tests). Defaults to the production notarizer key.
* @returns `true` if the signature is valid for the given key, else `false`.
* @throws If the JWT is malformed.
*/
const verifySessionJwtSignature = async (jwt, dangerouslyOverrideNotarizerPublicKey) => {
const notarizerKeyHex = dangerouslyOverrideNotarizerPublicKey ??
constants.PRODUCTION_NOTARIZER_SIGN_PUBLIC_KEY;
/* 1. split JWT -------------------------------------------------------- */
const [headerB64, payloadB64, signatureB64] = jwt.split(".");
if (!signatureB64)
throw new Error("invalid JWT: need 3 parts");
const signingInput = `${headerB64}.${payloadB64}`;
/* 2. sha256(sha256(header.payload)) ----------------------------------- */
const h1 = sha256.sha256(new TextEncoder().encode(signingInput));
const msgDigest = sha256.sha256(h1); // 32-byte Uint8Array
/* 3. base64-url decode signature -------------------------------------- */
const toB64 = (u) => (u = u.replace(/-/g, "+").replace(/_/g, "/")).padEnd(u.length + ((4 - (u.length % 4)) % 4), "=");
const signature = Uint8Array.from(atob(toB64(signatureB64))
.split("")
.map((c) => c.charCodeAt(0))); // 64 bytes
/* 4. load public key -------------------------------------------------- */
const publicKey = encoding.uint8ArrayFromHexString(notarizerKeyHex);
/* 5. verify ----------------------------------------------------------- */
return p256.p256.verify(signature, msgDigest, publicKey);
};
/**
* Encrypts a message to an uncompressed P256 public key
* The function takes in standard strings and converts them
* to Uint8Arrays to be used by the lower level quorumKeyEncrypt
* function. More details about how the encryption works is described
* in that function's documentation.
*
* @param targetPublicKeyUncompressed A hex string uncompressed public key to encrypt a message to
* @param message A standard string message to encrypt, does not have to be hex encoded
* @returns {Promise<Uint8Array>} A borsh serialized envelope with the encrypted message (more details found in quorumKeyEncrypt)
*/
const encryptToEnclave = async (targetPublicKeyUncompressed, message) => {
return await crypto.quorumKeyEncrypt(encoding.uint8ArrayFromHexString(targetPublicKeyUncompressed), new TextEncoder().encode(message));
};
/**
* Encrypts an OTP code and a client public key to the target encryption key
* provided by the enclave during initOtp. The resulting encrypted bundle is
* sent to verifyOtpV2 so the enclave can decrypt it, verify the OTP code,
* and issue a verification token bound to the client's public key.
*
* Verifies the enclave signature on the target bundle before encrypting.
*
* @param otpCode - The OTP code entered by the user.
* @param otpEncryptionTargetBundle - The signed target encryption bundle returned from initOtp.
* @param publicKey - Compressed hex public key to embed in the encrypted bundle.
* @param dangerouslyOverrideSignerPublicKey - Optional override for the TLS fetcher signing key used to verify the bundle signature. Only use in test/preprod environments.
* @returns A promise resolving to the encrypted OTP bundle string.
*/
const encryptOtpCodeToBundle = async (otpCode, otpEncryptionTargetBundle, publicKey, dangerouslyOverrideSignerPublicKey) => {
const parsedBundle = JSON.parse(otpEncryptionTargetBundle);
const verified = await verifyEnclaveSignature(parsedBundle.enclaveQuorumPublic, parsedBundle.dataSignature, parsedBundle.data, dangerouslyOverrideSignerPublicKey ??
constants.PRODUCTION_TLS_FETCHER_SIGN_PUBLIC_KEY);
if (!verified) {
throw new Error("OTP encryption target bundle signature verification failed");
}
const signedData = JSON.parse(new TextDecoder().decode(encoding.uint8ArrayFromHexString(parsedBundle.data)));
const targetKeyBuf = encoding.uint8ArrayFromHexString(signedData.targetPublic);
const plainTextBuf = new TextEncoder().encode(JSON.stringify({ otp_code: otpCode, public_key: publicKey }));
const encryptedBuf = crypto.hpkeEncrypt({ plainTextBuf, targetKeyBuf });
return crypto.formatHpkeBuf(encryptedBuf);
};
/**
* Helper function used specifically to encrypt a client secret to
* TLS Fetchers quorum key. This is used for client_secret upload
* when enabling authentication with an OAuth 2.0 provider
*
* @param client_secret The client secret issued by the OAuth 2.0 provider
* @param dangerouslyOverrideTlsFetcherPublicKey *(optional)* Hex-encoded
* uncompressed P-256 public key to encrypt to (use only in
* tests/dev environment). Defaults to the production TLS Fetcher key.
* @returns {Promise<string>} A hex encoded borsh serialized envelope with the encrypted client
* secret meant to be passed to the CreateOauth2Credential Activity
*/
const encryptOauth2ClientSecret = async (client_secret, dangerouslyOverrideTlsFetcherPublicKey) => {
return encoding.uint8ArrayToHexString(await encryptToEnclave(dangerouslyOverrideTlsFetcherPublicKey ??
constants.PRODUCTION_TLS_FETCHER_ENCRYPT_PUBLIC_KEY, client_secret));
};
/**
* Helper function used specifically to encrypt your on ramp private/secret api keys
* to the on ramp encryption public key. This is used before uploading your on ramp
* credentials to Turnkey via the CreateFiatOnRampCredential activity
*
* @param secret The private/secret api key issued by the on ramp provider
* @param dangerouslyOverrideOnRampEncryptionPublicKey *(optional)* Hex-encoded
* uncompressed P-256 public key to encrypt to (use only in
* tests/dev environment). Defaults to the production on ramp encryption public key.
* @returns {Promise<string>} A base58check encoded borsh serialized envelope with the encrypted secret
* meant to be passed to the CreateFiatOnRampCredential activity
*/
const encryptOnRampSecret = (secret, dangerouslyOverrideOnRampEncryptionPublicKey) => {
return encoding.bs58check.encode(crypto.hpkeEncrypt({
plainTextBuf: new TextEncoder().encode(secret),
targetKeyBuf: crypto.uncompressRawPublicKey(encoding.uint8ArrayFromHexString(dangerouslyOverrideOnRampEncryptionPublicKey ??
constants.PRODUCTION_ON_RAMP_CREDENTIALS_ENCRYPTION_PUBLIC_KEY)),
}));
};
exports.decryptCredentialBundle = decryptCredentialBundle;
exports.decryptExportBundle = decryptExportBundle;
exports.encryptOauth2ClientSecret = encryptOauth2ClientSecret;
exports.encryptOnRampSecret = encryptOnRampSecret;
exports.encryptOtpCodeToBundle = encryptOtpCodeToBundle;
exports.encryptPrivateKeyToBundle = encryptPrivateKeyToBundle;
exports.encryptToEnclave = encryptToEnclave;
exports.encryptWalletToBundle = encryptWalletToBundle;
exports.verifySessionJwtSignature = verifySessionJwtSignature;
exports.verifyStampSignature = verifyStampSignature;
//# sourceMappingURL=turnkey.js.map