From 497d3414df2cf9b0e677f8799e660bf1f72c5b71 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 23 Feb 2026 14:35:47 +0530 Subject: [PATCH] feat: implement Gmail metadata synchronization service - Added a new file `metadataSync.ts` to handle the synchronization of Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event. - Updated import paths in `SkillProvider` to reflect the new location of the Gmail metadata synchronization function. - Enhanced type definitions for Gmail profile and email summaries to ensure proper data structure during synchronization. --- .../services/metadataSync.ts} | 2 +- src/providers/SkillProvider.tsx | 2 +- src/services/api/userApi.ts | 5 +- src/utils/desktopDeepLinkListener.ts | 32 +++++++++++-- src/utils/integrationTokensCrypto.ts | 47 ++++++++++++++----- 5 files changed, 68 insertions(+), 20 deletions(-) rename src/lib/{skills/gmailMetadataSync.ts => gmail/services/metadataSync.ts} (96%) diff --git a/src/lib/skills/gmailMetadataSync.ts b/src/lib/gmail/services/metadataSync.ts similarity index 96% rename from src/lib/skills/gmailMetadataSync.ts rename to src/lib/gmail/services/metadataSync.ts index 520a65931..916d7240a 100644 --- a/src/lib/skills/gmailMetadataSync.ts +++ b/src/lib/gmail/services/metadataSync.ts @@ -3,7 +3,7 @@ * `integration:metadata-sync` socket event so the server can merge them * into the user's Google OAuth integration metadata. */ -import { emitViaRustSocket } from '../../utils/tauriSocket'; +import { emitViaRustSocket } from '../../../utils/tauriSocket'; const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; const PROVIDER_GOOGLE = 'gmail'; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 89f7f8794..291d79724 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -22,7 +22,7 @@ import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSli import { syncGmailMetadataToBackend, type GmailStateForSync, -} from '../lib/skills/gmailMetadataSync'; +} from '../lib/gmail/services/metadataSync'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 419b182d0..56285a635 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -22,9 +22,6 @@ export const userApi = { * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { - await apiClient.post<{ success: boolean; data: unknown }>( - '/settings/onboarding-complete', - {} - ); + await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {}); }, }; diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index aae2c6157..c65a4ae65 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -90,7 +90,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { } const encryptionKeyHex = state.auth.encryptionKeyByUser[userId]; - if (!encryptionKeyHex) { + if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') { console.warn( '[DeepLink] Cannot fetch integration tokens: no encryption key found for user', userId @@ -98,8 +98,34 @@ const handleOAuthDeepLink = async (parsed: URL) => { return; } - const keyForBackend = hexToBase64(encryptionKeyHex); - const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex); + const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, ''); + if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])', + { userId, encryptionKeyHex } + ); + return; + } + + let keyForBackend: string; + try { + keyForBackend = hexToBase64(encryptionKeyHex); + } catch (e) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed', + { userId, encryptionKeyHex, error: e } + ); + return; + } + if (!keyForBackend) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64', + { userId, encryptionKeyHex } + ); + return; + } + + const response = await fetchIntegrationTokens(integrationId, keyForBackend); if (!response.success || !response.data?.encrypted) { console.warn( '[DeepLink] Integration tokens response missing encrypted payload for integration', diff --git a/src/utils/integrationTokensCrypto.ts b/src/utils/integrationTokensCrypto.ts index 1c7a5eaab..bc1aaf45d 100644 --- a/src/utils/integrationTokensCrypto.ts +++ b/src/utils/integrationTokensCrypto.ts @@ -12,13 +12,23 @@ export type IntegrationTokensPayload = { }; /** Stored value: encrypted blob only; decrypt with key when needed */ -export type StoredIntegrationTokens = { - encrypted: string; -}; +export type StoredIntegrationTokens = { encrypted: string }; + +const HEX_REGEX = /^[0-9a-fA-F]*$/; export function hexToBytes(hex: string): Uint8Array { const cleanHex = hex.trim().replace(/^0x/i, ''); if (!cleanHex) return new Uint8Array(); + if (cleanHex.length % 2 !== 0) { + throw new TypeError( + `hexToBytes: hex string must have even length (got ${cleanHex.length})` + ); + } + if (!HEX_REGEX.test(cleanHex)) { + throw new TypeError( + 'hexToBytes: hex string must contain only [0-9a-fA-F] characters' + ); + } 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); @@ -63,8 +73,8 @@ export async function decryptIntegrationTokens( } const keyBytes = hexToBytes(keyHex); - if (keyBytes.length === 0) { - throw new Error('Invalid encryption key'); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); } const combined = base64ToBytes(encryptedPayload); @@ -101,21 +111,36 @@ export async function decryptIntegrationTokens( * 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 { +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'); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); + } + + try { + const payload = JSON.parse(plaintext) as Record; + if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) { + throw new Error( + 'Payload must include a non-empty expiresAt field when using deterministic IV' + ); + } + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error( + 'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV' + ); + } + throw e; } const messageBytes = new TextEncoder().encode(plaintext); + // Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV + // (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats. const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes); const iv = new Uint8Array(hashBuffer).slice(0, 16);