5 Commits

Author SHA1 Message Date
lusixing
0aab9a838b ai_role_update 2026-02-02 22:20:24 -08:00
lusixing
6822638d47 complete_heir_functions 2026-02-02 19:40:49 -08:00
lusixing
5c1172a912 added_reveal_secret_and_delete_treasure 2026-02-02 17:34:03 -08:00
lusixing
b5373c2d9a update_260201-3 2026-02-01 21:13:15 -08:00
Ada
3ffcc60ee8 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 15:22:32 -08:00
18 changed files with 2237 additions and 521 deletions

13
metro.config.js Normal file
View File

@@ -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;

82
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -62,20 +62,18 @@ export default function BiometricModal({
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);
});
};

View File

@@ -51,11 +51,13 @@ export const API_ENDPOINTS = {
CREATE: '/assets/create',
CLAIM: '/assets/claim',
ASSIGN: '/assets/assign',
DELETE: '/assets/delete',
},
// AI Services
AI: {
PROXY: '/ai/proxy',
GET_ROLES: '/get_ai_roles',
},
// Admin Operations
@@ -67,9 +69,9 @@ export const API_ENDPOINTS = {
// =============================================================================
// Vault storage (user-isolated, multi-account)
// =============================================================================
// - AsyncStorage keys for vault state (S0 share, initialized flag).
// - User-scoped: each account has its own keys so vault state is isolated.
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE.
// - AsyncStorage keys for vault state (S0 share, initialized flag, mnemonic part backup).
// - User-scoped: each account has its own keys so vault/mnemonic state is isolated.
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE / MNEMONIC_PART_LOCAL.
// - Clear: use same keys in multiRemove (e.g. MeScreen Reset Vault State).
// - Multi-account: same device, multiple users → each has independent vault (no cross-user leakage).
@@ -79,21 +81,30 @@ const VAULT_KEY_PREFIX = 'sentinel_vault';
export const VAULT_STORAGE_KEYS = {
INITIALIZED: 'sentinel_vault_initialized',
SHARE_DEVICE: 'sentinel_vault_s0',
MNEMONIC_PART_LOCAL: 'sentinel_mnemonic_part_local',
} as const;
/**
* Returns vault storage keys for the given user (user isolation).
* - Use for: reading S0, writing S0 after mnemonic, clearing on Reset Vault State.
* - Use for: reading/writing S0, mnemonic part backup, clearing on Reset Vault State.
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
*/
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}`,
};
}

View File

@@ -7,8 +7,9 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginRequest, RegisterRequest } from '../types';
import { User, LoginRequest, RegisterRequest, AIRole } from '../types';
import { authService } from '../services/auth.service';
import { aiService } from '../services/ai.service';
import { storageService } from '../services/storage.service';
// =============================================================================
@@ -18,11 +19,13 @@ import { storageService } from '../services/storage.service';
interface AuthContextType {
user: User | null;
token: string | null;
aiRoles: AIRole[];
isLoading: boolean;
isInitializing: boolean;
signIn: (credentials: LoginRequest) => Promise<void>;
signUp: (data: RegisterRequest) => Promise<void>;
signOut: () => void;
refreshAIRoles: () => Promise<void>;
}
// Storage keys
@@ -44,6 +47,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [aiRoles, setAIRoles] = useState<AIRole[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
@@ -66,6 +70,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
// Fetch AI roles after restoring session
fetchAIRoles(storedToken);
}
} catch (error) {
console.error('[Auth] Failed to load stored auth:', error);
@@ -74,6 +80,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
/**
* Fetch AI roles from API
*/
const fetchAIRoles = async (authToken: string) => {
console.log('[Auth] Fetching AI roles with token:', authToken ? `${authToken.substring(0, 10)}...` : 'MISSING');
try {
const roles = await aiService.getAIRoles(authToken);
setAIRoles(roles);
console.log('[Auth] AI roles fetched successfully:', roles.length);
} catch (error) {
console.error('[Auth] Failed to fetch AI roles:', error);
}
};
/**
* Manual refresh of AI roles
*/
const refreshAIRoles = async () => {
if (token) {
await fetchAIRoles(token);
}
};
/**
* Save authentication to AsyncStorage
*/
@@ -114,6 +143,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(response.access_token);
setUser(response.user);
await saveAuth(response.access_token, response.user);
// Fetch AI roles immediately after login
await fetchAIRoles(response.access_token);
} catch (error) {
throw error;
} finally {
@@ -143,7 +174,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = () => {
setUser(null);
setToken(null);
setAIRoles([]);
clearAuth();
//storageService.clearAllData();
};
@@ -152,11 +186,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
value={{
user,
token,
aiRoles,
isLoading,
isInitializing,
signIn,
signUp,
signOut
signOut,
refreshAIRoles
}}
>
{children}

View File

@@ -6,9 +6,12 @@
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 { storageService } from '../services/storage.service';
import {
initialVaultAssets,
mapApiAssetsToVaultAssets,
@@ -35,6 +38,10 @@ export interface UseVaultAssetsReturn {
refreshAssets: () => Promise<void>;
/** Create asset via POST /assets/create; on success refreshes list */
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
/** Delete asset via POST /assets/delete; on success refreshes list */
deleteAsset: (assetId: number) => Promise<CreateAssetResult>;
/** Assign asset to heir via POST /assets/assign */
assignAsset: (assetId: number, heirEmail: string) => Promise<CreateAssetResult>;
/** True while create request is in flight */
isSealing: boolean;
/** Error message from last create failure (non-401) */
@@ -51,7 +58,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<VaultAsset[]>(initialVaultAssets);
const [isSealing, setIsSealing] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
@@ -63,10 +70,14 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
if (Array.isArray(list)) {
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
}
} catch {
} catch (err: unknown) {
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
if (/Could not validate credentials/i.test(rawMessage)) {
signOut();
}
// Keep current assets (mock or previous fetch)
}
}, [token]);
}, [token, signOut]);
// Fetch list when unlocked and token exists
useEffect(() => {
@@ -79,7 +90,13 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
}
})
.catch(() => {
.catch((err) => {
if (!cancelled) {
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
if (/Could not validate credentials/i.test(rawMessage)) {
signOut();
}
}
// Keep initial (mock) assets
});
return () => {
@@ -101,22 +118,45 @@ 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
);
await assetsService.createAsset(
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);
}
const createdAsset = 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
);
// Backup plaintext content locally
if (createdAsset && createdAsset.id && user?.id) {
await storageService.saveAssetBackup(createdAsset.id, content, user.id);
}
await refreshAssets();
return { success: true };
} catch (err: unknown) {
@@ -143,9 +183,85 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setIsSealing(false);
}
},
[token, user, refreshAssets, signOut]
);
const deleteAsset = useCallback(
async (assetId: number): Promise<CreateAssetResult> => {
if (!token) {
return { success: false, error: 'Not logged in.' };
}
setIsSealing(true);
setCreateError(null);
try {
await assetsService.deleteAsset(assetId, token);
await refreshAssets();
return { success: true };
} catch (err: unknown) {
const status =
err && typeof err === 'object' && 'status' in err
? (err as { status?: number }).status
: undefined;
const rawMessage =
err instanceof Error ? err.message : String(err ?? 'Failed to delete.');
const isUnauthorized =
status === 401 || /401|Unauthorized/i.test(rawMessage);
if (isUnauthorized) {
signOut();
return { success: false, isUnauthorized: true };
}
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
? 'Network error. Please check that the backend is running and reachable.'
: rawMessage;
setCreateError(friendlyMessage);
return { success: false, error: friendlyMessage };
} finally {
setIsSealing(false);
}
},
[token, refreshAssets, signOut]
);
const assignAsset = useCallback(
async (assetId: number, heirEmail: string): Promise<CreateAssetResult> => {
if (!token) {
return { success: false, error: 'Not logged in.' };
}
setIsSealing(true);
setCreateError(null);
try {
await assetsService.assignAsset({ asset_id: assetId, heir_email: heirEmail }, token);
await refreshAssets();
return { success: true };
} catch (err: unknown) {
const status =
err && typeof err === 'object' && 'status' in err
? (err as { status?: number }).status
: undefined;
const rawMessage =
err instanceof Error ? err.message : String(err ?? 'Failed to assign.');
const isUnauthorized =
status === 401 || /401|Unauthorized/i.test(rawMessage);
if (isUnauthorized) {
signOut();
return { success: false, isUnauthorized: true };
}
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
? 'Network error. Please check that the backend is running and reachable.'
: rawMessage;
setCreateError(friendlyMessage);
return { success: false, error: friendlyMessage };
} finally {
setIsSealing(false);
}
},
[token, signOut]
);
const clearCreateError = useCallback(() => setCreateError(null), []);
return {
@@ -153,6 +269,8 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setAssets,
refreshAssets,
createAsset,
deleteAsset,
assignAsset,
isSealing,
createError,
clearCreateError,

View File

@@ -28,11 +28,16 @@ import {
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { AIRole } from '../types';
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
@@ -59,7 +64,7 @@ interface ChatSession {
// =============================================================================
export default function FlowScreen() {
const { token, user, signOut } = useAuth();
const { token, user, signOut, aiRoles, refreshAIRoles } = useAuth();
const scrollViewRef = useRef<ScrollView>(null);
// Current conversation state
@@ -69,8 +74,8 @@ export default function FlowScreen() {
const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// AI Role state
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]);
// AI Role state - start with null to detect first load
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
@@ -78,6 +83,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<ChatSession[]>([
// Sample history data
{
@@ -143,9 +160,9 @@ export default function FlowScreen() {
// Load messages whenever role changes
useEffect(() => {
const loadRoleMessages = async () => {
if (!user) return;
if (!user || !selectedRole) return;
try {
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id);
const savedMessages = await storageService.getCurrentChat(selectedRole?.id || '', user.id);
if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({
...msg,
@@ -156,18 +173,42 @@ export default function FlowScreen() {
setMessages([]);
}
} catch (error) {
console.error(`Failed to load messages for role ${selectedRole.id}:`, error);
if (selectedRole) {
console.error(`Failed to load messages for role ${selectedRole?.id}:`, error);
}
setMessages([]);
}
};
loadRoleMessages();
}, [selectedRole.id, user]);
}, [selectedRole?.id, user]);
// Ensure we have a valid selected role from the dynamic list
useEffect(() => {
if (aiRoles.length > 0) {
if (!selectedRole) {
// Initial load or first time roles become available
setSelectedRole(aiRoles[0]);
} else {
// If roles refreshed, make sure current selectedRole is still valid or updated
const updatedRole = aiRoles.find(r => r.id === selectedRole?.id);
if (updatedRole) {
setSelectedRole(updatedRole);
} else {
// Current role no longer exists in dynamic list, fallback to first
setSelectedRole(aiRoles[0]);
}
}
} else if (!selectedRole) {
// Fallback if no dynamic roles yet
setSelectedRole(AI_CONFIG.ROLES[0]);
}
}, [aiRoles]);
// Save current messages for the active role when they change
useEffect(() => {
if (user && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole.id, messages, user.id);
if (user && selectedRole && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole?.id || '', messages, user.id);
}
if (messages.length > 0) {
@@ -175,7 +216,7 @@ export default function FlowScreen() {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages, selectedRole.id, user]);
}, [messages, selectedRole?.id, user]);
// Save history when it changes
useEffect(() => {
@@ -213,7 +254,7 @@ export default function FlowScreen() {
* Handle sending a message to AI
*/
const handleSendMessage = async () => {
if (!newContent.trim() || isSending) return;
if (!newContent.trim() || isSending || !selectedRole) return;
// Check authentication
if (!token) {
@@ -240,7 +281,7 @@ export default function FlowScreen() {
try {
// Call AI proxy with selected role's system prompt
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt);
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole?.systemPrompt || '');
// Add AI response
const aiMsg: ChatMessage = {
@@ -408,8 +449,8 @@ export default function FlowScreen() {
// Clear current messages and storage for this role
setMessages([]);
if (user) {
storageService.saveCurrentChat(selectedRole.id, [], user.id);
if (user && selectedRole) {
storageService.saveCurrentChat(selectedRole?.id || '', [], user.id);
}
closeHistoryModal();
};
@@ -453,6 +494,112 @@ 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
const createdAsset = await assetsService.createAsset({
title: `Chat Summary - ${new Date().toLocaleDateString()}`,
private_key_shard: shareServer,
content_inner_encrypted: encryptedSummary,
}, token);
// Backup plaintext content locally
if (createdAsset && createdAsset.id && user?.id) {
await storageService.saveAssetBackup(createdAsset.id, generatedSummary, user.id);
}
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
// =============================================================================
@@ -525,9 +672,9 @@ export default function FlowScreen() {
<View style={styles.emptyIcon}>
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
</View>
<Text style={styles.emptyTitle}>Chatting with {selectedRole.name}</Text>
<Text style={styles.emptyTitle}>Chatting with {selectedRole?.name || 'AI'}</Text>
<Text style={styles.emptySubtitle}>
{selectedRole.description}
{selectedRole?.description || 'Loading AI Assistant...'}
</Text>
</View>
);
@@ -585,17 +732,32 @@ export default function FlowScreen() {
onPress={() => setShowRoleModal(true)}
activeOpacity={0.7}
>
{selectedRole && (
<Ionicons
name={selectedRole.icon as any}
name={(selectedRole?.icon || 'help-outline') as any}
size={16}
color={colors.nautical.teal}
/>
)}
<Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole.name}
{selectedRole?.name || 'Loading...'}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* Summary Button */}
<TouchableOpacity
style={[styles.historyButton, { marginRight: spacing.sm }]}
onPress={() => setShowSummaryConfirmModal(true)}
disabled={messages.length === 0 || isSummarizing}
>
<Ionicons
name="document-text-outline"
size={20}
color={messages.length === 0 || isSummarizing ? colors.flow.textSecondary : colors.flow.primary}
/>
</TouchableOpacity>
{/* History Button */}
<TouchableOpacity
style={styles.historyButton}
@@ -776,34 +938,34 @@ export default function FlowScreen() {
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{AI_CONFIG.ROLES.map((role) => (
{aiRoles.map((role) => (
<View key={role.id} style={styles.roleItemContainer}>
<View
style={[
styles.roleItem,
selectedRole.id === role.id && styles.roleItemActive
selectedRole?.id === role.id && styles.roleItemActive
]}
>
<TouchableOpacity
style={styles.roleSelectionArea}
onPress={() => {
setSelectedRole(role as any);
setSelectedRole(role);
setShowRoleModal(false);
}}
>
<View style={[
styles.roleItemIcon,
selectedRole.id === role.id && styles.roleItemIconActive
selectedRole?.id === role.id && styles.roleItemIconActive
]}>
<Ionicons
name={role.icon as any}
size={20}
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal}
color={selectedRole?.id === role.id ? '#fff' : colors.nautical.teal}
/>
</View>
<Text style={[
styles.roleItemName,
selectedRole.id === role.id && styles.roleItemNameActive
selectedRole?.id === role.id && styles.roleItemNameActive
]}>
{role.name}
</Text>
@@ -843,6 +1005,212 @@ export default function FlowScreen() {
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Confirmation Modal */}
<Modal
visible={showSummaryConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowSummaryConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Generate Summary</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to generate a summary for the current conversation?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowSummaryConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>No</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleGenerateSummary}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Generate</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Result Modal */}
<Modal
visible={showSummaryResultModal}
transparent
animationType="slide"
onRequestClose={() => setShowSummaryResultModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryResultModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { maxHeight: '70%' }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Conversation Summary</Text>
<TouchableOpacity onPress={() => setShowSummaryResultModal(false)}>
<Ionicons name="close" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.summaryContainer} showsVerticalScrollIndicator={false}>
<View style={styles.summaryCard}>
<Text style={styles.summaryText}>{generatedSummary}</Text>
</View>
</ScrollView>
<View style={styles.summaryActions}>
<TouchableOpacity
style={[styles.actionButton, styles.saveToVaultButton]}
onPress={() => setShowVaultConfirmModal(true)}
disabled={isSavingToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
{isSavingToVault ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="shield-checkmark-outline" size={20} color="#fff" />
<Text style={styles.confirmButtonText}>Save to Vault</Text>
</>
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowSummaryResultModal(false)}
>
<Text style={styles.closeButtonText}>Done</Text>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save to Vault Confirmation Modal */}
<Modal
visible={showVaultConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowVaultConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowVaultConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Save to Vault</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to securely save this summary to your digital vault?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowVaultConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleSaveToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Save</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save Result Modal */}
<Modal
visible={showSaveResultModal}
transparent
animationType="fade"
onRequestClose={handleFinishSaveFlow}
>
<TouchableWithoutFeedback onPress={handleFinishSaveFlow}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl, alignItems: 'center' }]}>
<View style={styles.modalHandle} />
<View style={[
styles.resultIconContainer,
saveResult.success ? styles.successIconBg : styles.errorIconBg
]}>
<Ionicons
name={saveResult.success ? "checkmark-circle" : "alert-circle"}
size={64}
color={saveResult.success ? colors.nautical.teal : colors.nautical.coral}
/>
</View>
<Text style={styles.modalTitle}>
{saveResult.success ? 'Success!' : 'Oops!'}
</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base, textAlign: 'center' }]}>
{saveResult.message}
</Text>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton, { width: '100%' }]}
onPress={handleFinishSaveFlow}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Confirm</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Loading Modal */}
<Modal
visible={isSummarizing}
transparent
animationType="fade"
>
<View style={styles.loadingOverlay}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.nautical.teal} />
<Text style={styles.loadingText}>Generating Summary...</Text>
</View>
</View>
</Modal>
</View>
);
}
@@ -1281,4 +1649,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',
},
});

View File

@@ -315,6 +315,7 @@ export default function MeScreen() {
await AsyncStorage.multiRemove([
vaultKeys.INITIALIZED,
vaultKeys.SHARE_DEVICE,
vaultKeys.MNEMONIC_PART_LOCAL,
]);
setResetVaultFeedback({
status: 'success',

View File

@@ -25,8 +25,10 @@ import { VaultAsset, VaultAssetType, Heir } from '../types';
import BiometricModal from '../components/common/BiometricModal';
import { useAuth } from '../context/AuthContext';
import { useVaultAssets } from '../hooks/useVaultAssets';
import { getVaultStorageKeys } from '../config';
import { getVaultStorageKeys, DEBUG_MODE } from '../config';
import { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss';
import { storageService } from '../services/storage.service';
import { SentinelVault } from '@/utils/crypto_core';
// Asset type configuration with nautical theme
const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = {
@@ -91,41 +93,6 @@ type HeirAssignment = {
heir: Heir;
};
// Mock data
// const initialAssets: VaultAsset[] = [
// {
// id: '1',
// type: 'private_key',
// label: 'ETH Main Wallet Key',
// createdAt: new Date('2024-01-10'),
// updatedAt: new Date('2024-01-10'),
// isEncrypted: true,
// },
// {
// id: '2',
// type: 'game_account',
// label: 'Steam Account Credentials',
// createdAt: new Date('2024-01-08'),
// updatedAt: new Date('2024-01-08'),
// isEncrypted: true,
// },
// {
// id: '3',
// type: 'document',
// label: 'Insurance Policy Scan',
// createdAt: new Date('2024-01-05'),
// updatedAt: new Date('2024-01-05'),
// isEncrypted: true,
// },
// {
// id: '4',
// type: 'will',
// label: 'Testament Draft v2',
// createdAt: new Date('2024-01-02'),
// updatedAt: new Date('2024-01-15'),
// isEncrypted: true,
// },
// ];
const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], size: number, color: string) => {
switch (config.iconType) {
@@ -148,11 +115,15 @@ export default function VaultScreen() {
setAssets,
refreshAssets,
createAsset: createVaultAsset,
deleteAsset: deleteVaultAsset,
assignAsset: assignVaultAsset,
isSealing,
createError: addError,
clearCreateError: clearAddError,
} = useVaultAssets(isUnlocked);
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedType, setSelectedType] = useState<VaultAssetType>('custom');
const [newLabel, setNewLabel] = useState('');
const [showUploadSuccess, setShowUploadSuccess] = useState(false);
@@ -169,8 +140,15 @@ export default function VaultScreen() {
const [showAddBiometric, setShowAddBiometric] = useState(false);
const [accountProvider, setAccountProvider] = useState<'bank' | 'steam' | 'facebook' | 'custom'>('bank');
const [showMnemonic, setShowMnemonic] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [showAssignErrorModal, setShowAssignErrorModal] = useState(false);
const [assignErrorMessage, setAssignErrorMessage] = useState('');
const [isAssigning, setIsAssigning] = useState(false);
const [heirEmail, setHeirEmail] = useState('');
const [showLegacyAssignCta, setShowLegacyAssignCta] = useState(false);
const [hasS0, setHasS0] = useState<boolean | null>(null);
const [backupContent, setBackupContent] = useState<string | null>(null);
const [isFetchingBackup, setIsFetchingBackup] = useState(false);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]);
const [mnemonicStep, setMnemonicStep] = useState<1 | 2 | 3 | 4 | 5>(1);
@@ -210,7 +188,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 +196,7 @@ export default function VaultScreen() {
if (isUnlocked) {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
duration: 200,
useNativeDriver: true,
}).start();
}
@@ -230,12 +208,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,
}),
])
@@ -258,7 +236,7 @@ export default function VaultScreen() {
setProgressIndex(0);
progressAnim.setValue(0);
setTimeout(() => setShowMnemonic(true), 200);
AsyncStorage.setItem('sentinel_mnemonic_part_local', parts[0].join(' ')).catch(() => {
AsyncStorage.setItem(vaultKeys.MNEMONIC_PART_LOCAL, parts[0].join(' ')).catch(() => {
// Best-effort local store; UI remains available
});
};
@@ -380,9 +358,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);
@@ -486,6 +474,10 @@ export default function VaultScreen() {
setSelectedAsset(asset);
setShowDetail(true);
setShowKeyPreview(false);
if (DEBUG_MODE) {
console.log('[DEBUG] Vault Asset Details:', JSON.stringify(asset.rawData, null, 2));
}
};
const handleCloseDetail = () => {
@@ -493,6 +485,93 @@ export default function VaultScreen() {
setSelectedAsset(null);
setShowKeyPreview(false);
setShowGuardedBiometric(false);
setBackupContent(null);
};
const handleFetchBackup = async () => {
if (!selectedAsset || !user?.id) return;
setIsFetchingBackup(true);
try {
const content = await storageService.getAssetBackup(Number(selectedAsset.id), user.id);
if (content) {
setBackupContent(content);
} else {
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('No Backup Found', 'No local plaintext backup found for this treasure.');
}
}
} catch (error) {
console.error('Fetch backup error:', error);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Error', 'Failed to retrieve local backup.');
}
} finally {
setIsFetchingBackup(false);
}
};
const handleDeleteAsset = async () => {
if (!selectedAsset || isDeleting) return;
setIsDeleting(true);
try {
const result = await deleteVaultAsset(Number(selectedAsset.id));
if (result.success) {
setShowDeleteConfirm(false);
handleCloseDetail();
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Success', 'Treasure removed from the vault.');
}
} else if (result.isUnauthorized) {
setShowDeleteConfirm(false);
handleCloseDetail();
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Unauthorized', 'Your session has expired. Please sign in again.');
}
} else if (result.error && typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Failed', result.error);
}
} catch (error) {
console.error('Delete error:', error);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Error', 'An unexpected error occurred during deletion.');
}
} finally {
setIsDeleting(false);
}
};
const handleAssignHeir = async () => {
if (!selectedAsset || isAssigning) return;
if (!heirEmail.trim() || !heirEmail.includes('@')) {
Alert.alert('Invalid Email', 'Please enter a valid email address.');
return;
}
setIsAssigning(true);
try {
const result = await assignVaultAsset(Number(selectedAsset.id), heirEmail.trim());
if (result.success) {
setShowAssignModal(false);
setHeirEmail('');
Alert.alert('Success', `Asset assigned to ${heirEmail.trim()}`);
} else if (result.isUnauthorized) {
setShowAssignModal(false);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Unauthorized', 'Your session has expired. Please sign in again.');
}
} else if (result.error) {
setAssignErrorMessage(result.error);
setShowAssignErrorModal(true);
}
} catch (error) {
console.error('Assign error:', error);
setAssignErrorMessage('An unexpected error occurred during assignment.');
setShowAssignErrorModal(true);
} finally {
setIsAssigning(false);
}
};
const handleGuardedAccess = () => {
@@ -502,6 +581,7 @@ export default function VaultScreen() {
const handleGuardedSuccess = () => {
setShowGuardedBiometric(false);
setShowKeyPreview(true);
handleFetchBackup();
};
const handleAddVerification = () => {
@@ -1437,6 +1517,12 @@ export default function VaultScreen() {
<Text style={styles.metaLabel}>{detailMetaLabel}</Text>
<Text style={styles.metaValue}>{detailMetaValue}</Text>
</View>
{selectedAsset?.heirEmail ? (
<View style={[styles.metaCard, { width: '100%' }]}>
<Text style={styles.metaLabel}>ASSIGNED HEIR</Text>
<Text style={styles.metaValue}>{selectedAsset.heirEmail}</Text>
</View>
) : null}
</View>
<View style={styles.actionGroup}>
@@ -1449,10 +1535,30 @@ export default function VaultScreen() {
<MaterialCommunityIcons name="file-lock" size={18} color={colors.vault.primary} />
<Text style={styles.actionText}>Export Cipher Pack</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionRow} activeOpacity={0.8}>
<MaterialCommunityIcons name="refresh" size={18} color={colors.vault.primary} />
<TouchableOpacity style={styles.actionRow} activeOpacity={0.7}>
<Ionicons name="notifications-outline" size={18} color={colors.vault.text} />
<Text style={styles.actionText}>Reset Sentinel Timer</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionRow, styles.assignActionRow]}
onPress={() => setShowAssignModal(true)}
activeOpacity={0.7}
>
<Ionicons name="person-add-outline" size={18} color={colors.vault.text} />
<Text style={styles.actionText}>
{selectedAsset?.heirEmail ? 'Change Heir' : 'Assign Heir'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionRow, styles.deleteActionRow]}
onPress={() => setShowDeleteConfirm(true)}
activeOpacity={0.7}
>
<Feather name="trash-2" size={18} color={colors.vault.warning} />
<Text style={[styles.actionText, styles.deleteActionText]}>Delete Treasure</Text>
</TouchableOpacity>
</View>
<View style={styles.guardCard}>
@@ -1463,6 +1569,7 @@ export default function VaultScreen() {
<Text style={styles.guardText}>
Plaintext access requires biometric verification and a memory rehearsal step.
</Text>
<TouchableOpacity
style={styles.guardButton}
onPress={handleGuardedAccess}
@@ -1470,10 +1577,13 @@ export default function VaultScreen() {
>
<Text style={styles.guardButtonText}>Begin Verification</Text>
</TouchableOpacity>
{showKeyPreview && (
<View style={styles.previewCard}>
<Text style={styles.previewLabel}>MNEMONIC SHARD (MASKED)</Text>
<Text style={styles.previewValue}>ocean-anchored-ember-veil</Text>
<Text style={styles.previewLabel}>LOCAL PLAINTEXT BACKUP</Text>
<Text style={styles.previewValue}>
{isFetchingBackup ? 'Fetching content...' : (backupContent || 'No local backup found for this treasure')}
</Text>
</View>
)}
</View>
@@ -1498,6 +1608,155 @@ export default function VaultScreen() {
isDark
/>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
visible={showDeleteConfirm}
animationType="fade"
transparent
onRequestClose={() => setShowDeleteConfirm(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowDeleteConfirm(false)}
/>
<View style={styles.deleteConfirmContent}>
<View style={styles.deleteIconContainer}>
<Feather name="alert-triangle" size={32} color={colors.vault.warning} />
</View>
<Text style={styles.deleteTitle}>Remove Treasure?</Text>
<Text style={styles.deleteMessage}>
This action cannot be undone. The treasure will be permanently shredded from the deep vault.
</Text>
<View style={styles.deleteButtons}>
<TouchableOpacity
style={styles.deleteCancelButton}
onPress={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
>
<Text style={styles.deleteCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteConfirmButton}
onPress={handleDeleteAsset}
disabled={isDeleting}
>
{isDeleting ? (
<Text style={styles.deleteConfirmText}>Shredding...</Text>
) : (
<Text style={styles.deleteConfirmText}>Confirm Delete</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Assign Heir Modal */}
<Modal
visible={showAssignModal}
animationType="slide"
transparent
onRequestClose={() => setShowAssignModal(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowAssignModal(false)}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.assignModalContainer}
>
<View style={styles.assignModalContent}>
<View style={styles.assignModalHeader}>
<View style={styles.assignIconGlow}>
<Ionicons name="person-add" size={32} color={colors.vault.primary} />
</View>
<Text style={styles.assignTitle}>Designate Heir</Text>
<Text style={styles.assignSubtitle}>
Enter the email address of the person who should inherit this treasure.
</Text>
</View>
<View style={styles.assignInputContainer}>
<Text style={styles.inputLabel}>HEIR EMAIL ADDRESS</Text>
<TextInput
style={styles.assignInput}
value={heirEmail}
onChangeText={setHeirEmail}
placeholder="name@example.com"
placeholderTextColor={colors.vault.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.assignButtons}>
<TouchableOpacity
style={styles.assignCancelButton}
onPress={() => {
setShowAssignModal(false);
setHeirEmail('');
}}
disabled={isAssigning}
>
<Text style={styles.assignCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.assignConfirmButton}
onPress={handleAssignHeir}
disabled={isAssigning || !heirEmail.trim()}
>
<LinearGradient
colors={[colors.vault.primary, colors.vault.secondary]}
style={styles.assignConfirmGradient}
>
{isAssigning ? (
<Text style={styles.assignConfirmText}>Assigning...</Text>
) : (
<Text style={styles.assignConfirmText}>Confirm Heir</Text>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
{/* Assign Heir Error Modal */}
<Modal
visible={showAssignErrorModal}
animationType="fade"
transparent
onRequestClose={() => setShowAssignErrorModal(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowAssignErrorModal(false)}
/>
<View style={styles.errorModalContent}>
<View style={styles.errorIconContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={32} color={colors.vault.warning} />
</View>
<Text style={styles.errorTitle}>Assignment Failed</Text>
<Text style={styles.errorMessage}>{assignErrorMessage}</Text>
<TouchableOpacity
style={styles.errorCloseButton}
onPress={() => setShowAssignErrorModal(false)}
>
<Text style={styles.errorCloseButtonText}>Dismiss</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
@@ -1766,6 +2025,73 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(26, 58, 74, 0.8)',
justifyContent: 'flex-end',
},
modalOverlayDismiss: {
...StyleSheet.absoluteFillObject,
},
deleteConfirmContent: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
padding: spacing.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
alignItems: 'center',
marginBottom: spacing.xxl + spacing.lg,
},
deleteIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.warning}20`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
deleteTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.sm,
fontFamily: typography.fontFamily.serif,
},
deleteMessage: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
marginBottom: spacing.xl,
},
deleteButtons: {
flexDirection: 'row',
gap: spacing.md,
width: '100%',
},
deleteCancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
deleteCancelText: {
color: colors.vault.textSecondary,
fontSize: typography.fontSize.base,
fontWeight: '600',
},
deleteConfirmButton: {
flex: 2,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.vault.warning,
alignItems: 'center',
},
deleteConfirmText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
modalContent: {
backgroundColor: colors.nautical.cream,
borderTopLeftRadius: borderRadius.xxl,
@@ -2570,8 +2896,204 @@ const styles = StyleSheet.create({
borderColor: colors.sentinel.cardBorder,
},
mnemonicSecondaryText: {
color: colors.sentinel.text,
fontWeight: '700',
letterSpacing: typography.letterSpacing.wide,
},
deleteActionRow: {
backgroundColor: 'rgba(229, 115, 115, 0.08)',
borderColor: 'rgba(229, 115, 115, 0.2)',
marginTop: spacing.sm,
},
deleteActionText: {
color: colors.vault.warning,
},
confirmModalOverlay: {
flex: 1,
backgroundColor: 'rgba(11, 20, 24, 0.85)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
confirmModalContent: {
width: '100%',
backgroundColor: colors.vault.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
gap: spacing.md,
},
confirmModalTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
textAlign: 'center',
},
confirmModalText: {
fontSize: typography.fontSize.base,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: 22,
},
confirmModalButtons: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.sm,
},
confirmModalButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
},
confirmCancelButton: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
confirmDeleteButton: {
backgroundColor: colors.vault.warning,
},
confirmCancelText: {
color: colors.vault.textSecondary,
},
assignActionRow: {
// Optional specific styling
},
assignModalContainer: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
overflow: 'hidden',
marginBottom: spacing.xxl,
},
assignModalContent: {
padding: spacing.xl,
},
assignModalHeader: {
alignItems: 'center',
marginBottom: spacing.xl,
},
assignIconGlow: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.primary}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
assignTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.xs,
fontFamily: typography.fontFamily.serif,
},
assignSubtitle: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
},
assignInputContainer: {
marginBottom: spacing.xl,
},
inputLabel: {
fontSize: typography.fontSize.xs,
color: colors.vault.textSecondary,
fontWeight: '700',
letterSpacing: 1,
marginBottom: spacing.sm,
},
assignInput: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
padding: spacing.md,
fontSize: typography.fontSize.base,
color: colors.vault.text,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
assignButtons: {
flexDirection: 'row',
gap: spacing.md,
},
assignCancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
assignCancelText: {
color: colors.vault.textSecondary,
fontSize: typography.fontSize.base,
fontWeight: '600',
},
assignConfirmButton: {
flex: 2,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
assignConfirmGradient: {
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
assignConfirmText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
errorModalContent: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
padding: spacing.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
alignItems: 'center',
marginBottom: spacing.xxl,
},
errorIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.warning}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
errorTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.sm,
fontFamily: typography.fontFamily.serif,
},
errorMessage: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
marginBottom: spacing.xl,
},
errorCloseButton: {
width: '100%',
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.vault.warning,
alignItems: 'center',
},
errorCloseButtonText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
});

View File

@@ -12,6 +12,7 @@ import {
getApiHeaders,
logApiDebug,
} from '../config';
import { AIRole } from '../types';
// =============================================================================
// Type Definitions
@@ -241,4 +242,83 @@ export const aiService = {
throw error;
}
},
/**
* Summarize a chat conversation
* @param messages - Array of chat messages
* @param token - JWT token for authentication
* @returns AI summary text
*/
async summarizeChat(messages: AIMessage[], token?: string): Promise<string> {
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';
},
/**
* Fetch available AI roles from backend
* @param token - Optional JWT token for authentication
* @returns Array of AI roles
*/
async getAIRoles(token?: string): Promise<AIRole[]> {
if (NO_BACKEND_MODE) {
logApiDebug('AI Roles', 'Using mock roles');
return [...AI_CONFIG.ROLES];
}
if (!token) {
console.warn('[AI Service] getAIRoles called without token, falling back to static roles');
return [...AI_CONFIG.ROLES];
}
const url = buildApiUrl(API_ENDPOINTS.AI.GET_ROLES);
const headers = getApiHeaders(token);
logApiDebug('AI Roles Request', {
url,
hasToken: !!token,
headers: {
...headers,
Authorization: headers.Authorization ? `${headers.Authorization.substring(0, 15)}...` : 'MISSING'
}
});
try {
const response = await fetch(url, {
method: 'GET',
headers,
});
if (!response.ok) {
console.error(`[AI Service] Failed to fetch AI roles: ${response.status}. Falling back to static roles.`);
return [...AI_CONFIG.ROLES];
}
const data = await response.json();
logApiDebug('AI Roles Success', { count: data.length });
return data;
} catch (error) {
console.error('[AI Service] Fetch AI roles error:', error);
// Fallback to config roles if API fails for better UX
return [...AI_CONFIG.ROLES];
}
},
};

View File

@@ -23,6 +23,7 @@ export interface Asset {
author_id: number;
private_key_shard: string;
content_outer_encrypted: string;
heir_email?: string;
}
export interface AssetCreate {
@@ -45,7 +46,7 @@ export interface AssetClaimResponse {
export interface AssetAssign {
asset_id: number;
heir_name: string;
heir_email: string;
}
// =============================================================================
@@ -59,6 +60,7 @@ const MOCK_ASSETS: Asset[] = [
author_id: MOCK_CONFIG.USER.id,
private_key_shard: 'mock_shard_1',
content_outer_encrypted: 'mock_encrypted_content_1',
heir_email: 'heir@example.com',
},
{
id: 2,
@@ -217,7 +219,7 @@ export const assetsService = {
logApiDebug('Assign Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
@@ -245,4 +247,44 @@ export const assetsService = {
throw error;
}
},
/**
* Delete an asset
* @param assetId - ID of the asset to delete
* @param token - JWT token for authentication
* @returns Success message
*/
async deleteAsset(assetId: number, token: string): Promise<{ message: string }> {
if (NO_BACKEND_MODE) {
logApiDebug('Delete Asset', `Using mock mode for ID: ${assetId}`);
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: 'Asset deleted successfully' });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.DELETE);
logApiDebug('Delete Asset URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify({ asset_id: assetId }),
});
logApiDebug('Delete Asset Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to delete asset');
}
return await response.json();
} catch (error) {
console.error('Delete asset error:', error);
throw error;
}
},
};

View File

@@ -14,6 +14,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEYS = {
CHAT_HISTORY: '@sentinel:chat_history',
CURRENT_MESSAGES: '@sentinel:current_messages',
ASSET_BACKUP: '@sentinel:asset_backup',
} as const;
// =============================================================================
@@ -115,6 +116,32 @@ export const storageService = {
} catch (e) {
console.error('Error clearing storage data:', e);
}
},
/**
* Save the plaintext backup of an asset locally
*/
async saveAssetBackup(assetId: number, content: string, userId: string | number): Promise<void> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
await AsyncStorage.setItem(key, content);
console.log(`[Storage] Saved asset backup for user ${userId}, asset ${assetId}`);
} catch (e) {
console.error(`Error saving asset backup for asset ${assetId}:`, e);
}
},
/**
* Retrieve the plaintext backup of an asset locally
*/
async getAssetBackup(assetId: number, userId: string | number): Promise<string | null> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
return await AsyncStorage.getItem(key);
} catch (e) {
console.error(`Error getting asset backup for asset ${assetId}:`, e);
return null;
}
}
};

View File

@@ -28,6 +28,8 @@ export interface VaultAsset {
createdAt: Date;
updatedAt: Date;
isEncrypted: boolean;
heirEmail?: string;
rawData?: any; // For debug logging
}
// Sentinel Types
@@ -102,3 +104,13 @@ export interface LoginResponse {
token_type: string;
user: User;
}
// AI Types
export interface AIRole {
id: string;
name: string;
description: string;
systemPrompt: string;
icon: string;
iconFamily: string;
}

202
src/utils/crypto_core.ts Normal file
View File

@@ -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<Buffer> {
// 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 "【解密失败】:密钥错误或数据被篡改";
}
}
}

View File

@@ -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<string, any> = {
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);
}

View File

@@ -14,8 +14,13 @@ export interface ApiAsset {
id: number;
title: string;
type?: string;
author_id?: number;
private_key_shard?: string;
content_outer_encrypted?: string;
created_at?: string;
updated_at?: string;
heir_id?: number;
heir_email?: string;
}
// -----------------------------------------------------------------------------
@@ -31,6 +36,8 @@ export const VAULT_ASSET_TYPES: VaultAssetType[] = [
'custom',
];
export const initialVaultAssets: VaultAsset[] = [];
// -----------------------------------------------------------------------------
// Mapping
// -----------------------------------------------------------------------------
@@ -50,6 +57,8 @@ export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
isEncrypted: true,
heirEmail: api.heir_email,
rawData: api,
};
}
@@ -60,41 +69,3 @@ export function mapApiAssetsToVaultAssets(apiList: ApiAsset[]): VaultAsset[] {
return apiList.map(mapApiAssetToVaultAsset);
}
// -----------------------------------------------------------------------------
// Mock / initial data (fallback when API is unavailable)
// -----------------------------------------------------------------------------
export const initialVaultAssets: VaultAsset[] = [
{
id: '1',
type: 'private_key',
label: 'ETH Main Wallet Key',
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
isEncrypted: true,
},
{
id: '2',
type: 'game_account',
label: 'Steam Account Credentials',
createdAt: new Date('2024-01-08'),
updatedAt: new Date('2024-01-08'),
isEncrypted: true,
},
{
id: '3',
type: 'document',
label: 'Insurance Policy Scan',
createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-05'),
isEncrypted: true,
},
{
id: '4',
type: 'will',
label: 'Testament Draft v2',
createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-15'),
isEncrypted: true,
},
];