diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..37c7faa --- /dev/null +++ b/metro.config.js @@ -0,0 +1,13 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const config = getDefaultConfig(__dirname); + +config.resolver.extraNodeModules = { + ...config.resolver.extraNodeModules, + crypto: path.resolve(__dirname, 'src/utils/crypto_polyfill.ts'), + stream: require.resolve('readable-stream'), // Just in case + vm: require.resolve('vm-browserify'), +}; + +module.exports = config; diff --git a/package-lock.json b/package-lock.json index 20b06ee..4e72b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/native": "^6.1.18", @@ -19,6 +21,7 @@ "expo": "~52.0.0", "expo-asset": "~11.0.5", "expo-constants": "~17.0.8", + "expo-crypto": "~14.0.2", "expo-font": "~13.0.4", "expo-haptics": "~14.0.0", "expo-image-picker": "^17.0.10", @@ -32,7 +35,9 @@ "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-view-shot": "^3.8.0", - "react-native-web": "~0.19.13" + "react-native-web": "~0.19.13", + "readable-stream": "^4.7.0", + "vm-browserify": "^1.1.2" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -3211,9 +3216,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { @@ -5579,6 +5596,15 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/exec-async": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", @@ -5756,6 +5782,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmmirror.com/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-font": { "version": "13.0.4", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz", @@ -9063,6 +9101,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -9534,6 +9581,22 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readline": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", @@ -10234,6 +10297,15 @@ "node": ">=4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -11011,6 +11083,12 @@ "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT" }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "license": "MIT" + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index d6cdd73..59217e6 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/native": "^6.1.18", @@ -20,6 +22,7 @@ "expo": "~52.0.0", "expo-asset": "~11.0.5", "expo-constants": "~17.0.8", + "expo-crypto": "~14.0.2", "expo-font": "~13.0.4", "expo-haptics": "~14.0.0", "expo-image-picker": "^17.0.10", @@ -29,11 +32,13 @@ "react-dom": "18.3.1", "react-native": "^0.76.9", "react-native-gesture-handler": "~2.20.2", - "react-native-view-shot": "^3.8.0", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", - "react-native-web": "~0.19.13" + "react-native-view-shot": "^3.8.0", + "react-native-web": "~0.19.13", + "readable-stream": "^4.7.0", + "vm-browserify": "^1.1.2" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/src/components/common/BiometricModal.tsx b/src/components/common/BiometricModal.tsx index 510b50e..a62f30a 100644 --- a/src/components/common/BiometricModal.tsx +++ b/src/components/common/BiometricModal.tsx @@ -36,7 +36,7 @@ export default function BiometricModal({ if (visible) { setIsScanning(false); scanAnimation.setValue(0); - + // Pulse animation Animated.loop( Animated.sequence([ @@ -57,32 +57,30 @@ export default function BiometricModal({ const handleScan = () => { setIsScanning(true); - + Animated.loop( Animated.sequence([ Animated.timing(scanAnimation, { toValue: 1, - duration: 800, + duration: 400, useNativeDriver: true, }), Animated.timing(scanAnimation, { toValue: 0, - duration: 800, + duration: 400, useNativeDriver: true, }), ]), - { iterations: 2 } + { iterations: 1 } ).start(() => { - setTimeout(() => { - onSuccess(); - }, 300); + onSuccess(); }); }; const backgroundColor = isDark ? colors.vault.cardBackground : colors.white; const textColor = isDark ? colors.vault.text : colors.nautical.navy; const accentColor = isDark ? colors.vault.primary : colors.nautical.teal; - const accentGradient: [string, string] = isDark + const accentGradient: [string, string] = isDark ? [colors.vault.primary, colors.vault.secondary] : [colors.nautical.teal, colors.nautical.seafoam]; @@ -97,10 +95,10 @@ export default function BiometricModal({ {/* Ship wheel watermark */} - @@ -109,7 +107,7 @@ export default function BiometricModal({ {message} - + - diff --git a/src/config/index.ts b/src/config/index.ts index cd88aff..9bec176 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -91,12 +91,18 @@ export function getVaultStorageKeys(userId: number | string | null): { INITIALIZED: string; SHARE_DEVICE: string; MNEMONIC_PART_LOCAL: string; + AES_KEY: string; + SHARE_SERVER: string; + SHARE_HEIR: string; } { const suffix = userId != null ? `_u${userId}` : '_guest'; return { INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`, SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`, MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local${suffix}`, + AES_KEY: `sentinel_aes_key${suffix}`, + SHARE_SERVER: `sentinel_share_server${suffix}`, + SHARE_HEIR: `sentinel_share_heir${suffix}`, }; } diff --git a/src/hooks/useVaultAssets.ts b/src/hooks/useVaultAssets.ts index ea51fca..ffaffeb 100644 --- a/src/hooks/useVaultAssets.ts +++ b/src/hooks/useVaultAssets.ts @@ -6,9 +6,11 @@ import { useState, useEffect, useCallback } from 'react'; import * as bip39 from 'bip39'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuth } from '../context/AuthContext'; import { assetsService } from '../services/assets.service'; -import { createAssetPayload } from '../services/vault.service'; +import { getVaultStorageKeys, DEBUG_MODE } from '../config'; +import { SentinelVault } from '../utils/crypto_core'; import { initialVaultAssets, mapApiAssetsToVaultAssets, @@ -51,7 +53,7 @@ export interface UseVaultAssetsReturn { * Vault assets list + create. Fetches on unlock when token exists; keeps mock on error. */ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn { - const { token, signOut } = useAuth(); + const { user, token, signOut } = useAuth(); const [assets, setAssets] = useState(initialVaultAssets); const [isSealing, setIsSealing] = useState(false); const [createError, setCreateError] = useState(null); @@ -101,19 +103,37 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn { setIsSealing(true); setCreateError(null); try { - const wordList = bip39.wordlists.english; - const payload = await createAssetPayload( - title.trim(), - content.trim(), - wordList, - 'note', - 0 - ); + const vaultKeys = getVaultStorageKeys(user?.id ?? null); + const [s1Str, aesKeyHex, s0Str, s2Str] = await Promise.all([ + AsyncStorage.getItem(vaultKeys.SHARE_SERVER), + AsyncStorage.getItem(vaultKeys.AES_KEY), + AsyncStorage.getItem(vaultKeys.SHARE_DEVICE), + AsyncStorage.getItem(vaultKeys.SHARE_HEIR), + ]); + + if (!s1Str || !aesKeyHex) { + throw new Error('Vault keys missing. Please re-unlock your vault.'); + } + + const vault = new SentinelVault(); + const aesKey = Buffer.from(aesKeyHex, 'hex'); + const encryptedBuffer = vault.encryptData(aesKey, content.trim()); + const content_inner_encrypted = encryptedBuffer.toString('hex'); + + if (DEBUG_MODE) { + console.log('[DEBUG] Crypto Data during Asset Creation:'); + console.log(' s0 (Device):', s0Str); + console.log(' s1 (Server):', s1Str); + console.log(' s2 (Heir): ', s2Str); + console.log(' AES Key: ', aesKeyHex); + console.log(' Encrypted: ', content_inner_encrypted); + } + await assetsService.createAsset( { - title: payload.title, - private_key_shard: payload.private_key_shard, - content_inner_encrypted: payload.content_inner_encrypted, + title: title.trim(), + private_key_shard: s1Str, + content_inner_encrypted, }, token ); @@ -143,7 +163,7 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn { setIsSealing(false); } }, - [token, refreshAssets, signOut] + [token, user, refreshAssets, signOut] ); const clearCreateError = useCallback(() => setCreateError(null), []); diff --git a/src/screens/FlowScreen.tsx b/src/screens/FlowScreen.tsx index 2dddebf..a45214f 100644 --- a/src/screens/FlowScreen.tsx +++ b/src/screens/FlowScreen.tsx @@ -29,10 +29,14 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors'; -import { aiService } from '../services/ai.service'; +import { aiService, AIMessage } from '../services/ai.service'; +import { assetsService } from '../services/assets.service'; import { useAuth } from '../context/AuthContext'; -import { AI_CONFIG } from '../config'; +import { AI_CONFIG, getVaultStorageKeys } from '../config'; import { storageService } from '../services/storage.service'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { SentinelVault } from '../utils/crypto_core'; +import { Buffer } from 'buffer'; // ============================================================================= // Type Definitions @@ -78,6 +82,18 @@ export default function FlowScreen() { const [showHistoryModal, setShowHistoryModal] = useState(false); const modalSlideAnim = useRef(new Animated.Value(0)).current; + // Summary state + const [showSummaryConfirmModal, setShowSummaryConfirmModal] = useState(false); + const [showSummaryResultModal, setShowSummaryResultModal] = useState(false); + const [isSummarizing, setIsSummarizing] = useState(false); + const [generatedSummary, setGeneratedSummary] = useState(''); + + // Save to Vault state + const [showVaultConfirmModal, setShowVaultConfirmModal] = useState(false); + const [showSaveResultModal, setShowSaveResultModal] = useState(false); + const [saveResult, setSaveResult] = useState<{ success: boolean; message: string }>({ success: true, message: '' }); + const [isSavingToVault, setIsSavingToVault] = useState(false); + const [chatHistory, setChatHistory] = useState([ // Sample history data { @@ -453,6 +469,107 @@ export default function FlowScreen() { ); }; + /** + * Handle generating summary for current conversation + */ + const handleGenerateSummary = async () => { + if (messages.length === 0) { + Alert.alert('No Messages', 'There are no messages to summarize.'); + return; + } + + if (!token) { + Alert.alert('Login Required', 'Please login to generate a summary.'); + return; + } + + setShowSummaryConfirmModal(false); + setIsSummarizing(true); + + try { + // Convert messages to AIMessage format + const aiMessages: AIMessage[] = messages.map(msg => ({ + role: msg.role, + content: msg.content, + })); + + const summary = await aiService.summarizeChat(aiMessages, token); + setGeneratedSummary(summary); + setShowSummaryResultModal(true); + } catch (error) { + console.error('Failed to generate summary:', error); + Alert.alert('Error', 'Failed to generate summary. Please try again later.'); + } finally { + setIsSummarizing(false); + } + }; + + /** + * Handle saving the generated summary to the vault + */ + const handleSaveToVault = async () => { + if (!generatedSummary || isSavingToVault) return; + + if (!token) { + Alert.alert('Login Required', 'Please login to save to vault.'); + return; + } + + setShowVaultConfirmModal(false); + setIsSavingToVault(true); + + try { + // Retrieve vault keys + if (!user) { + Alert.alert('Error', 'User information not found. Please login again.'); + return; + } + const vaultKeys = getVaultStorageKeys(user.id); + const shareServer = await AsyncStorage.getItem(vaultKeys.SHARE_SERVER); + const aesKeyHex = await AsyncStorage.getItem(vaultKeys.AES_KEY); + + if (!shareServer || !aesKeyHex) { + Alert.alert( + 'Vault Not Initialized', + 'Your vault is not fully initialized. Please set it up in the Vault tab first.' + ); + return; + } + + // Encrypt summary with AES key + const vault = new SentinelVault(); + const aesKey = Buffer.from(aesKeyHex, 'hex'); + const encryptedSummary = vault.encryptData(aesKey, generatedSummary).toString('hex'); + + // Create asset in backend + await assetsService.createAsset({ + title: `Chat Summary - ${new Date().toLocaleDateString()}`, + private_key_shard: shareServer, + content_inner_encrypted: encryptedSummary, + }, token); + + setSaveResult({ success: true, message: 'Summary encrypted and saved to your vault successfully.' }); + setShowSaveResultModal(true); + } catch (error) { + console.error('Failed to save to vault:', error); + setSaveResult({ success: false, message: 'Failed to save summary to vault. Please try again.' }); + setShowSaveResultModal(true); + } finally { + setIsSavingToVault(false); + } + }; + + /** + * Handle closing all summary related modals after successful save or manual close of result + */ + const handleFinishSaveFlow = () => { + setShowSaveResultModal(false); + if (saveResult.success) { + setShowSummaryResultModal(false); + setShowVaultConfirmModal(false); + } + }; + // ============================================================================= // Helper Functions // ============================================================================= @@ -596,6 +713,19 @@ export default function FlowScreen() { + {/* Summary Button */} + setShowSummaryConfirmModal(true)} + disabled={messages.length === 0 || isSummarizing} + > + + + {/* History Button */} + + {/* Summary Confirmation Modal */} + setShowSummaryConfirmModal(false)} + > + setShowSummaryConfirmModal(false)}> + + e.stopPropagation()}> + + + Generate Summary + + Would you like to generate a summary for the current conversation? + + + + setShowSummaryConfirmModal(false)} + > + No + + + + Yes, Generate + + + + + + + + + + {/* Summary Result Modal */} + setShowSummaryResultModal(false)} + > + setShowSummaryResultModal(false)}> + + e.stopPropagation()}> + + + + Conversation Summary + setShowSummaryResultModal(false)}> + + + + + + + {generatedSummary} + + + + + setShowVaultConfirmModal(true)} + disabled={isSavingToVault} + > + + {isSavingToVault ? ( + + ) : ( + <> + + Save to Vault + + )} + + + + setShowSummaryResultModal(false)} + > + Done + + + + + + + + + {/* Save to Vault Confirmation Modal */} + setShowVaultConfirmModal(false)} + > + setShowVaultConfirmModal(false)}> + + e.stopPropagation()}> + + + Save to Vault + + Would you like to securely save this summary to your digital vault? + + + + setShowVaultConfirmModal(false)} + > + Cancel + + + + Yes, Save + + + + + + + + + + {/* Save Result Modal */} + + + + e.stopPropagation()}> + + + + + + + + + {saveResult.success ? 'Success!' : 'Oops!'} + + + + {saveResult.message} + + + + + Confirm + + + + + + + + + {/* Summary Loading Modal */} + + + + + Generating Summary... + + + ); } @@ -1281,4 +1617,101 @@ const styles = StyleSheet.create({ color: colors.flow.textSecondary, fontWeight: '600', }, + + // Summary Modal styles + modalSubtitle: { + fontSize: typography.fontSize.base, + color: colors.flow.textSecondary, + lineHeight: 22, + }, + modalActions: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.base, + }, + actionButton: { + flex: 1, + height: 50, + borderRadius: borderRadius.lg, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + actionButtonGradient: { + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + cancelButton: { + backgroundColor: colors.nautical.paleAqua, + }, + confirmButton: { + // Gradient handled in child + }, + cancelButtonText: { + fontSize: typography.fontSize.base, + fontWeight: '600', + color: colors.flow.textSecondary, + }, + confirmButtonText: { + fontSize: typography.fontSize.base, + fontWeight: '600', + color: '#fff', + }, + summaryContainer: { + marginVertical: spacing.md, + }, + summaryCard: { + backgroundColor: colors.nautical.paleAqua + '40', // 25% opacity + padding: spacing.md, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.nautical.lightMint, + }, + summaryText: { + fontSize: typography.fontSize.base, + color: colors.flow.text, + lineHeight: 24, + }, + summaryActions: { + marginTop: spacing.md, + gap: spacing.sm, + }, + saveToVaultButton: { + height: 54, + }, + resultIconContainer: { + width: 80, + height: 80, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.md, + }, + successIconBg: { + backgroundColor: colors.nautical.paleAqua, + }, + errorIconBg: { + backgroundColor: 'rgba(231, 76, 60, 0.1)', // coral at 10% + }, + loadingOverlay: { + flex: 1, + backgroundColor: 'rgba(26, 58, 74, 0.6)', + justifyContent: 'center', + alignItems: 'center', + }, + loadingContainer: { + backgroundColor: colors.flow.cardBackground, + padding: spacing.xl, + borderRadius: borderRadius.xl, + alignItems: 'center', + ...shadows.soft, + gap: spacing.md, + }, + loadingText: { + fontSize: typography.fontSize.base, + color: colors.flow.text, + fontWeight: '600', + }, }); diff --git a/src/screens/VaultScreen.tsx b/src/screens/VaultScreen.tsx index 56d59c4..339619a 100644 --- a/src/screens/VaultScreen.tsx +++ b/src/screens/VaultScreen.tsx @@ -27,6 +27,7 @@ import { useAuth } from '../context/AuthContext'; import { useVaultAssets } from '../hooks/useVaultAssets'; import { getVaultStorageKeys } from '../config'; import { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss'; +import { SentinelVault } from '@/utils/crypto_core'; // Asset type configuration with nautical theme const assetTypeConfig: Record = { @@ -210,7 +211,7 @@ export default function VaultScreen() { return; } if (isUnlocked) return; - const timer = setTimeout(() => setShowBiometric(true), 500); + const timer = setTimeout(() => setShowBiometric(true), 100); return () => clearTimeout(timer); }, [isUnlocked, hasS0]); @@ -218,7 +219,7 @@ export default function VaultScreen() { if (isUnlocked) { Animated.timing(fadeAnim, { toValue: 1, - duration: 600, + duration: 200, useNativeDriver: true, }).start(); } @@ -230,12 +231,12 @@ export default function VaultScreen() { Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.05, - duration: 1500, + duration: 500, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, - duration: 1500, + duration: 500, useNativeDriver: true, }), ]) @@ -380,9 +381,19 @@ export default function VaultScreen() { const entropy = mnemonicToEntropy(mnemonicWords, wordList); const shares = splitSecret(entropy); const s0 = shares[0]; // device share (S0) + const s1 = shares[1]; // server share (S1) + const s2 = shares[2]; // heir share (S2) // S0 is stored in AsyncStorage under user-scoped key — app-level storage, not hardware TEE/SE + const vault = new SentinelVault() + + const aes_key = await vault.deriveKey(mnemonicWords.join(' ')) + await AsyncStorage.setItem(vaultKeys.SHARE_DEVICE, serializeShare(s0)); + await AsyncStorage.setItem(vaultKeys.SHARE_SERVER, serializeShare(s1)); await AsyncStorage.setItem(vaultKeys.INITIALIZED, '1'); + await AsyncStorage.setItem(vaultKeys.AES_KEY, aes_key.toString('hex')); + await AsyncStorage.setItem(vaultKeys.SHARE_HEIR, serializeShare(s2)); + setHasS0(true); setShowMnemonic(false); setShowBiometric(true); @@ -576,327 +587,327 @@ export default function VaultScreen() { behavior={Platform.OS === 'ios' ? 'padding' : undefined} > - - setShowMnemonic(false)} - activeOpacity={0.85} + - - - - - Mnemonic Setup - - {mnemonicStep === 1 ? ( - <> - - Review your 12-word mnemonic. Tap any word to replace it. - - - {mnemonicWords.map((word, index) => ( - setReplaceIndex(index)} - activeOpacity={0.8} - > - setShowMnemonic(false)} + activeOpacity={0.85} + > + + + + + Mnemonic Setup + + {mnemonicStep === 1 ? ( + <> + + Review your 12-word mnemonic. Tap any word to replace it. + + + {mnemonicWords.map((word, index) => ( + setReplaceIndex(index)} + activeOpacity={0.8} > - {index + 1} + + {index + 1} + + + {word} + + + ))} + + {replaceIndex !== null ? ( + + + Replace word {replaceIndex + 1} - - {word} - - - ))} - - {replaceIndex !== null ? ( - - - Replace word {replaceIndex + 1} - - - - {(replaceQuery - ? bip39.wordlists.english.filter((word) => + + + {(replaceQuery + ? bip39.wordlists.english.filter((word) => word.startsWith(replaceQuery.toLowerCase()) ) - : bip39.wordlists.english - ) - .slice(0, 24) - .map((word) => ( - handleReplaceWord(word)} - activeOpacity={0.8} - > - {word} - - ))} - - setReplaceIndex(null)} - activeOpacity={0.85} - > - CANCEL - - - ) : null} - setMnemonicStep(2)} - activeOpacity={0.85} - > - NEXT - - - ) : null} + : bip39.wordlists.english + ) + .slice(0, 24) + .map((word) => ( + handleReplaceWord(word)} + activeOpacity={0.8} + > + {word} + + ))} + + setReplaceIndex(null)} + activeOpacity={0.85} + > + CANCEL + + + ) : null} + setMnemonicStep(2)} + activeOpacity={0.85} + > + NEXT + + + ) : null} - {mnemonicStep === 2 ? ( - <> - - Confirm your 12-word mnemonic. - - - - {mnemonicWords.join(' ')} + {mnemonicStep === 2 ? ( + <> + + Confirm your 12-word mnemonic. - - setMnemonicStep(3)} - activeOpacity={0.85} - > - CONFIRM - - setMnemonicStep(1)} - activeOpacity={0.85} - > - EDIT SELECTION - - - ) : null} - - {mnemonicStep === 3 ? ( - <> - - Back up your mnemonic before entering the Vault. - - - - {mnemonicWords.join(' ')} - - - - - {isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'} - - - - EMAIL BACKUP - - - ) : null} - - {mnemonicStep === 4 ? ( - <> - - Finalizing your vault protection. - - - - - - - - - {progressIndex === 0 && '1. Your key is being processed'} - {progressIndex === 1 && '2. Your key has been split'} - {progressIndex === 2 && '3. Part one stored on this device'} - {progressIndex === 3 && '4. Part two uploaded to the cloud'} - {progressIndex >= 4 && '5. Part three inquiry initiated'} - - - - ) : null} - - {mnemonicStep === 5 ? ( - <> - {heirStep === 'decision' ? ( - <> - - Share Part Three with your legacy handler? + + + {mnemonicWords.join(' ')} - - YES, SEND - - - NOT NOW - - - ) : null} + + setMnemonicStep(3)} + activeOpacity={0.85} + > + CONFIRM + + setMnemonicStep(1)} + activeOpacity={0.85} + > + EDIT SELECTION + + + ) : null} - {heirStep === 'asset' ? ( - <> - - Select the vault item to assign. + {mnemonicStep === 3 ? ( + <> + + Back up your mnemonic before entering the Vault. + + + + {mnemonicWords.join(' ')} - - {assets.map((asset) => { - const config = assetTypeConfig[asset.type]; - return ( + + + + {isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'} + + + + EMAIL BACKUP + + + ) : null} + + {mnemonicStep === 4 ? ( + <> + + Finalizing your vault protection. + + + + + + + + + {progressIndex === 0 && '1. Your key is being processed'} + {progressIndex === 1 && '2. Your key has been split'} + {progressIndex === 2 && '3. Part one stored on this device'} + {progressIndex === 3 && '4. Part two uploaded to the cloud'} + {progressIndex >= 4 && '5. Part three inquiry initiated'} + + + + ) : null} + + {mnemonicStep === 5 ? ( + <> + {heirStep === 'decision' ? ( + <> + + Share Part Three with your legacy handler? + + + YES, SEND + + + NOT NOW + + + ) : null} + + {heirStep === 'asset' ? ( + <> + + Select the vault item to assign. + + + {assets.map((asset) => { + const config = assetTypeConfig[asset.type]; + return ( + handleSelectHeirAsset(asset)} + activeOpacity={0.8} + > + + {renderAssetTypeIcon(config, 18, colors.vault.primary)} + + + {asset.label} + {config.label} + + + ); + })} + + setHeirStep('decision')} + activeOpacity={0.85} + > + BACK + + + ) : null} + + {heirStep === 'heir' ? ( + <> + + Choose a legacy handler. + + + {initialHeirs.map((heir) => ( handleSelectHeirAsset(asset)} + onPress={() => handleSelectHeir(heir)} activeOpacity={0.8} > - {renderAssetTypeIcon(config, 18, colors.vault.primary)} + - {asset.label} - {config.label} + {heir.name} + {heir.email} - ); - })} - - setHeirStep('decision')} - activeOpacity={0.85} - > - BACK - - - ) : null} + ))} + + setHeirStep('asset')} + activeOpacity={0.85} + > + BACK + + + ) : null} - {heirStep === 'heir' ? ( - <> - - Choose a legacy handler. - - - {initialHeirs.map((heir) => ( - handleSelectHeir(heir)} - activeOpacity={0.8} - > - - - - - {heir.name} - {heir.email} - - - ))} - - setHeirStep('asset')} - activeOpacity={0.85} - > - BACK - - - ) : null} - - {heirStep === 'summary' ? ( - <> - - Confirm assignment details. - - - Vault Item - {selectedHeirAsset?.label} - Legacy Handler - {selectedHeir?.name} - {selectedHeir?.email} - Release Tier - Tier {selectedHeir?.releaseLevel} - - - SUBMIT - - setHeirStep('heir')} - activeOpacity={0.85} - > - EDIT - - - ) : null} - - ) : null} - - - - - + {heirStep === 'summary' ? ( + <> + + Confirm assignment details. + + + Vault Item + {selectedHeirAsset?.label} + Legacy Handler + {selectedHeir?.name} + {selectedHeir?.email} + Release Tier + Tier {selectedHeir?.releaseLevel} + + + SUBMIT + + setHeirStep('heir')} + activeOpacity={0.85} + > + EDIT + + + ) : null} + + ) : null} + + + + + @@ -904,60 +915,60 @@ export default function VaultScreen() { // Lock screen const lockScreen = ( - - - - - - - - - - - THE DEEP VAULT - Where treasures rest in silence - - - - - - setShowBiometric(true) : handleUnlock} - activeOpacity={0.8} + + + + + + - - - - {hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'} - - - - - - + + + - setShowBiometric(false)} - title="Enter the Vault" - message="Verify your identity to access your treasures" - isDark - /> - - ); + THE DEEP VAULT + Where treasures rest in silence + + + + + + setShowBiometric(true) : handleUnlock} + activeOpacity={0.8} + > + + + + {hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'} + + + + + + + + setShowBiometric(false)} + title="Enter the Vault" + message="Verify your identity to access your treasures" + isDark + /> + + ); const vaultScreen = ( @@ -1003,7 +1014,7 @@ export default function VaultScreen() { )} {/* Asset List */} - TREASURE TYPE - { + if (NO_BACKEND_MODE) { + logApiDebug('AI Summary', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve('This is a mock summary of your conversation. You discussed various topics including AI integration and UI design. The main conclusion was to proceed with the proposed implementation plan.'); + }, AI_CONFIG.MOCK_RESPONSE_DELAY); + }); + } + + const historicalMessages = messages.map(msg => ({ + role: msg.role, + content: msg.content, + })); + + const summaryPrompt: AIMessage = { + role: 'user', + content: 'Please provide a concise summary of the conversation above in Chinese (since the user request was in Chinese). Focus on the main topics discussed and any key conclusions or actions mentioned.', + }; + + const response = await this.chat([...historicalMessages, summaryPrompt], token); + return response.choices[0]?.message?.content || 'No summary generated'; + }, }; diff --git a/src/utils/crypto_core.ts b/src/utils/crypto_core.ts new file mode 100644 index 0000000..9a47b9c --- /dev/null +++ b/src/utils/crypto_core.ts @@ -0,0 +1,202 @@ +import * as bip39 from 'bip39'; +import * as crypto from 'crypto'; + +// 定义分片类型:[x坐标, y坐标] +export type Share = [bigint, bigint]; + +// 定义生成密钥的返回接口 +export interface VaultKeys { + mnemonic: string; + entropyHex: string; +} + +export class SentinelKeyEngine { + // 使用第 13 个梅森素数 (2^521 - 1) + // readonly 确保不会被修改 + private readonly PRIME: bigint = 2n ** 521n - 1n; + + /** + * 1. 生成原始 12 助记词 (Master Key) + */ + public generateVaultKeys(): VaultKeys { + // 生成 128 位强度的助记词 (12 个单词) + const mnemonic = bip39.generateMnemonic(128); + + // 将助记词转为 16 进制熵 (Hex String) + const entropyHex = bip39.mnemonicToEntropy(mnemonic); + + return { mnemonic, entropyHex }; + } + + public mnemonicToEntropy(mnemonic: string): string { + return bip39.mnemonicToEntropy(mnemonic); + } + + /** + * 2. SSS (3,2) 门限分片逻辑 + * @param entropyHex - 16进制字符串 (32字符) + */ + public splitToShares(entropyHex: string): Share[] { + // 将 Hex 熵转换为 BigInt + const secretInt = BigInt('0x' + entropyHex); + + // 生成随机系数 a,范围 [0, PRIME-1] + const a = this.secureRandomBigInt(this.PRIME); + + // 定义函数 f(x) = (S + a * x) % PRIME + const f = (x: number): bigint => { + const xBi = BigInt(x); + return (secretInt + a * xBi) % this.PRIME; + }; + + // 生成 3 个分片: x=1, x=2, x=3 + const share1: Share = [1n, f(1)]; // 手机分片 + const share2: Share = [2n, f(2)]; // 云端分片 + const share3: Share = [3n, f(3)]; // 传承卡分片 + + return [share1, share2, share3]; + } + + /** + * 3. 恢复逻辑:拉格朗日插值还原 + * @param shareA - 第一个分片 + * @param shareB - 第二个分片 + */ + public recoverFromShares(shareA: Share, shareB: Share): string { + const [x1, y1] = shareA; + const [x2, y2] = shareB; + + // 计算分子: (x2 * y1 - x1 * y2) % PRIME + // TS/JS 的 % 运算符对负数返回负数,需修正为正余数 + let numerator = (x2 * y1 - x1 * y2) % this.PRIME; + if (numerator < 0n) numerator += this.PRIME; + + // 计算分母: (x2 - x1) + let denominator = (x2 - x1) % this.PRIME; + if (denominator < 0n) denominator += this.PRIME; + + // 计算分母的模逆: denominator^-1 mod PRIME + // 费马小定理: a^(p-2) = a^-1 (mod p) + const invDenominator = this.modPow(denominator, this.PRIME - 2n, this.PRIME); + + // 还原常数项 S + const secretInt = (numerator * invDenominator) % this.PRIME; + + // 转回 Hex 字符串 + let recoveredEntropyHex = secretInt.toString(16); + + // 补齐前导零 (Pad Start) + // 128 bit 熵 = 16 字节 = 32 个 Hex 字符 + // 如果你的熵是 256 bit,这里需要改为 64 + recoveredEntropyHex = recoveredEntropyHex.padStart(32, '0'); + + return bip39.entropyToMnemonic(recoveredEntropyHex); + } + + // --- Private Helper Methods --- + + /** + * 生成小于 limit 的安全随机 BigInt + */ + private secureRandomBigInt(limit: bigint): bigint { + // 计算需要的字节数 + const bitLength = limit.toString(2).length; + const byteLength = Math.ceil(bitLength / 8); + + let randomBi: bigint; + do { + const buf = crypto.randomBytes(byteLength); + randomBi = BigInt('0x' + buf.toString('hex')); + // 拒绝采样:确保结果小于 limit + } while (randomBi >= limit); + + return randomBi; + } + + /** + * 模幂运算: (base^exp) % modulus + * 用于计算模逆 + */ + private modPow(base: bigint, exp: bigint, modulus: bigint): bigint { + let result = 1n; + base = base % modulus; + while (exp > 0n) { + if (exp % 2n === 1n) result = (result * base) % modulus; + exp = exp >> 1n; // 相当于除以 2 + base = (base * base) % modulus; + } + return result; + } +} + +export class SentinelVault { + private salt: Buffer; + + constructor(salt?: string | Buffer) { + // 默认盐值与 Python 版本保持一致 + this.salt = salt ? Buffer.from(salt) : Buffer.from('Sentinel_Salt_2026'); + } + + /** + * 使用 PBKDF2 将助记词转换为 AES-256 密钥 (32 bytes) + */ + public async deriveKey(mnemonicPhrase: string): Promise { + // 1. BIP-39 助记词转种子 (遵循 BIP-39 标准) + // Python 的 to_seed 默认返回 64 字节种子 + const seed = await bip39.mnemonicToSeed(mnemonicPhrase); + + // 2. PBKDF2 派生密钥 + // 注意:PyCryptodome 的 PBKDF2 默认使用 HMAC-SHA1 (如未指定) + // 为了确保与 Python 逻辑严格一致,这里使用 'sha1' + return new Promise((resolve, reject) => { + crypto.pbkdf2(seed, this.salt, 100000, 32, 'sha1', (err, derivedKey) => { + if (err) reject(err); + resolve(derivedKey); + }); + }); + } + + /** + * 使用 AES-256-GCM 模式进行加密 + */ + public encryptData(key: Buffer, plaintext: string): Buffer { + // GCM 模式推荐 nonce 长度,Python 默认通常为 16 字节 + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + const ciphertext = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final() + ]); + + // 获取 GCM 认证标签 (16 bytes) + const tag = cipher.getAuthTag(); + + // 拼接结果:Nonce + Tag + Ciphertext + return Buffer.concat([iv, tag, ciphertext]); + } + + /** + * AES-256-GCM 解密 + */ + public decryptData(key: Buffer, encryptedBlob: Buffer): string { + try { + // 切片提取组件 + const iv = encryptedBlob.subarray(0, 16); + const tag = encryptedBlob.subarray(16, 32); + const ciphertext = encryptedBlob.subarray(32); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]); + + return decrypted.toString('utf8'); + } catch (error) { + return "【解密失败】:密钥错误或数据被篡改"; + } + } +} \ No newline at end of file diff --git a/src/utils/crypto_polyfill.ts b/src/utils/crypto_polyfill.ts new file mode 100644 index 0000000..816b749 --- /dev/null +++ b/src/utils/crypto_polyfill.ts @@ -0,0 +1,135 @@ +import * as ExpoCrypto from 'expo-crypto'; +import { Buffer } from 'buffer'; +import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2'; +import { sha1 } from '@noble/hashes/sha1'; +import { sha256 } from '@noble/hashes/sha256'; +import { sha512 } from '@noble/hashes/sha512'; +import { gcm } from '@noble/ciphers/aes'; + +/** + * Node.js Crypto Polyfill for React Native + */ + +export function randomBytes(size: number): Buffer { + const bytes = new Uint8Array(size); + ExpoCrypto.getRandomValues(bytes); + return Buffer.from(bytes); +} + +const hashMap: Record = { + sha1, + sha256, + sha512, +}; + +export function pbkdf2( + password: string | Buffer, + salt: string | Buffer, + iterations: number, + keylen: number, + digest: string, + callback: (err: Error | null, derivedKey: Buffer) => void +): void { + try { + const passwordBytes = typeof password === 'string' ? Buffer.from(password) : password; + const saltBytes = typeof salt === 'string' ? Buffer.from(salt) : salt; + const hasher = hashMap[digest.toLowerCase()]; + + if (!hasher) { + throw new Error(`Unsupported digest: ${digest}`); + } + + const result = noblePbkdf2(hasher, passwordBytes, saltBytes, { + c: iterations, + dkLen: keylen, + }); + + callback(null, Buffer.from(result)); + } catch (err) { + callback(err as Error, Buffer.alloc(0)); + } +} + +// AES-GCM Implementation +class Cipher { + private key: Uint8Array; + private iv: Uint8Array; + private authTag: Buffer | null = null; + private aesGcm: any; + private buffer: Buffer = Buffer.alloc(0); + + constructor(key: Buffer, iv: Buffer) { + this.key = new Uint8Array(key); + this.iv = new Uint8Array(iv); + // @noble/ciphers/aes gcm takes (key, nonce) + this.aesGcm = gcm(this.key, this.iv); + } + + update(data: string | Buffer, inputEncoding?: string): Buffer { + const input = typeof data === 'string' ? Buffer.from(data, inputEncoding as any) : data; + this.buffer = Buffer.concat([this.buffer, input]); + return Buffer.alloc(0); + } + + final(): Buffer { + const result = this.aesGcm.encrypt(this.buffer); + // @noble/ciphers returns ciphertext + tag (16 bytes) + const tag = result.slice(-16); + const ciphertext = result.slice(0, -16); + this.authTag = Buffer.from(tag); + return Buffer.from(ciphertext); + } + + getAuthTag(): Buffer { + if (!this.authTag) throw new Error('Ciphers: TAG not available before final()'); + return this.authTag; + } +} + +class Decipher { + private key: Uint8Array; + private iv: Uint8Array; + private tag: Uint8Array | null = null; + private aesGcm: any; + private buffer: Buffer = Buffer.alloc(0); + + constructor(key: Buffer, iv: Buffer) { + this.key = new Uint8Array(key); + this.iv = new Uint8Array(iv); + this.aesGcm = gcm(this.key, this.iv); + } + + setAuthTag(tag: Buffer): void { + this.tag = new Uint8Array(tag); + } + + update(data: Buffer): Buffer { + this.buffer = Buffer.concat([this.buffer, data]); + return Buffer.alloc(0); + } + + final(): Buffer { + if (!this.tag) throw new Error('Decipher: Auth tag not set'); + // @noble/ciphers expects ciphertext then tag + const full = new Uint8Array(this.buffer.length + this.tag.length); + full.set(this.buffer); + full.set(this.tag, this.buffer.length); + + const decrypted = this.aesGcm.decrypt(full); + return Buffer.from(decrypted); + } +} + +export function createCipheriv(algorithm: string, key: Buffer, iv: Buffer): Cipher { + if (algorithm !== 'aes-256-gcm') { + throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`); + } + return new Cipher(key, iv); +} + +export function createDecipheriv(algorithm: string, key: Buffer, iv: Buffer): Decipher { + if (algorithm !== 'aes-256-gcm') { + throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`); + } + return new Decipher(key, iv); +}