From 596a742035e79bb16dfe2f121a7ecb6efec86c3c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 12:58:13 +0530 Subject: [PATCH] refactor: move integration token encryption/decryption logic to a new utility file - Extracted encryption and decryption functions for integration tokens into `integrationTokensCrypto.ts`. - Removed redundant functions from `desktopDeepLinkListener.ts` to streamline the code. - Updated deep link handling to store only the encrypted tokens instead of decrypted ones. - Improved type definitions for integration tokens and their payloads. --- src/utils/desktopDeepLinkListener.ts | 103 +----------------- src/utils/integrationTokensCrypto.ts | 150 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 100 deletions(-) create mode 100644 src/utils/integrationTokensCrypto.ts diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index d56657c58..beaed8af5 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -6,13 +6,7 @@ import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authA import { store } from '../store'; import { setToken } from '../store/authSlice'; import { setSkillState } from '../store/skillsSlice'; - -type IntegrationTokensPayload = { - accessToken: string; - refreshToken: string; - /** ISO timestamp string */ - expiresAt: string; -}; +import { hexToBase64 } from './integrationTokensCrypto'; function getCurrentUserId(): string | null { const state = store.getState(); @@ -34,85 +28,6 @@ function getCurrentUserId(): string | null { } } -function hexToBase64(hex: string): string { - const bytes = hexToBytes(hex); - if (bytes.length === 0) return ''; - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function hexToBytes(hex: string): Uint8Array { - const cleanHex = hex.trim().replace(/^0x/i, ''); - if (!cleanHex) return new Uint8Array(); - const bytes = new Uint8Array(cleanHex.length / 2); - for (let i = 0; i < cleanHex.length; i += 2) { - bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); - } - return bytes; -} - -function base64ToBytes(b64: string): Uint8Array { - // Normalize potential URL-safe base64 and missing padding - let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); - const pad = normalized.length % 4; - if (pad === 2) normalized += '=='; - else if (pad === 3) normalized += '='; - - const binary = atob(normalized); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -async function decryptIntegrationTokensWithKey( - encryptedPayload: string, - keyHex: string -): Promise { - if (typeof crypto === 'undefined' || !crypto.subtle) { - throw new Error('Web Crypto API is not available for decryption'); - } - - const keyBytes = hexToBytes(keyHex); - if (keyBytes.length === 0) { - throw new Error('Invalid encryption key'); - } - - const combined = base64ToBytes(encryptedPayload); - // Backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData (rest) - if (combined.length <= 32) { - throw new Error('Encrypted payload too short'); - } - - const iv = combined.slice(0, 16); - const authTag = combined.slice(16, 32); - const encryptedData = combined.slice(32); - const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); - ciphertextWithTag.set(encryptedData, 0); - ciphertextWithTag.set(authTag, encryptedData.length); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyBytes as unknown as BufferSource, - { name: 'AES-GCM' }, - false, - ['decrypt'] - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv, tagLength: 128 }, - cryptoKey, - ciphertextWithTag as unknown as BufferSource - ); - - return new TextDecoder().decode(decrypted); -} - - /** * Handle an `alphahuman://auth?token=...` deep link for login. */ @@ -189,18 +104,6 @@ const handleOAuthDeepLink = async (parsed: URL) => { return; } - let decryptedTokens: IntegrationTokensPayload; - try { - const plaintext = await decryptIntegrationTokensWithKey( - response.data.encrypted, - encryptionKeyHex - ); - decryptedTokens = JSON.parse(plaintext) as IntegrationTokensPayload; - } catch (err) { - console.error('[DeepLink] Failed to decrypt integration tokens:', err); - return; - } - const existingState = state.skills.skillStates[skillId] ?? {}; store.dispatch( setSkillState({ @@ -208,8 +111,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { state: { ...existingState, oauthTokens: { - ...(existingState.oauthTokens as Record | undefined), - [integrationId]: decryptedTokens, + ...(existingState.oauthTokens as Record | undefined), + [integrationId]: { encrypted: response.data.encrypted }, }, }, }) diff --git a/src/utils/integrationTokensCrypto.ts b/src/utils/integrationTokensCrypto.ts new file mode 100644 index 000000000..1c7a5eaab --- /dev/null +++ b/src/utils/integrationTokensCrypto.ts @@ -0,0 +1,150 @@ +/** + * Helpers for encrypting/decrypting OAuth integration tokens. + * Matches backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData. + * IV is derived from SHA-256(message) for encryption (deterministic). + */ + +export type IntegrationTokensPayload = { + accessToken: string; + refreshToken: string; + /** ISO timestamp string */ + expiresAt: string; +}; + +/** Stored value: encrypted blob only; decrypt with key when needed */ +export type StoredIntegrationTokens = { + encrypted: string; +}; + +export function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.trim().replace(/^0x/i, ''); + if (!cleanHex) return new Uint8Array(); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +export function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + if (bytes.length === 0) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToBytes(b64: string): Uint8Array { + let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4; + if (pad === 2) normalized += '=='; + else if (pad === 3) normalized += '='; + + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Decrypt an encrypted tokens payload (base64) using a 32-byte key (hex). + * Backend format: IV (16) + AuthTag (16) + EncryptedData. + */ +export async function decryptIntegrationTokens( + encryptedPayload: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for decryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const combined = base64ToBytes(encryptedPayload); + if (combined.length <= 32) { + throw new Error('Encrypted payload too short'); + } + + const iv = combined.slice(0, 16); + const authTag = combined.slice(16, 32); + const encryptedData = combined.slice(32); + const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); + ciphertextWithTag.set(encryptedData, 0); + ciphertextWithTag.set(authTag, encryptedData.length); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + ciphertextWithTag as unknown as BufferSource + ); + + return new TextDecoder().decode(decrypted); +} + +/** + * Encrypt a plaintext string (e.g. JSON) using a 32-byte key (hex). + * Matches backend: deterministic IV = first 16 bytes of SHA-256(message). + * Returns base64(IV + AuthTag + EncryptedData). + */ +export async function encryptIntegrationTokens( + plaintext: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for encryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const messageBytes = new TextEncoder().encode(plaintext); + + const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes); + const iv = new Uint8Array(hashBuffer).slice(0, 16); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + const encryptedBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + messageBytes + ); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const authTag = encryptedArray.slice(-16); + const encryptedData = encryptedArray.slice(0, -16); + + const combined = new Uint8Array(iv.length + authTag.length + encryptedData.length); + combined.set(iv, 0); + combined.set(authTag, iv.length); + combined.set(encryptedData, iv.length + authTag.length); + + let binary = ''; + for (let i = 0; i < combined.length; i++) { + binary += String.fromCharCode(combined[i]); + } + return btoa(binary); +}