Compare commits
5 Commits
240a7eea8b
...
feature/va
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e78c84c9 | ||
|
|
8e6c621f7b | ||
|
|
7b8511f080 | ||
|
|
f6fa19d0b2 | ||
|
|
536513ab3f |
6
app.json
@@ -19,14 +19,10 @@
|
||||
"bundleIdentifier": "com.sentinel.app"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#459E9E"
|
||||
},
|
||||
"package": "com.sentinel.app"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"favicon": "./assets/icon.png",
|
||||
"bundler": "metro"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 86 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 70 B |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 52 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 70 B |
@@ -27,7 +27,7 @@ export const DEBUG_MODE = true;
|
||||
/**
|
||||
* Base URL for the backend API server
|
||||
*/
|
||||
export const API_BASE_URL = 'http://192.168.56.103:8000';
|
||||
export const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* API request timeout in milliseconds
|
||||
@@ -64,6 +64,39 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// 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.
|
||||
// - 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).
|
||||
|
||||
const VAULT_KEY_PREFIX = 'sentinel_vault';
|
||||
|
||||
/** Base key names (for reference). Prefer getVaultStorageKeys(userId) for all reads/writes. */
|
||||
export const VAULT_STORAGE_KEYS = {
|
||||
INITIALIZED: 'sentinel_vault_initialized',
|
||||
SHARE_DEVICE: 'sentinel_vault_s0',
|
||||
} 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.
|
||||
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
|
||||
*/
|
||||
export function getVaultStorageKeys(userId: number | string | null): {
|
||||
INITIALIZED: string;
|
||||
SHARE_DEVICE: string;
|
||||
} {
|
||||
const suffix = userId != null ? `_u${userId}` : '_guest';
|
||||
return {
|
||||
INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`,
|
||||
SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
6
src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* React hooks for Sentinel
|
||||
*/
|
||||
|
||||
export { useVaultAssets } from './useVaultAssets';
|
||||
export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets';
|
||||
160
src/hooks/useVaultAssets.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* useVaultAssets: Encapsulates /assets/get and /assets/create for VaultScreen.
|
||||
* - Fetches assets when vault is unlocked and token exists.
|
||||
* - Exposes createAsset with 401/network error handling and list refresh on success.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as bip39 from 'bip39';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { assetsService } from '../services/assets.service';
|
||||
import { createAssetPayload } from '../services/vault.service';
|
||||
import {
|
||||
initialVaultAssets,
|
||||
mapApiAssetsToVaultAssets,
|
||||
type ApiAsset,
|
||||
} from '../utils/vaultAssets';
|
||||
import type { VaultAsset } from '../types';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface CreateAssetResult {
|
||||
success: boolean;
|
||||
isUnauthorized?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UseVaultAssetsReturn {
|
||||
/** Current list (mock until API succeeds) */
|
||||
assets: VaultAsset[];
|
||||
/** Replace list (e.g. after external refresh) */
|
||||
setAssets: React.Dispatch<React.SetStateAction<VaultAsset[]>>;
|
||||
/** Refetch from GET /assets/get */
|
||||
refreshAssets: () => Promise<void>;
|
||||
/** Create asset via POST /assets/create; on success refreshes list */
|
||||
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
|
||||
/** True while create request is in flight */
|
||||
isSealing: boolean;
|
||||
/** Error message from last create failure (non-401) */
|
||||
createError: string | null;
|
||||
/** Clear createError */
|
||||
clearCreateError: () => void;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hook
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 [assets, setAssets] = useState<VaultAsset[]>(initialVaultAssets);
|
||||
const [isSealing, setIsSealing] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const refreshAssets = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await assetsService.getMyAssets(token);
|
||||
if (Array.isArray(list)) {
|
||||
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||
}
|
||||
} catch {
|
||||
// Keep current assets (mock or previous fetch)
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch list when unlocked and token exists
|
||||
useEffect(() => {
|
||||
if (!isUnlocked || !token) return;
|
||||
let cancelled = false;
|
||||
assetsService
|
||||
.getMyAssets(token)
|
||||
.then((list) => {
|
||||
if (!cancelled && Array.isArray(list)) {
|
||||
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep initial (mock) assets
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isUnlocked, token]);
|
||||
|
||||
const createAsset = useCallback(
|
||||
async ({
|
||||
title,
|
||||
content,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
}): Promise<CreateAssetResult> => {
|
||||
if (!token) {
|
||||
return { success: false, error: 'Not logged in.' };
|
||||
}
|
||||
setIsSealing(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const wordList = bip39.wordlists.english;
|
||||
const payload = await createAssetPayload(
|
||||
title.trim(),
|
||||
content.trim(),
|
||||
wordList,
|
||||
'note',
|
||||
0
|
||||
);
|
||||
await assetsService.createAsset(
|
||||
{
|
||||
title: payload.title,
|
||||
private_key_shard: payload.private_key_shard,
|
||||
content_inner_encrypted: payload.content_inner_encrypted,
|
||||
},
|
||||
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 create.');
|
||||
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 (see API_BASE_URL in config).'
|
||||
: rawMessage;
|
||||
setCreateError(friendlyMessage);
|
||||
return { success: false, error: friendlyMessage };
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
}
|
||||
},
|
||||
[token, refreshAssets, signOut]
|
||||
);
|
||||
|
||||
const clearCreateError = useCallback(() => setCreateError(null), []);
|
||||
|
||||
return {
|
||||
assets,
|
||||
setAssets,
|
||||
refreshAssets,
|
||||
createAsset,
|
||||
isSealing,
|
||||
createError,
|
||||
clearCreateError,
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
||||
import HeritageScreen from './HeritageScreen';
|
||||
import { VAULT_STORAGE_KEYS } from './SentinelScreen';
|
||||
import { getVaultStorageKeys } from '../config';
|
||||
|
||||
// Mock heirs data
|
||||
const initialHeirs: Heir[] = [
|
||||
@@ -248,6 +248,7 @@ export default function MeScreen() {
|
||||
});
|
||||
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
||||
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||
const [resetVaultFeedback, setResetVaultFeedback] = useState<{ status: 'idle' | 'success' | 'error'; message: string }>({ status: 'idle', message: '' });
|
||||
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
||||
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
||||
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
||||
@@ -308,17 +309,30 @@ export default function MeScreen() {
|
||||
};
|
||||
|
||||
const handleResetVault = async () => {
|
||||
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||
try {
|
||||
await AsyncStorage.multiRemove([
|
||||
VAULT_STORAGE_KEYS.INITIALIZED,
|
||||
VAULT_STORAGE_KEYS.SHARE_DEVICE,
|
||||
vaultKeys.INITIALIZED,
|
||||
vaultKeys.SHARE_DEVICE,
|
||||
]);
|
||||
Alert.alert('Done', 'Vault state reset. Go to Sentinel → Open Shadow Vault to see first-time flow.');
|
||||
setResetVaultFeedback({
|
||||
status: 'success',
|
||||
message: 'Vault state has been reset. Next time you open Shadow Vault you will see the mnemonic flow again.',
|
||||
});
|
||||
} catch (e) {
|
||||
Alert.alert('Error', 'Failed to reset vault state.');
|
||||
setResetVaultFeedback({
|
||||
status: 'error',
|
||||
message: 'Failed to reset vault state. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSanctumModal = () => {
|
||||
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||
setShowSanctumModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
@@ -760,7 +774,7 @@ export default function MeScreen() {
|
||||
visible={showSanctumModal}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowSanctumModal(false)}
|
||||
onRequestClose={handleCloseSanctumModal}
|
||||
>
|
||||
<View style={styles.spiritOverlay}>
|
||||
<View style={styles.spiritModal}>
|
||||
@@ -908,7 +922,31 @@ export default function MeScreen() {
|
||||
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
|
||||
<Text style={styles.devResetText}>Reset Vault State</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.sanctumHint}>Clear hasVaultInitialized & Share A. Test first-open flow.</Text>
|
||||
<Text style={styles.sanctumHint}>Clear S0 (SHARE_DEVICE) from storage. Next vault open uses mnemonic flow.</Text>
|
||||
{resetVaultFeedback.status !== 'idle' && (
|
||||
<View
|
||||
style={[
|
||||
styles.resetVaultFeedback,
|
||||
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackSuccess : styles.resetVaultFeedbackError,
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={resetVaultFeedback.status === 'success' ? 'checkmark-circle' : 'alert-circle'}
|
||||
size={20}
|
||||
color={resetVaultFeedback.status === 'success' ? colors.sentinel?.statusNormal ?? '#6BBF8A' : colors.nautical.coral}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.resetVaultFeedbackText,
|
||||
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackTextSuccess : styles.resetVaultFeedbackTextError,
|
||||
]}
|
||||
>
|
||||
{resetVaultFeedback.status === 'success' ? 'Success' : 'Error'}
|
||||
{' — '}
|
||||
{resetVaultFeedback.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
@@ -916,7 +954,7 @@ export default function MeScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.confirmPulseButton}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setShowSanctumModal(false)}
|
||||
onPress={handleCloseSanctumModal}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
||||
<Text style={styles.confirmPulseText}>Save</Text>
|
||||
@@ -924,7 +962,7 @@ export default function MeScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.confirmPulseButton}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setShowSanctumModal(false)}
|
||||
onPress={handleCloseSanctumModal}
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
||||
<Text style={styles.confirmPulseText}>Close</Text>
|
||||
@@ -1910,6 +1948,34 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.nautical.coral,
|
||||
},
|
||||
resetVaultFeedback: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.base,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
resetVaultFeedbackSuccess: {
|
||||
backgroundColor: 'rgba(107, 191, 138, 0.2)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(107, 191, 138, 0.5)',
|
||||
},
|
||||
resetVaultFeedbackError: {
|
||||
backgroundColor: 'rgba(229, 115, 115, 0.2)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(229, 115, 115, 0.5)',
|
||||
},
|
||||
resetVaultFeedbackText: {
|
||||
flex: 1,
|
||||
fontSize: typography.fontSize.sm,
|
||||
},
|
||||
resetVaultFeedbackTextSuccess: {
|
||||
color: '#2E7D5E',
|
||||
},
|
||||
resetVaultFeedbackTextError: {
|
||||
color: colors.nautical.coral,
|
||||
},
|
||||
confirmPulseButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -65,6 +65,8 @@ const initialLogs: KillSwitchLog[] = [
|
||||
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
|
||||
];
|
||||
|
||||
export { VAULT_STORAGE_KEYS } from '../config';
|
||||
|
||||
export default function SentinelScreen() {
|
||||
const [status, setStatus] = useState<SystemStatus>('normal');
|
||||
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
||||
|
||||
@@ -24,6 +24,9 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col
|
||||
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 { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss';
|
||||
|
||||
// Asset type configuration with nautical theme
|
||||
const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = {
|
||||
@@ -89,40 +92,40 @@ type HeirAssignment = {
|
||||
};
|
||||
|
||||
// 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 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) {
|
||||
@@ -140,7 +143,15 @@ const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], siz
|
||||
export default function VaultScreen() {
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
const [showBiometric, setShowBiometric] = useState(false);
|
||||
const [assets, setAssets] = useState<VaultAsset[]>(initialAssets);
|
||||
const {
|
||||
assets,
|
||||
setAssets,
|
||||
refreshAssets,
|
||||
createAsset: createVaultAsset,
|
||||
isSealing,
|
||||
createError: addError,
|
||||
clearCreateError: clearAddError,
|
||||
} = useVaultAssets(isUnlocked);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<VaultAssetType>('custom');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
@@ -159,6 +170,7 @@ export default function VaultScreen() {
|
||||
const [accountProvider, setAccountProvider] = useState<'bank' | 'steam' | 'facebook' | 'custom'>('bank');
|
||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
||||
const [showLegacyAssignCta, setShowLegacyAssignCta] = useState(false);
|
||||
const [hasS0, setHasS0] = useState<boolean | null>(null);
|
||||
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
|
||||
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]);
|
||||
const [mnemonicStep, setMnemonicStep] = useState<1 | 2 | 3 | 4 | 5>(1);
|
||||
@@ -170,19 +182,37 @@ export default function VaultScreen() {
|
||||
const [replaceQuery, setReplaceQuery] = useState('');
|
||||
const [progressIndex, setProgressIndex] = useState(0);
|
||||
const [progressAnim] = useState(new Animated.Value(0));
|
||||
const { user } = useAuth();
|
||||
const { user, token } = useAuth();
|
||||
const vaultKeys = React.useMemo(() => getVaultStorageKeys(user?.id ?? null), [user?.id]);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [treasureContent, setTreasureContent] = useState('');
|
||||
const mnemonicRef = useRef<View>(null);
|
||||
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Detect S0 (TEE/SE) for current user: if present, later open shows biometric only; if not, mnemonic flow
|
||||
useEffect(() => {
|
||||
if (!isUnlocked) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowBiometric(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
let cancelled = false;
|
||||
AsyncStorage.getItem(vaultKeys.SHARE_DEVICE)
|
||||
.then((v) => {
|
||||
if (!cancelled) setHasS0(!!v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasS0(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [vaultKeys.SHARE_DEVICE]);
|
||||
|
||||
// Only when S0 exists and vault not unlocked: show biometric after short delay.
|
||||
// When hasS0 is false or null, never show biometric — go straight to mnemonic flow.
|
||||
useEffect(() => {
|
||||
if (hasS0 !== true) {
|
||||
setShowBiometric(false);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
if (isUnlocked) return;
|
||||
const timer = setTimeout(() => setShowBiometric(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isUnlocked, hasS0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnlocked) {
|
||||
@@ -325,28 +355,49 @@ export default function VaultScreen() {
|
||||
|
||||
const handleHeirDecision = (share: boolean) => {
|
||||
// Placeholder for future heir flow
|
||||
setShowMnemonic(false);
|
||||
setIsUnlocked(true);
|
||||
finishMnemonicThenShowBiometric();
|
||||
};
|
||||
|
||||
const handleSubmitAssignment = () => {
|
||||
// Placeholder for submitting assignment to API
|
||||
setShowMnemonic(false);
|
||||
setIsUnlocked(true);
|
||||
finishMnemonicThenShowBiometric();
|
||||
};
|
||||
|
||||
const handleHeirYes = () => {
|
||||
setShowMnemonic(false);
|
||||
setIsUnlocked(true);
|
||||
finishMnemonicThenShowBiometric();
|
||||
setShowLegacyAssignCta(true);
|
||||
};
|
||||
|
||||
const handleHeirNo = () => {
|
||||
setShowMnemonic(false);
|
||||
setIsUnlocked(true);
|
||||
finishMnemonicThenShowBiometric();
|
||||
setShowLegacyAssignCta(true);
|
||||
};
|
||||
|
||||
/** After mnemonic flow: persist S0 (simulated TEE/SE via AsyncStorage), then show biometric; unlock on success */
|
||||
const finishMnemonicThenShowBiometric = async () => {
|
||||
try {
|
||||
const wordList = bip39.wordlists.english;
|
||||
const entropy = mnemonicToEntropy(mnemonicWords, wordList);
|
||||
const shares = splitSecret(entropy);
|
||||
const s0 = shares[0]; // device share (S0)
|
||||
// S0 is stored in AsyncStorage under user-scoped key — app-level storage, not hardware TEE/SE
|
||||
await AsyncStorage.setItem(vaultKeys.SHARE_DEVICE, serializeShare(s0));
|
||||
await AsyncStorage.setItem(vaultKeys.INITIALIZED, '1');
|
||||
setHasS0(true);
|
||||
setShowMnemonic(false);
|
||||
setShowBiometric(true);
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('finishMnemonicThenShowBiometric', e);
|
||||
setShowMnemonic(false);
|
||||
setShowBiometric(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBiometricSuccess = () => {
|
||||
setShowBiometric(false);
|
||||
setIsUnlocked(true);
|
||||
};
|
||||
|
||||
const handleOpenLegacyAssign = () => {
|
||||
setSelectedHeir(null);
|
||||
setSelectedHeirAsset(null);
|
||||
@@ -375,29 +426,52 @@ export default function VaultScreen() {
|
||||
setAccountProvider('bank');
|
||||
};
|
||||
|
||||
const handleAddAsset = () => {
|
||||
if (!newLabel.trim()) return;
|
||||
const handleAddAsset = async () => {
|
||||
if (!newLabel.trim() || !treasureContent.trim()) return;
|
||||
if (!addVerified) return;
|
||||
if (selectedType === 'private_key' && !rehearsalConfirmed) return;
|
||||
if (!token) {
|
||||
Alert.alert('Not logged in', 'Please sign in first to add a Treasure.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newAsset: VaultAsset = {
|
||||
id: Date.now().toString(),
|
||||
type: selectedType,
|
||||
label: newLabel,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isEncrypted: true,
|
||||
};
|
||||
const result = await createVaultAsset({
|
||||
title: newLabel.trim(),
|
||||
content: treasureContent.trim(),
|
||||
});
|
||||
|
||||
setAssets([newAsset, ...assets]);
|
||||
if (result.success) {
|
||||
setNewLabel('');
|
||||
setTreasureContent('');
|
||||
setSelectedType('custom');
|
||||
setShowAddModal(false);
|
||||
setAddVerified(false);
|
||||
setRehearsalConfirmed(false);
|
||||
|
||||
setShowAddModal(false);
|
||||
clearAddError();
|
||||
setShowUploadSuccess(true);
|
||||
setTimeout(() => setShowUploadSuccess(false), 2500);
|
||||
if (typeof Alert !== 'undefined' && Alert.alert) {
|
||||
Alert.alert('Success', 'Treasure sealed and saved successfully.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isUnauthorized) {
|
||||
setShowAddModal(false);
|
||||
clearAddError();
|
||||
if (typeof Alert !== 'undefined' && Alert.alert) {
|
||||
Alert.alert(
|
||||
'Unauthorized',
|
||||
'Your session has expired or you are not logged in. Please sign in again.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error && typeof Alert !== 'undefined' && Alert.alert) {
|
||||
Alert.alert('Failed', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
@@ -485,7 +559,9 @@ export default function VaultScreen() {
|
||||
? '12/24 Words'
|
||||
: selectedConfig?.label || '--';
|
||||
const canSeal = !!newLabel.trim()
|
||||
&& !!treasureContent.trim()
|
||||
&& addVerified
|
||||
&& !isSealing
|
||||
&& (selectedType !== 'private_key' || rehearsalConfirmed);
|
||||
|
||||
const mnemonicModal = (
|
||||
@@ -499,10 +575,10 @@ export default function VaultScreen() {
|
||||
style={styles.mnemonicOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View ref={mnemonicRef} collapsable={false}>
|
||||
<LinearGradient
|
||||
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
|
||||
style={styles.mnemonicCard}
|
||||
ref={mnemonicRef}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.mnemonicClose}
|
||||
@@ -821,6 +897,7 @@ export default function VaultScreen() {
|
||||
<View style={[styles.stepDot, mnemonicStep !== 1 && styles.stepDotActive]} />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
@@ -852,7 +929,7 @@ export default function VaultScreen() {
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.unlockButton}
|
||||
onPress={() => setShowBiometric(true)}
|
||||
onPress={hasS0 ? () => setShowBiometric(true) : handleUnlock}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
@@ -861,8 +938,10 @@ export default function VaultScreen() {
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Ionicons name="finger-print" size={20} color={colors.vault.background} />
|
||||
<Text style={styles.unlockButtonText}>Captain's Verification</Text>
|
||||
<Ionicons name={hasS0 ? 'finger-print' : 'key'} size={20} color={colors.vault.background} />
|
||||
<Text style={styles.unlockButtonText}>
|
||||
{hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -870,8 +949,8 @@ export default function VaultScreen() {
|
||||
</LinearGradient>
|
||||
|
||||
<BiometricModal
|
||||
visible={showBiometric}
|
||||
onSuccess={handleUnlock}
|
||||
visible={hasS0 === true && showBiometric}
|
||||
onSuccess={handleBiometricSuccess}
|
||||
onCancel={() => setShowBiometric(false)}
|
||||
title="Enter the Vault"
|
||||
message="Verify your identity to access your treasures"
|
||||
@@ -963,6 +1042,7 @@ export default function VaultScreen() {
|
||||
style={styles.addButton}
|
||||
onPress={() => {
|
||||
resetAddFlow();
|
||||
clearAddError();
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
@@ -1005,7 +1085,7 @@ export default function VaultScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.stepRow}>
|
||||
{['Type', 'Method', 'Verify'].map((label, index) => {
|
||||
{['Title', 'Content', 'Verify'].map((label, index) => {
|
||||
const stepIndex = index + 1;
|
||||
const isActive = addStep === stepIndex;
|
||||
const isDone = addStep > stepIndex;
|
||||
@@ -1034,6 +1114,14 @@ export default function VaultScreen() {
|
||||
|
||||
{addStep === 1 && (
|
||||
<>
|
||||
<Text style={styles.modalLabel}>TREASURE TITLE</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g., Main wallet mnemonic"
|
||||
placeholderTextColor={colors.nautical.sage}
|
||||
value={newLabel}
|
||||
onChangeText={setNewLabel}
|
||||
/>
|
||||
<Text style={styles.modalLabel}>TREASURE TYPE</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -1093,9 +1181,25 @@ export default function VaultScreen() {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.modalLabel}>CONTENT</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputMultiline]}
|
||||
placeholder="Enter content to seal (plaintext is encrypted locally before upload)"
|
||||
placeholderTextColor={colors.nautical.sage}
|
||||
value={treasureContent}
|
||||
onChangeText={setTreasureContent}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<View style={styles.encryptionNote}>
|
||||
<MaterialCommunityIcons name="anchor" size={16} color={colors.nautical.teal} />
|
||||
<Text style={styles.encryptionNoteText}>
|
||||
Data is encrypted on-device. Plaintext is shredded after sealing.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedType === 'game_account' && (
|
||||
<>
|
||||
<Text style={styles.modalLabel}>ACCOUNT PROVIDER</Text>
|
||||
@@ -1135,9 +1239,6 @@ export default function VaultScreen() {
|
||||
<Ionicons name="log-in-outline" size={18} color={colors.nautical.cream} />
|
||||
<Text style={styles.loginButtonText}>Open App to Login</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.modalLabel}>TREASURE NAME</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
@@ -1146,13 +1247,8 @@ export default function VaultScreen() {
|
||||
value={newLabel}
|
||||
onChangeText={setNewLabel}
|
||||
/>
|
||||
|
||||
<View style={styles.encryptionNote}>
|
||||
<MaterialCommunityIcons name="anchor" size={16} color={colors.nautical.teal} />
|
||||
<Text style={styles.encryptionNoteText}>
|
||||
Data is encrypted on-device. Plaintext is shredded after sealing.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1201,14 +1297,24 @@ export default function VaultScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{addError ? (
|
||||
<View style={styles.addErrorBox}>
|
||||
<Ionicons name="warning" size={18} color="#c2410c" />
|
||||
<Text style={styles.addErrorText}>{addError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => {
|
||||
if (addStep === 1) {
|
||||
setShowAddModal(false);
|
||||
setTreasureContent('');
|
||||
clearAddError();
|
||||
} else {
|
||||
setAddStep(addStep - 1);
|
||||
clearAddError();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1248,7 +1354,7 @@ export default function VaultScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="lock" size={18} color="#fff" />
|
||||
<Text style={styles.confirmButtonText}>Seal Treasure</Text>
|
||||
<Text style={styles.confirmButtonText}>{isSealing ? 'Sealing...' : 'Seal Treasure'}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -1344,7 +1450,7 @@ export default function VaultScreen() {
|
||||
<Text style={styles.actionText}>Export Cipher Pack</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionRow} activeOpacity={0.8}>
|
||||
<MaterialCommunityIcons name="timer-reset" size={18} color={colors.vault.primary} />
|
||||
<MaterialCommunityIcons name="refresh" size={18} color={colors.vault.primary} />
|
||||
<Text style={styles.actionText}>Reset Sentinel Timer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -1835,6 +1941,10 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: colors.nautical.lightMint,
|
||||
},
|
||||
inputMultiline: {
|
||||
minHeight: 120,
|
||||
paddingTop: spacing.base,
|
||||
},
|
||||
encryptionNote: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1850,6 +1960,23 @@ const styles = StyleSheet.create({
|
||||
color: colors.nautical.teal,
|
||||
lineHeight: typography.fontSize.sm * 1.4,
|
||||
},
|
||||
addErrorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(194, 65, 12, 0.12)',
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(194, 65, 12, 0.3)',
|
||||
},
|
||||
addErrorText: {
|
||||
flex: 1,
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: '#c2410c',
|
||||
lineHeight: typography.fontSize.sm * 1.4,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
|
||||
@@ -142,11 +142,16 @@ export const assetsService = {
|
||||
body: JSON.stringify(asset),
|
||||
});
|
||||
|
||||
logApiDebug('Create Asset Response Status', response.status);
|
||||
const responseStatus = response.status;
|
||||
logApiDebug('Create Asset Response Status', responseStatus);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to create asset');
|
||||
const detail = errorData.detail || 'Failed to create asset';
|
||||
const message = responseStatus === 401 ? `Unauthorized (401): ${detail}` : detail;
|
||||
const err = new Error(message) as Error & { status?: number };
|
||||
err.status = responseStatus;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
@@ -23,3 +23,9 @@ export {
|
||||
type DeclareGualeRequest,
|
||||
type DeclareGualeResponse
|
||||
} from './admin.service';
|
||||
export {
|
||||
createVaultPayload,
|
||||
createAssetPayload,
|
||||
type CreateVaultPayloadResult,
|
||||
type CreateAssetPayloadResult,
|
||||
} from './vault.service';
|
||||
|
||||
81
src/services/vault.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Vault Service: 为 /assets/create 生成 private_key_shard 与 content_inner_encrypted
|
||||
*
|
||||
* 流程(与后端 test_scenario / SentinelVault 一致):
|
||||
* 1. 用 SSS 生成助记词并分片 → 选一个分片作为 private_key_shard(存后端,继承时返回)
|
||||
* 2. 用助记词派生 AES 密钥,对明文做 AES-GCM 加密 → content_inner_encrypted(hex 字符串)
|
||||
*
|
||||
* 使用方式:在任意页面调用 createVaultPayload(plaintext, wordList),得到可直接传给 assetsService.createAsset 的字段。
|
||||
*/
|
||||
|
||||
import {
|
||||
generateVaultKeys,
|
||||
serializeShare,
|
||||
type SSSShare,
|
||||
type VaultKeyData,
|
||||
} from '../utils/sss';
|
||||
import { deriveKey, encryptDataGCM, bytesToHex } from '../utils/vaultCrypto';
|
||||
|
||||
export interface CreateVaultPayloadResult {
|
||||
/** 传给后端的 private_key_shard(存一个 SSS 分片的序列化字符串,如云端分片) */
|
||||
private_key_shard: string;
|
||||
/** 传给后端的 content_inner_encrypted(AES-GCM 密文的 hex) */
|
||||
content_inner_encrypted: string;
|
||||
/** 本次生成的助记词(用户需妥善保管,恢复时需任意 2 个分片) */
|
||||
mnemonic: string[];
|
||||
/** 三个分片:device / cloud / heir,可与后端返回的 server_shard 组合恢复助记词 */
|
||||
shares: SSSShare[];
|
||||
}
|
||||
|
||||
export interface CreateAssetPayloadResult {
|
||||
title: string;
|
||||
type: string;
|
||||
private_key_shard: string;
|
||||
content_inner_encrypted: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成金库:助记词 + SSS 分片 + 内层加密内容
|
||||
* @param plaintext 要加密的明文(如遗产说明、账号密码等)
|
||||
* @param wordList 助记词词表(与 sss 使用的词表一致)
|
||||
* @param shareIndexForServer 哪个分片存后端,0=device, 1=cloud, 2=heir,默认 1(云端)
|
||||
*/
|
||||
export async function createVaultPayload(
|
||||
plaintext: string,
|
||||
wordList: readonly string[],
|
||||
shareIndexForServer: 0 | 1 | 2 = 1
|
||||
): Promise<CreateVaultPayloadResult> {
|
||||
const { mnemonic, shares }: VaultKeyData = generateVaultKeys(wordList, 12);
|
||||
const mnemonicPhrase = mnemonic.join(' ');
|
||||
const key = await deriveKey(mnemonicPhrase);
|
||||
const encrypted = await encryptDataGCM(key, plaintext);
|
||||
const content_inner_encrypted = bytesToHex(encrypted);
|
||||
const shareForServer = shares[shareIndexForServer];
|
||||
const private_key_shard = serializeShare(shareForServer);
|
||||
|
||||
return {
|
||||
private_key_shard,
|
||||
content_inner_encrypted,
|
||||
mnemonic,
|
||||
shares,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可直接用于 POST /assets/create 的请求体(含 title / type)
|
||||
*/
|
||||
export async function createAssetPayload(
|
||||
title: string,
|
||||
plaintext: string,
|
||||
wordList: readonly string[],
|
||||
assetType: string = 'note',
|
||||
shareIndexForServer: 0 | 1 | 2 = 1
|
||||
): Promise<CreateAssetPayloadResult> {
|
||||
const vault = await createVaultPayload(plaintext, wordList, shareIndexForServer);
|
||||
return {
|
||||
title,
|
||||
type: assetType,
|
||||
private_key_shard: vault.private_key_shard,
|
||||
content_inner_encrypted: vault.content_inner_encrypted,
|
||||
};
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from './sss';
|
||||
export * from './vaultAssets';
|
||||
|
||||
100
src/utils/vaultAssets.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Vault assets: API ↔ UI mapping and initial mock data.
|
||||
* Used by useVaultAssets and VaultScreen for /assets/get and /assets/create flows.
|
||||
*/
|
||||
|
||||
import type { VaultAsset, VaultAssetType } from '../types';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Shape returned by GET /assets/get (backend AssetOut) */
|
||||
export interface ApiAsset {
|
||||
id: number;
|
||||
title: string;
|
||||
type?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VAULT_ASSET_TYPES: VaultAssetType[] = [
|
||||
'game_account',
|
||||
'private_key',
|
||||
'document',
|
||||
'photo',
|
||||
'will',
|
||||
'custom',
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Mapping
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map backend API asset to VaultAsset for UI.
|
||||
*/
|
||||
export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
|
||||
const type: VaultAssetType =
|
||||
api.type && VAULT_ASSET_TYPES.includes(api.type as VaultAssetType)
|
||||
? (api.type as VaultAssetType)
|
||||
: 'custom';
|
||||
return {
|
||||
id: String(api.id),
|
||||
type,
|
||||
label: api.title,
|
||||
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
|
||||
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
|
||||
isEncrypted: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of API assets to VaultAsset[].
|
||||
*/
|
||||
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,
|
||||
},
|
||||
];
|
||||
107
src/utils/vaultCrypto.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt.
|
||||
* Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM).
|
||||
* Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed.
|
||||
*/
|
||||
|
||||
const SALT = new TextEncoder().encode('Sentinel_Salt_2026');
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
const AES_KEY_LEN = 256;
|
||||
const GCM_IV_LEN = 16;
|
||||
const GCM_TAG_LEN = 16;
|
||||
|
||||
function getCrypto(): Crypto {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) return crypto;
|
||||
throw new Error('vaultCrypto: crypto.subtle not available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 32-byte AES key from mnemonic phrase (space-separated words).
|
||||
*/
|
||||
export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise<ArrayBuffer> {
|
||||
const crypto = getCrypto();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(mnemonicPhrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBuf,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
AES_KEY_LEN
|
||||
);
|
||||
return bits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault).
|
||||
*/
|
||||
export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise<Uint8Array> {
|
||||
const crypto = getCrypto();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN));
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ciphertextWithTag = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 },
|
||||
cryptoKey,
|
||||
encoded
|
||||
);
|
||||
const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength);
|
||||
out.set(iv, 0);
|
||||
out.set(new Uint8Array(ciphertextWithTag), iv.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag).
|
||||
*/
|
||||
export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise<string> {
|
||||
const crypto = getCrypto();
|
||||
const iv = blob.subarray(0, GCM_IV_LEN);
|
||||
const ciphertextWithTag = blob.subarray(GCM_IV_LEN);
|
||||
const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
||||
const ctBuf = ciphertextWithTag.buffer.slice(
|
||||
ciphertextWithTag.byteOffset,
|
||||
ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength
|
||||
) as ArrayBuffer;
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
const dec = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 },
|
||||
cryptoKey,
|
||||
ctBuf
|
||||
);
|
||||
return new TextDecoder().decode(dec);
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const len = hex.length / 2;
|
||||
const out = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||