mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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.
This commit is contained in:
@@ -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<string> {
|
||||
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<string, unknown> | undefined),
|
||||
[integrationId]: decryptedTokens,
|
||||
...(existingState.oauthTokens as Record<string, { encrypted: string }> | undefined),
|
||||
[integrationId]: { encrypted: response.data.encrypted },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user