UNPKG

react-native-avo-inspector

Version:

[![npm version](https://badge.fury.io/js/react-native-avo-inspector.svg)](https://badge.fury.io/js/react-native-avo-inspector)

278 lines (277 loc) 13.4 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { p256 } from '@noble/curves/p256'; /** * Converts bytes (Uint8Array) to hex string */ function bytesToHex(bytes) { return Array.from(bytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Converts hex string to bytes (Uint8Array) */ function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } return bytes; } // Get crypto API - works in both browser and Node.js environments // In browser: window.crypto or globalThis.crypto // In Node.js: global.crypto or globalThis.crypto (polyfilled in tests) const getCrypto = () => { // Try globalThis first (works in both Node.js and browsers) if (typeof globalThis !== 'undefined' && globalThis.crypto) { const crypto = globalThis.crypto; if (crypto && crypto.subtle) { return crypto; } } // Try window (browser) if (typeof window !== 'undefined' && window.crypto) { const crypto = window.crypto; if (crypto && crypto.subtle) { return crypto; } } // Try global (Node.js) if (typeof global !== 'undefined' && global.crypto) { const crypto = global.crypto; if (crypto && crypto.subtle) { return crypto; } } // Fallback - should not happen in proper environments const debugInfo = { hasGlobalThis: typeof globalThis !== 'undefined', hasGlobalThisCrypto: typeof globalThis !== 'undefined' && !!globalThis.crypto, hasGlobalThisCryptoSubtle: typeof globalThis !== 'undefined' && !!globalThis.crypto && !!globalThis.crypto.subtle, hasWindow: typeof window !== 'undefined', hasWindowCrypto: typeof window !== 'undefined' && !!window.crypto, hasGlobal: typeof global !== 'undefined', hasGlobalCrypto: typeof global !== 'undefined' && !!global.crypto, hasGlobalCryptoSubtle: typeof global !== 'undefined' && !!global.crypto && !!global.crypto.subtle }; throw new Error('crypto.subtle not available. Debug: ' + JSON.stringify(debugInfo)); }; /** * Generates a new ECC key pair for encryption/decryption. * Uses P-256 (prime256v1 / NIST P-256) curve which is standard for Web Crypto API. * * @returns An object containing the private and public keys as hex strings */ export function generateKeyPair() { // Generate a new random private key (32 bytes) const privateKeyBytes = p256.utils.randomPrivateKey(); // Get public key (uncompressed format: 65 bytes = 0x04 + 32-byte x + 32-byte y) const publicKeyBytes = p256.getPublicKey(privateKeyBytes, false); // Convert to hex strings const privateKey = bytesToHex(privateKeyBytes).padStart(64, '0'); const publicKey = bytesToHex(publicKeyBytes); return { privateKey, publicKey }; } /** * Derives a key from shared secret using SHA-256 */ async function deriveKey(sharedSecret) { const cryptoAPI = getCrypto(); return await cryptoAPI.subtle.digest('SHA-256', sharedSecret); } /** * Safely converts Uint8Array to base64 string. * Uses Buffer in Node.js for efficiency, or chunked conversion in browsers * to avoid call stack overflow with large arrays. */ function uint8ArrayToBase64(bytes) { // Check if we're in Node.js and Buffer is available if (typeof Buffer !== 'undefined' && Buffer.from) { return Buffer.from(bytes).toString('base64'); } // Browser fallback: use chunked conversion to avoid call stack overflow // Chunk size of 8192 (8k) is safe for String.fromCharCode.apply const CHUNK_SIZE = 8192; let binaryString = ''; for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { const chunk = bytes.slice(i, i + CHUNK_SIZE); // Convert chunk to array and use apply for this small chunk binaryString += String.fromCharCode.apply(null, Array.from(chunk)); } return btoa(binaryString); } /** * Encrypts a value using ECC public key encryption (ECIES). * The encrypted output can only be decrypted by the client using their private key. * This ensures that Avo cannot decrypt the values on the backend. * * ECIES uses hybrid encryption (ECDH + AES-256-GCM) which provides: * - No message size limitations * - Fast encryption even for large values * - Strong authentication via GCM * * SPECIFICATION (Standard Web Crypto Profile): * 1. Curve: P-256 (prime256v1 / NIST P-256) * 2. Key Derivation (KDF): SHA-256(SharedSecret) * 3. Cipher: AES-256-GCM * 4. Serialization: [Version(1b)] + [EphemeralPubKey(33 or 65b)] + [IV(16b)] + [AuthTag(16b)] + [Ciphertext] * Version 0x00 = Standard Web Profile * EphemeralPubKey: 0x04 (uncompressed) + 64 bytes = 65 bytes total, or compressed format (33 bytes) * * @param value - The value to encrypt (any type - will be JSON stringified) * @param publicKey - The ECC public key in hex format provided by the client * @returns Promise resolving to base64-encoded encrypted string that can only be decrypted with the private key */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function encryptValue(value, publicKey) { try { // Convert the value to a JSON string to support all types // Note: JSON.stringify(undefined) returns undefined (not a string), so handle it explicitly const stringValue = value === undefined ? 'null' : JSON.stringify(value); if (stringValue === undefined) { throw new Error('Cannot encrypt undefined value'); } // 1. Prepare Public Key // Ensure publicKey is a string const publicKeyStr = typeof publicKey === 'string' ? publicKey : String(publicKey); // Convert recipient's public key from hex to bytes const recipientPublicKeyBytes = hexToBytes(publicKeyStr); // 2. Generate Ephemeral Key Pair on P-256 const ephemeralPrivateKeyBytes = p256.utils.randomPrivateKey(); const ephemeralPublicKeyBytes = p256.getPublicKey(ephemeralPrivateKeyBytes, false); // Uncompressed format (65 bytes) // 3. Derive Shared Secret (ECDH) // getSharedSecret returns compressed point (33 bytes: 0x02/0x03 + X-coordinate) // Extract X-coordinate (last 32 bytes) to match elliptic's derive().toArray("be", 32) const sharedSecretPoint = p256.getSharedSecret(ephemeralPrivateKeyBytes, recipientPublicKeyBytes); const sharedSecret = sharedSecretPoint.slice(-32); // Extract X-coordinate (last 32 bytes) // 4. Key Derivation (Hash with SHA-256) const derivedKeyBuffer = await deriveKey(sharedSecret); // 5. Import Key for Web Crypto AES-GCM const cryptoAPI = getCrypto(); const keyMaterial = await cryptoAPI.subtle.importKey('raw', derivedKeyBuffer, { name: 'AES-GCM' }, false, ['encrypt']); // 6. Generate random IV (16 bytes) const iv = cryptoAPI.getRandomValues(new Uint8Array(16)); // 7. Encrypt using AES-256-GCM // Convert string to Uint8Array const plaintext = new TextEncoder().encode(stringValue); const encryptedData = await cryptoAPI.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 // 16 bytes = 128 bits }, keyMaterial, plaintext); // Web Crypto API appends the auth tag to the ciphertext // We need to extract it separately for our format const encryptedArray = new Uint8Array(encryptedData); const authTagLength = 16; // 128 bits = 16 bytes const ciphertextLength = encryptedArray.length - authTagLength; const ciphertext = encryptedArray.slice(0, ciphertextLength); const authTag = encryptedArray.slice(ciphertextLength); // 8. Serialize Output // Format: [Version(1)] + [Ephemeral Public Key(65)] + [IV(16)] + [AuthTag(16)] + [Ciphertext] const version = new Uint8Array([0x00]); // Version 0: Standard Web Profile // Combine all parts const resultLength = 1 + ephemeralPublicKeyBytes.length + 16 + 16 + ciphertext.length; const result = new Uint8Array(resultLength); let offset = 0; result.set(version, offset); offset += 1; result.set(ephemeralPublicKeyBytes, offset); offset += ephemeralPublicKeyBytes.length; result.set(iv, offset); offset += 16; result.set(authTag, offset); offset += 16; result.set(ciphertext, offset); // Convert to base64 using safe conversion that handles large arrays return uint8ArrayToBase64(result); } catch (error) { throw new Error(`Failed to encrypt value. Please check that the public key is valid. Error: ${error instanceof Error ? error.message : String(error)}`); } } /** * Decrypts a value that was encrypted with encryptValue. * * SPECIFICATION (Standard Web Crypto Profile): * 1. Curve: P-256 (prime256v1 / NIST P-256) * 2. Key Derivation (KDF): SHA-256(SharedSecret) * 3. Cipher: AES-256-GCM * 4. Deserialization: [Version(1b)] + [EphemeralPubKey(33 or 65b)] + [IV(16b)] + [AuthTag(16b)] + [Ciphertext] * * @param encryptedValue - The base64-encoded encrypted string * @param privateKey - The ECC private key in hex format * @returns Promise resolving to the original decrypted value (parsed from JSON) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function decryptValue(encryptedValue, privateKey) { try { // Convert encrypted value from base64 to Uint8Array const binaryString = atob(encryptedValue); const encryptedBuffer = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { encryptedBuffer[i] = binaryString.charCodeAt(i); } // Minimum size: version(1) + pubkey(33 min) + iv(16) + authTag(16) + ciphertext(1 min) = 67 bytes if (encryptedBuffer.length < 67) { throw new Error('Invalid encrypted data: payload too short'); } // Ensure privateKey is a string const privateKeyStr = typeof privateKey === 'string' ? privateKey : String(privateKey); // 1. Deserialize Input // Format: [Version(1)] + [Ephemeral Public Key(33 or 65)] + [IV(16)] + [AuthTag(16)] + [Ciphertext] let offset = 0; const version = encryptedBuffer[offset]; offset += 1; if (version !== 0x00) { throw new Error(`Unsupported encryption version: ${version}`); } // Check header to determine public key size (0x02/0x03 = 33 bytes compressed, 0x04 = 65 bytes uncompressed) const keyHeader = encryptedBuffer[offset]; const pubKeySize = keyHeader === 0x02 || keyHeader === 0x03 ? 33 : 65; // Validate buffer has enough bytes for this key format const minRequired = 1 + pubKeySize + 16 + 16 + 1; if (encryptedBuffer.length < minRequired) { throw new Error(`Invalid encrypted data: expected at least ${minRequired} bytes, got ${encryptedBuffer.length}`); } const ephemeralPublicKey = encryptedBuffer.slice(offset, offset + pubKeySize); offset += pubKeySize; const iv = encryptedBuffer.slice(offset, offset + 16); // IV (16 bytes) offset += 16; const authTag = encryptedBuffer.slice(offset, offset + 16); // Auth tag (16 bytes) offset += 16; const ciphertext = encryptedBuffer.slice(offset); // Remaining bytes are ciphertext // 2. Prepare Private Key const recipientPrivateKeyBytes = hexToBytes(privateKeyStr); // 3. Derive Shared Secret (ECDH) // getSharedSecret returns compressed point (33 bytes: 0x02/0x03 + X-coordinate) // Extract X-coordinate (last 32 bytes) to match elliptic's derive().toArray("be", 32) const sharedSecretPoint = p256.getSharedSecret(recipientPrivateKeyBytes, ephemeralPublicKey); const sharedSecret = sharedSecretPoint.slice(-32); // Extract X-coordinate (last 32 bytes) // 4. Key Derivation (Hash with SHA-256) const derivedKeyBuffer = await deriveKey(sharedSecret); // 5. Import Key for Web Crypto AES-GCM const cryptoAPI = getCrypto(); const keyMaterial = await cryptoAPI.subtle.importKey('raw', derivedKeyBuffer, { name: 'AES-GCM' }, false, ['decrypt']); // 6. Decrypt using AES-256-GCM // Web Crypto API expects the auth tag appended to ciphertext const combinedLength = ciphertext.length + authTag.length; const data = new Uint8Array(combinedLength); data.set(ciphertext, 0); data.set(authTag, ciphertext.length); const decryptedBuffer = await cryptoAPI.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 // 16 bytes = 128 bits }, keyMaterial, data); // 7. Convert to string and parse JSON const decoder = new TextDecoder(); const stringValue = decoder.decode(decryptedBuffer); return JSON.parse(stringValue); } catch (error) { throw new Error(`Failed to decrypt value. Please check that the private key is valid and matches the public key used for encryption. Error: ${error instanceof Error ? error.message : String(error)}`); } }