Skip to content

Commit b188ae8

Browse files
author
Ubuntu
committed
feat(mobile): store API key securely using expo-secure-store
- Add expo-secure-store dependency for iOS Keychain/Android Keystore - Store API key in secure storage instead of AsyncStorage - Update tunnelPersistence.ts to use SecureStore for apiKey field - Clear secure key on tunnel metadata clear - Fix isCloudflareTunnel type from string to boolean
1 parent ad3e2bf commit b188ae8

File tree

2 files changed

+36
-11
lines changed

2 files changed

+36
-11
lines changed

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"expo-speech": "~14.0.7",
2525
"expo-speech-recognition": "^2.1.2",
2626
"expo-status-bar": "~3.0.8",
27+
"expo-secure-store": "~14.0.8",
2728
"react": "19.1.0",
2829
"react-dom": "19.1.0",
2930
"react-native": "0.81.4",

apps/mobile/src/lib/tunnelPersistence.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import * as SecureStore from 'expo-secure-store';
23

34
const TUNNEL_METADATA_KEY = 'speakmcp_tunnel_metadata_v1';
5+
const API_KEY_SECURE_KEY = 'speakmcp_api_key_secure';
46

57
/**
68
* Tunnel metadata for connection persistence.
79
* Stores information needed to resume or reconnect to a tunnel.
10+
* API key is stored securely using expo-secure-store (iOS Keychain/Android Keystore).
811
*/
912
export interface TunnelMetadata {
1013
/** The base URL of the tunnel endpoint */
1114
baseUrl: string;
12-
/** API key for authentication */
15+
/** API key for authentication - stored in secure storage */
1316
apiKey: string;
1417
/** Timestamp of last successful connection */
1518
lastConnectedAt: number;
@@ -23,19 +26,27 @@ export interface TunnelMetadata {
2326

2427
/**
2528
* Save tunnel metadata for later reconnection.
26-
*
27-
* TODO: Security enhancement - Consider storing the apiKey in secure storage
28-
* (expo-secure-store for iOS Keychain/Android Keystore) instead of AsyncStorage
29-
* for production deployments where the API key grants sensitive access.
29+
* API key is stored securely using expo-secure-store.
3030
*/
3131
export async function saveTunnelMetadata(metadata: TunnelMetadata): Promise<void> {
3232
try {
33-
await AsyncStorage.setItem(TUNNEL_METADATA_KEY, JSON.stringify(metadata));
33+
// Store apiKey securely using expo-secure-store (iOS Keychain/Android Keystore)
34+
if (metadata.apiKey) {
35+
await SecureStore.setItemAsync(API_KEY_SECURE_KEY, metadata.apiKey, {
36+
requireAuthentication: false,
37+
});
38+
}
39+
40+
// Store non-sensitive metadata in AsyncStorage
41+
const { apiKey, ...safeMetadata } = metadata;
42+
await AsyncStorage.setItem(TUNNEL_METADATA_KEY, JSON.stringify(safeMetadata));
43+
3444
console.log('[TunnelPersistence] Saved tunnel metadata:', {
3545
baseUrl: metadata.baseUrl,
3646
hasApiKey: !!metadata.apiKey,
3747
hasSessionId: !!metadata.sessionId,
3848
hasResumeToken: !!metadata.resumeToken,
49+
secureStorageUsed: true,
3950
});
4051
} catch (error) {
4152
console.error('[TunnelPersistence] Failed to save tunnel metadata:', error);
@@ -45,27 +56,39 @@ export async function saveTunnelMetadata(metadata: TunnelMetadata): Promise<void
4556
/**
4657
* Load previously saved tunnel metadata.
4758
* Returns null if no metadata exists or if it's invalid.
59+
* API key is retrieved from secure storage.
4860
*/
4961
export async function loadTunnelMetadata(): Promise<TunnelMetadata | null> {
5062
try {
63+
// Load non-sensitive metadata from AsyncStorage
5164
const stored = await AsyncStorage.getItem(TUNNEL_METADATA_KEY);
5265
if (!stored) {
5366
return null;
5467
}
5568

5669
const parsed = JSON.parse(stored);
5770

58-
// Validate required fields exist and have correct types
71+
// Validate required fields exist and have correct types (except apiKey)
5972
if (
6073
typeof parsed.baseUrl !== 'string' ||
61-
typeof parsed.apiKey !== 'string' ||
6274
typeof parsed.lastConnectedAt !== 'number'
6375
) {
6476
console.warn('[TunnelPersistence] Invalid stored metadata: missing required fields or incorrect types');
6577
return null;
6678
}
6779

68-
return parsed as TunnelMetadata;
80+
// Retrieve apiKey from secure storage
81+
const apiKey = await SecureStore.getItemAsync(API_KEY_SECURE_KEY);
82+
83+
if (!apiKey) {
84+
console.warn('[TunnelPersistence] API key not found in secure storage');
85+
return null;
86+
}
87+
88+
return {
89+
...parsed,
90+
apiKey,
91+
} as TunnelMetadata;
6992
} catch (error) {
7093
console.error('[TunnelPersistence] Failed to load tunnel metadata:', error);
7194
return null;
@@ -96,11 +119,13 @@ export async function updateTunnelMetadata(
96119

97120
/**
98121
* Clear tunnel metadata (for logout or reset scenarios).
122+
* Also clears the secure-stored API key.
99123
*/
100124
export async function clearTunnelMetadata(): Promise<void> {
101125
try {
102126
await AsyncStorage.removeItem(TUNNEL_METADATA_KEY);
103-
console.log('[TunnelPersistence] Cleared tunnel metadata');
127+
await SecureStore.deleteItemAsync(API_KEY_SECURE_KEY);
128+
console.log('[TunnelPersistence] Cleared tunnel metadata and secure API key');
104129
} catch (error) {
105130
console.error('[TunnelPersistence] Failed to clear tunnel metadata:', error);
106131
}
@@ -127,4 +152,3 @@ export async function isTunnelMetadataFresh(maxAgeMs: number = 24 * 60 * 60 * 10
127152
const age = Date.now() - metadata.lastConnectedAt;
128153
return age < maxAgeMs;
129154
}
130-

0 commit comments

Comments
 (0)