Skip to content

Commit 879dd60

Browse files
authored
Merge pull request #30 from typelets/feature/mobile-diagram-display
Adds Mermaid.js diagram rendering with pinch-to-zoom on mobile and fixes critical scroll sticky by removing cache clearing bug. Performance improved 3-5x with optimized FlatList rendering and single-update decryption strategy.
2 parents 908340d + 0b88759 commit 879dd60

File tree

19 files changed

+665
-238
lines changed

19 files changed

+665
-238
lines changed

apps/mobile/v1/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import 'expo-router/entry';
22

3+
// Install crypto polyfills FIRST (before any other imports that might need Buffer)
4+
import { install } from 'react-native-quick-crypto';
5+
install();
6+
37
import * as Sentry from '@sentry/react-native';
48
import Constants from 'expo-constants';
59
import { Platform } from 'react-native';

apps/mobile/v1/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"expo-font": "~14.0.8",
3131
"expo-haptics": "~15.0.7",
3232
"expo-image": "~3.0.8",
33+
"expo-linear-gradient": "~15.0.7",
3334
"expo-linking": "~8.0.8",
3435
"expo-router": "~6.0.8",
3536
"expo-screen-orientation": "~9.0.7",
@@ -46,6 +47,7 @@
4647
"react": "19.1.0",
4748
"react-native": "0.81.4",
4849
"react-native-gesture-handler": "~2.28.0",
50+
"react-native-quick-crypto": "^0.7.17",
4951
"react-native-reanimated": "~4.1.1",
5052
"react-native-safe-area-context": "~5.6.0",
5153
"react-native-screens": "~4.16.0",

apps/mobile/v1/src/constants/ui.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Shared UI constants for consistent design across the app
33
*/
44

5-
const SHARED_BORDER_RADIUS = 8;
5+
const SHARED_BORDER_RADIUS = 4;
66

77
export const NOTE_CARD = {
88
BORDER_RADIUS: SHARED_BORDER_RADIUS,

apps/mobile/v1/src/lib/encryption/EncryptionService.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { DecryptedData, EncryptedNote, PotentiallyEncrypted } from './types';
1717
export class MobileEncryptionService {
1818
private cache: DecryptionCache;
1919
private masterPasswordMode = false;
20+
private keyCache: Map<string, string> = new Map(); // Cache for derived keys by salt
2021

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

34+
// Check key cache first (critical for performance!)
35+
const cacheKey = `${userId}:${saltBase64}`;
36+
const cachedKey = this.keyCache.get(cacheKey);
37+
if (cachedKey) {
38+
return cachedKey;
39+
}
40+
3341
try {
3442
const masterKey = await getMasterKey(userId);
3543

3644
if (this.masterPasswordMode && masterKey) {
3745
// In master password mode, return the stored key directly
46+
this.keyCache.set(cacheKey, masterKey);
3847
return masterKey;
3948
}
4049

4150
// For non-master password mode, derive key from user secret and salt
4251
const userSecret = await getUserSecret(userId);
43-
return await deriveEncryptionKey(userId, userSecret, saltBase64);
52+
const derivedKey = await deriveEncryptionKey(userId, userSecret, saltBase64);
53+
54+
// Cache the derived key to avoid expensive re-derivation
55+
this.keyCache.set(cacheKey, derivedKey);
56+
57+
return derivedKey;
4458
} catch (error) {
4559
throw new Error(`Key derivation failed: ${error}`);
4660
}
@@ -150,13 +164,23 @@ export class MobileEncryptionService {
150164
*/
151165
clearKeys(): void {
152166
this.cache.clearAll();
167+
this.keyCache.clear(); // Also clear key derivation cache
153168
}
154169

155170
/**
156171
* Clear cache for a specific note
157172
*/
158173
clearNoteCache(userId: string, encryptedTitle?: string): void {
159174
this.cache.clearUser(userId, encryptedTitle);
175+
176+
// Clear key cache entries for this user
177+
const keysToDelete: string[] = [];
178+
for (const key of this.keyCache.keys()) {
179+
if (key.startsWith(`${userId}:`)) {
180+
keysToDelete.push(key);
181+
}
182+
}
183+
keysToDelete.forEach((key) => this.keyCache.delete(key));
160184
}
161185

162186
/**

apps/mobile/v1/src/lib/encryption/core/aes.ts

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,49 @@
11
/**
22
* AES-GCM Encryption/Decryption
3-
* Using node-forge for compatibility with web app
3+
* Using react-native-quick-crypto when available (fast native implementation)
4+
* Falls back to node-forge for compatibility
45
*/
56

67
import forge from 'node-forge';
78

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

11+
// Try to import native crypto, but don't fail if not available (Expo Go)
12+
let createCipheriv: any = null;
13+
let createDecipheriv: any = null;
14+
let QuickCryptoBuffer: any = null;
15+
16+
// Try to get Buffer from global scope (polyfilled by react-native-quick-crypto)
17+
try {
18+
// @ts-ignore - Buffer should be global after react-native-quick-crypto is loaded
19+
QuickCryptoBuffer = global.Buffer || Buffer;
20+
} catch (e) {
21+
// Buffer not available globally
22+
}
23+
24+
try {
25+
const quickCrypto = require('react-native-quick-crypto');
26+
27+
// Get the cipher functions
28+
createCipheriv = quickCrypto.createCipheriv;
29+
createDecipheriv = quickCrypto.createDecipheriv;
30+
31+
if (createCipheriv && createDecipheriv && QuickCryptoBuffer) {
32+
console.log('[Encryption] Native AES-GCM available - will use fast implementation');
33+
} else {
34+
console.log('[Encryption] Native AES-GCM partially available but missing functions');
35+
if (__DEV__) {
36+
console.log('[Encryption] Available:', {
37+
createCipheriv: !!createCipheriv,
38+
createDecipheriv: !!createDecipheriv,
39+
Buffer: !!QuickCryptoBuffer
40+
});
41+
}
42+
}
43+
} catch (error) {
44+
console.log('[Encryption] Native AES-GCM not available - using node-forge');
45+
}
46+
1047
/**
1148
* Encrypt plaintext using AES-GCM
1249
*/
@@ -15,30 +52,39 @@ export async function encryptWithAESGCM(
1552
keyBase64: string,
1653
ivBase64: string
1754
): Promise<string> {
18-
// Convert base64 to forge-compatible format
55+
// Try native implementation first (if available)
56+
if (createCipheriv && QuickCryptoBuffer) {
57+
try {
58+
const key = QuickCryptoBuffer.from(keyBase64, 'base64');
59+
const iv = QuickCryptoBuffer.from(ivBase64, 'base64');
60+
61+
const cipher = createCipheriv('aes-256-gcm', key, iv);
62+
63+
let encrypted = cipher.update(plaintext, 'utf8');
64+
encrypted = QuickCryptoBuffer.concat([encrypted, cipher.final()]);
65+
66+
const authTag = cipher.getAuthTag();
67+
const encryptedWithTag = QuickCryptoBuffer.concat([encrypted, authTag]);
68+
69+
return encryptedWithTag.toString('base64');
70+
} catch (error) {
71+
console.warn('[Encryption] Native AES-GCM encryption failed, falling back to node-forge:', error);
72+
}
73+
}
74+
75+
// Fallback to node-forge
1976
const key = forge.util.decode64(keyBase64);
2077
const iv = forge.util.decode64(ivBase64);
2178

22-
// Create AES-GCM cipher
2379
const cipher = forge.cipher.createCipher('AES-GCM', key);
24-
25-
// Start encryption with IV
2680
cipher.start({ iv: forge.util.createBuffer(iv) });
27-
28-
// Update with plaintext
2981
cipher.update(forge.util.createBuffer(plaintext, 'utf8'));
30-
31-
// Finish encryption
3282
cipher.finish();
3383

34-
// Get ciphertext and auth tag
3584
const ciphertext = cipher.output.getBytes();
3685
const authTag = cipher.mode.tag.getBytes();
37-
38-
// Combine ciphertext + auth tag (Web Crypto API format)
3986
const encryptedWithTag = ciphertext + authTag;
4087

41-
// Convert to base64
4288
return forge.util.encode64(encryptedWithTag);
4389
}
4490

@@ -50,13 +96,45 @@ export async function decryptWithAESGCM(
5096
keyBase64: string,
5197
ivBase64: string
5298
): Promise<string> {
53-
// Convert base64 to forge-compatible format
99+
// Try native implementation first (if available)
100+
if (createDecipheriv && QuickCryptoBuffer) {
101+
try {
102+
const key = QuickCryptoBuffer.from(keyBase64, 'base64');
103+
const iv = QuickCryptoBuffer.from(ivBase64, 'base64');
104+
const encryptedDataWithTag = QuickCryptoBuffer.from(encryptedBase64, 'base64');
105+
106+
const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH;
107+
108+
if (encryptedDataWithTag.length < tagLength) {
109+
throw new Error(
110+
`Encrypted data too short for GCM (${encryptedDataWithTag.length} bytes, need at least ${tagLength})`
111+
);
112+
}
113+
114+
// Split the data: ciphertext + auth tag (last 16 bytes)
115+
const ciphertext = encryptedDataWithTag.subarray(0, -tagLength);
116+
const authTag = encryptedDataWithTag.subarray(-tagLength);
117+
118+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
119+
decipher.setAuthTag(authTag);
120+
121+
let decrypted = decipher.update(ciphertext);
122+
decrypted = QuickCryptoBuffer.concat([decrypted, decipher.final()]);
123+
124+
return decrypted.toString('utf8');
125+
} catch (error) {
126+
if (__DEV__) {
127+
console.error('[Encryption] ❌ Native AES-GCM decryption failed:', error);
128+
console.error('[Encryption] Error details:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
129+
}
130+
}
131+
}
132+
133+
// Fallback to node-forge
54134
const key = forge.util.decode64(keyBase64);
55135
const iv = forge.util.decode64(ivBase64);
56136
const encryptedDataWithTag = forge.util.decode64(encryptedBase64);
57137

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

62140
if (encryptedDataWithTag.length < tagLength) {
@@ -65,23 +143,17 @@ export async function decryptWithAESGCM(
65143
);
66144
}
67145

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

72-
// Create AES-GCM decipher
73149
const decipher = forge.cipher.createDecipher('AES-GCM', key);
74-
75-
// Start decryption with IV and auth tag
76150
decipher.start({
77151
iv: forge.util.createBuffer(iv),
78152
tag: forge.util.createBuffer(authTag),
79153
});
80154

81-
// Update with ciphertext
82155
decipher.update(forge.util.createBuffer(ciphertext));
83156

84-
// Finish and verify auth tag
85157
const success = decipher.finish();
86158

87159
if (!success) {

apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,90 @@
11
/**
22
* Key Derivation Functions
3-
* PBKDF2 implementation using node-forge
3+
* PBKDF2 implementation using node-forge (for Expo Go compatibility)
4+
* Can be upgraded to native implementation in development builds
45
*/
56

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

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

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

1225
/**
13-
* PBKDF2 implementation using node-forge to match web app
14-
* Wrapped with InteractionManager to ensure UI updates before blocking operation
26+
* PBKDF2 implementation with automatic native/fallback selection
27+
* - Uses react-native-quick-crypto if available (development builds)
28+
* - Falls back to node-forge for Expo Go compatibility
29+
*
30+
* Performance:
31+
* - Native: ~2-5 seconds, non-blocking
32+
* - Fallback: ~120 seconds, UI responsive after initial delay
1533
*/
1634
export async function pbkdf2(
1735
password: string,
1836
salt: string,
1937
iterations: number = ENCRYPTION_CONFIG.ITERATIONS,
2038
keyLength: number = ENCRYPTION_CONFIG.KEY_LENGTH
2139
): Promise<string> {
22-
try {
23-
// Wait for any pending interactions (UI updates) to complete before blocking
24-
await new Promise(resolve => {
25-
InteractionManager.runAfterInteractions(() => {
26-
resolve(true);
27-
});
28-
});
40+
// Try native implementation first (if available)
41+
if (nativePbkdf2 && QuickCryptoBuffer) {
42+
try {
43+
const passwordBuffer = QuickCryptoBuffer.from(password, 'utf8');
44+
const saltBuffer = QuickCryptoBuffer.from(salt, 'utf8');
2945

30-
// Small delay to ensure loading UI is fully rendered
31-
await new Promise(resolve => setTimeout(resolve, 100));
46+
const derivedKey = await nativePbkdf2(
47+
passwordBuffer,
48+
saltBuffer,
49+
iterations,
50+
keyLength / 8,
51+
'sha256'
52+
);
3253

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

36-
// Salt is already a string, use it directly
37-
const saltBytes = forge.util.encodeUtf8(salt);
60+
// Fallback to node-forge
61+
return pbkdf2Fallback(password, salt, iterations, keyLength);
62+
}
63+
64+
/**
65+
* Fallback PBKDF2 implementation using node-forge
66+
* Used only if native implementation is not available
67+
*/
68+
async function pbkdf2Fallback(
69+
password: string,
70+
salt: string,
71+
iterations: number,
72+
keyLength: number
73+
): Promise<string> {
74+
// Convert inputs to proper format
75+
const passwordBytes = forge.util.encodeUtf8(password);
76+
const saltBytes = forge.util.encodeUtf8(salt);
3877

39-
// Perform PBKDF2 computation (will block for ~2 minutes with 250k iterations)
40-
// This is synchronous and will freeze the UI, but we've ensured the loading screen is shown
41-
const derivedKey = forge.pkcs5.pbkdf2(
42-
passwordBytes,
43-
saltBytes,
44-
iterations,
45-
keyLength / 8, // Convert bits to bytes
46-
'sha256'
47-
);
78+
// Perform PBKDF2 computation (synchronous - will block UI)
79+
const derivedKey = forge.pkcs5.pbkdf2(
80+
passwordBytes,
81+
saltBytes,
82+
iterations,
83+
keyLength / 8,
84+
'sha256'
85+
);
4886

49-
return forge.util.encode64(derivedKey);
50-
} catch (error) {
51-
throw new Error(`PBKDF2 key derivation failed: ${error}`);
52-
}
87+
return forge.util.encode64(derivedKey);
5388
}
5489

5590
/**

0 commit comments

Comments
 (0)