Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mobile/v1/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import 'expo-router/entry';

// Install crypto polyfills FIRST (before any other imports that might need Buffer)
import { install } from 'react-native-quick-crypto';
install();

import * as Sentry from '@sentry/react-native';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/v1/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.8",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.8",
"expo-screen-orientation": "~9.0.7",
Expand All @@ -46,6 +47,7 @@
"react": "19.1.0",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0",
"react-native-quick-crypto": "^0.7.17",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/v1/src/constants/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Shared UI constants for consistent design across the app
*/

const SHARED_BORDER_RADIUS = 8;
const SHARED_BORDER_RADIUS = 4;

export const NOTE_CARD = {
BORDER_RADIUS: SHARED_BORDER_RADIUS,
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/v1/src/lib/encryption/EncryptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DecryptedData, EncryptedNote, PotentiallyEncrypted } from './types';
export class MobileEncryptionService {
private cache: DecryptionCache;
private masterPasswordMode = false;
private keyCache: Map<string, string> = new Map(); // Cache for derived keys by salt

constructor() {
this.cache = new DecryptionCache();
Expand All @@ -30,17 +31,30 @@ export class MobileEncryptionService {
throw new Error('Key derivation attempted without user ID');
}

// Check key cache first (critical for performance!)
const cacheKey = `${userId}:${saltBase64}`;
const cachedKey = this.keyCache.get(cacheKey);
if (cachedKey) {
return cachedKey;
}

try {
const masterKey = await getMasterKey(userId);

if (this.masterPasswordMode && masterKey) {
// In master password mode, return the stored key directly
this.keyCache.set(cacheKey, masterKey);
return masterKey;
}

// For non-master password mode, derive key from user secret and salt
const userSecret = await getUserSecret(userId);
return await deriveEncryptionKey(userId, userSecret, saltBase64);
const derivedKey = await deriveEncryptionKey(userId, userSecret, saltBase64);

// Cache the derived key to avoid expensive re-derivation
this.keyCache.set(cacheKey, derivedKey);

return derivedKey;
} catch (error) {
throw new Error(`Key derivation failed: ${error}`);
}
Expand Down Expand Up @@ -150,13 +164,23 @@ export class MobileEncryptionService {
*/
clearKeys(): void {
this.cache.clearAll();
this.keyCache.clear(); // Also clear key derivation cache
}

/**
* Clear cache for a specific note
*/
clearNoteCache(userId: string, encryptedTitle?: string): void {
this.cache.clearUser(userId, encryptedTitle);

// Clear key cache entries for this user
const keysToDelete: string[] = [];
for (const key of this.keyCache.keys()) {
if (key.startsWith(`${userId}:`)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach((key) => this.keyCache.delete(key));
}

/**
Expand Down
116 changes: 94 additions & 22 deletions apps/mobile/v1/src/lib/encryption/core/aes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
/**
* AES-GCM Encryption/Decryption
* Using node-forge for compatibility with web app
* Using react-native-quick-crypto when available (fast native implementation)
* Falls back to node-forge for compatibility
*/

import forge from 'node-forge';

import { ENCRYPTION_CONFIG } from '../config';

// Try to import native crypto, but don't fail if not available (Expo Go)
let createCipheriv: any = null;
let createDecipheriv: any = null;
let QuickCryptoBuffer: any = null;

// Try to get Buffer from global scope (polyfilled by react-native-quick-crypto)
try {
// @ts-ignore - Buffer should be global after react-native-quick-crypto is loaded
QuickCryptoBuffer = global.Buffer || Buffer;
} catch (e) {
// Buffer not available globally
}

try {
const quickCrypto = require('react-native-quick-crypto');

// Get the cipher functions
createCipheriv = quickCrypto.createCipheriv;
createDecipheriv = quickCrypto.createDecipheriv;

if (createCipheriv && createDecipheriv && QuickCryptoBuffer) {
console.log('[Encryption] Native AES-GCM available - will use fast implementation');
} else {
console.log('[Encryption] Native AES-GCM partially available but missing functions');
if (__DEV__) {
console.log('[Encryption] Available:', {
createCipheriv: !!createCipheriv,
createDecipheriv: !!createDecipheriv,
Buffer: !!QuickCryptoBuffer
});
}
}
} catch (error) {
console.log('[Encryption] Native AES-GCM not available - using node-forge');
}

/**
* Encrypt plaintext using AES-GCM
*/
Expand All @@ -15,30 +52,39 @@ export async function encryptWithAESGCM(
keyBase64: string,
ivBase64: string
): Promise<string> {
// Convert base64 to forge-compatible format
// Try native implementation first (if available)
if (createCipheriv && QuickCryptoBuffer) {
try {
const key = QuickCryptoBuffer.from(keyBase64, 'base64');
const iv = QuickCryptoBuffer.from(ivBase64, 'base64');

const cipher = createCipheriv('aes-256-gcm', key, iv);

let encrypted = cipher.update(plaintext, 'utf8');
encrypted = QuickCryptoBuffer.concat([encrypted, cipher.final()]);

const authTag = cipher.getAuthTag();
const encryptedWithTag = QuickCryptoBuffer.concat([encrypted, authTag]);

return encryptedWithTag.toString('base64');
} catch (error) {
console.warn('[Encryption] Native AES-GCM encryption failed, falling back to node-forge:', error);
}
}

// Fallback to node-forge
const key = forge.util.decode64(keyBase64);
const iv = forge.util.decode64(ivBase64);

// Create AES-GCM cipher
const cipher = forge.cipher.createCipher('AES-GCM', key);

// Start encryption with IV
cipher.start({ iv: forge.util.createBuffer(iv) });

// Update with plaintext
cipher.update(forge.util.createBuffer(plaintext, 'utf8'));

// Finish encryption
cipher.finish();

// Get ciphertext and auth tag
const ciphertext = cipher.output.getBytes();
const authTag = cipher.mode.tag.getBytes();

// Combine ciphertext + auth tag (Web Crypto API format)
const encryptedWithTag = ciphertext + authTag;

// Convert to base64
return forge.util.encode64(encryptedWithTag);
}

Expand All @@ -50,13 +96,45 @@ export async function decryptWithAESGCM(
keyBase64: string,
ivBase64: string
): Promise<string> {
// Convert base64 to forge-compatible format
// Try native implementation first (if available)
if (createDecipheriv && QuickCryptoBuffer) {
try {
const key = QuickCryptoBuffer.from(keyBase64, 'base64');
const iv = QuickCryptoBuffer.from(ivBase64, 'base64');
const encryptedDataWithTag = QuickCryptoBuffer.from(encryptedBase64, 'base64');

const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH;

if (encryptedDataWithTag.length < tagLength) {
throw new Error(
`Encrypted data too short for GCM (${encryptedDataWithTag.length} bytes, need at least ${tagLength})`
);
}

// Split the data: ciphertext + auth tag (last 16 bytes)
const ciphertext = encryptedDataWithTag.subarray(0, -tagLength);
const authTag = encryptedDataWithTag.subarray(-tagLength);

const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);

let decrypted = decipher.update(ciphertext);
decrypted = QuickCryptoBuffer.concat([decrypted, decipher.final()]);

return decrypted.toString('utf8');
} catch (error) {
if (__DEV__) {
console.error('[Encryption] ❌ Native AES-GCM decryption failed:', error);
console.error('[Encryption] Error details:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
}
}
}

// Fallback to node-forge
const key = forge.util.decode64(keyBase64);
const iv = forge.util.decode64(ivBase64);
const encryptedDataWithTag = forge.util.decode64(encryptedBase64);

// For node-forge GCM, we need to manually handle the auth tag
// Web Crypto API embeds the auth tag at the end of the encrypted data
const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH;

if (encryptedDataWithTag.length < tagLength) {
Expand All @@ -65,23 +143,17 @@ export async function decryptWithAESGCM(
);
}

// Split the data: ciphertext + auth tag (last 16 bytes)
const ciphertext = encryptedDataWithTag.slice(0, -tagLength);
const authTag = encryptedDataWithTag.slice(-tagLength);

// Create AES-GCM decipher
const decipher = forge.cipher.createDecipher('AES-GCM', key);

// Start decryption with IV and auth tag
decipher.start({
iv: forge.util.createBuffer(iv),
tag: forge.util.createBuffer(authTag),
});

// Update with ciphertext
decipher.update(forge.util.createBuffer(ciphertext));

// Finish and verify auth tag
const success = decipher.finish();

if (!success) {
Expand Down
93 changes: 64 additions & 29 deletions apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,90 @@
/**
* Key Derivation Functions
* PBKDF2 implementation using node-forge
* PBKDF2 implementation using node-forge (for Expo Go compatibility)
* Can be upgraded to native implementation in development builds
*/

import forge from 'node-forge';
import { InteractionManager } from 'react-native';

import { ENCRYPTION_CONFIG } from '../config';

// Try to import native crypto, but don't fail if not available (Expo Go)
let nativePbkdf2: any = null;
let QuickCryptoBuffer: any = null;
try {
// Dynamic import - won't crash if module not available
const quickCrypto = require('react-native-quick-crypto');
nativePbkdf2 = quickCrypto.pbkdf2;
QuickCryptoBuffer = quickCrypto.Buffer;
console.log('[Encryption] Native PBKDF2 available - will use fast implementation');
} catch (error) {
console.log('[Encryption] Native PBKDF2 not available - using node-forge (slower but compatible with Expo Go)');
}

/**
* PBKDF2 implementation using node-forge to match web app
* Wrapped with InteractionManager to ensure UI updates before blocking operation
* PBKDF2 implementation with automatic native/fallback selection
* - Uses react-native-quick-crypto if available (development builds)
* - Falls back to node-forge for Expo Go compatibility
*
* Performance:
* - Native: ~2-5 seconds, non-blocking
* - Fallback: ~120 seconds, UI responsive after initial delay
*/
export async function pbkdf2(
password: string,
salt: string,
iterations: number = ENCRYPTION_CONFIG.ITERATIONS,
keyLength: number = ENCRYPTION_CONFIG.KEY_LENGTH
): Promise<string> {
try {
// Wait for any pending interactions (UI updates) to complete before blocking
await new Promise(resolve => {
InteractionManager.runAfterInteractions(() => {
resolve(true);
});
});
// Try native implementation first (if available)
if (nativePbkdf2 && QuickCryptoBuffer) {
try {
const passwordBuffer = QuickCryptoBuffer.from(password, 'utf8');
const saltBuffer = QuickCryptoBuffer.from(salt, 'utf8');

// Small delay to ensure loading UI is fully rendered
await new Promise(resolve => setTimeout(resolve, 100));
const derivedKey = await nativePbkdf2(
passwordBuffer,
saltBuffer,
iterations,
keyLength / 8,
'sha256'
);

// Convert inputs to proper format to match web app
const passwordBytes = forge.util.encodeUtf8(password);
return derivedKey.toString('base64');
} catch (error) {
console.warn('[Encryption] Native PBKDF2 failed, falling back to node-forge:', error);
}
}

// Salt is already a string, use it directly
const saltBytes = forge.util.encodeUtf8(salt);
// Fallback to node-forge
return pbkdf2Fallback(password, salt, iterations, keyLength);
}

/**
* Fallback PBKDF2 implementation using node-forge
* Used only if native implementation is not available
*/
async function pbkdf2Fallback(
password: string,
salt: string,
iterations: number,
keyLength: number
): Promise<string> {
// Convert inputs to proper format
const passwordBytes = forge.util.encodeUtf8(password);
const saltBytes = forge.util.encodeUtf8(salt);

// Perform PBKDF2 computation (will block for ~2 minutes with 250k iterations)
// This is synchronous and will freeze the UI, but we've ensured the loading screen is shown
const derivedKey = forge.pkcs5.pbkdf2(
passwordBytes,
saltBytes,
iterations,
keyLength / 8, // Convert bits to bytes
'sha256'
);
// Perform PBKDF2 computation (synchronous - will block UI)
const derivedKey = forge.pkcs5.pbkdf2(
passwordBytes,
saltBytes,
iterations,
keyLength / 8,
'sha256'
);

return forge.util.encode64(derivedKey);
} catch (error) {
throw new Error(`PBKDF2 key derivation failed: ${error}`);
}
return forge.util.encode64(derivedKey);
}

/**
Expand Down
Loading