UNPKG

@turnkey/crypto

Version:

Encryption, decryption, and key related utility functions

393 lines (389 loc) 22.1 kB
'use strict'; 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