feat(vault): add get/create assets API in workflow

TODO: update vault.service.ts. Use MNEMONIC workflow to create real private_key_shard and content_inner_encrypted
This commit is contained in:
Ada
2026-02-01 09:19:45 -08:00
parent 536513ab3f
commit f6fa19d0b2
9 changed files with 628 additions and 78 deletions

6
src/hooks/index.ts Normal file
View 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
View 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,
};
}

View File

@@ -24,6 +24,7 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col
import { VaultAsset, VaultAssetType, Heir } from '../types'; import { VaultAsset, VaultAssetType, Heir } from '../types';
import BiometricModal from '../components/common/BiometricModal'; import BiometricModal from '../components/common/BiometricModal';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useVaultAssets } from '../hooks/useVaultAssets';
// Asset type configuration with nautical theme // Asset type configuration with nautical theme
const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = { const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = {
@@ -89,40 +90,40 @@ type HeirAssignment = {
}; };
// Mock data // Mock data
const initialAssets: VaultAsset[] = [ // const initialAssets: VaultAsset[] = [
{ // {
id: '1', // id: '1',
type: 'private_key', // type: 'private_key',
label: 'ETH Main Wallet Key', // label: 'ETH Main Wallet Key',
createdAt: new Date('2024-01-10'), // createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'), // updatedAt: new Date('2024-01-10'),
isEncrypted: true, // isEncrypted: true,
}, // },
{ // {
id: '2', // id: '2',
type: 'game_account', // type: 'game_account',
label: 'Steam Account Credentials', // label: 'Steam Account Credentials',
createdAt: new Date('2024-01-08'), // createdAt: new Date('2024-01-08'),
updatedAt: new Date('2024-01-08'), // updatedAt: new Date('2024-01-08'),
isEncrypted: true, // isEncrypted: true,
}, // },
{ // {
id: '3', // id: '3',
type: 'document', // type: 'document',
label: 'Insurance Policy Scan', // label: 'Insurance Policy Scan',
createdAt: new Date('2024-01-05'), // createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-05'), // updatedAt: new Date('2024-01-05'),
isEncrypted: true, // isEncrypted: true,
}, // },
{ // {
id: '4', // id: '4',
type: 'will', // type: 'will',
label: 'Testament Draft v2', // label: 'Testament Draft v2',
createdAt: new Date('2024-01-02'), // createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-15'), // updatedAt: new Date('2024-01-15'),
isEncrypted: true, // isEncrypted: true,
}, // },
]; // ];
const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], size: number, color: string) => { const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], size: number, color: string) => {
switch (config.iconType) { switch (config.iconType) {
@@ -140,7 +141,15 @@ const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], siz
export default function VaultScreen() { export default function VaultScreen() {
const [isUnlocked, setIsUnlocked] = useState(false); const [isUnlocked, setIsUnlocked] = useState(false);
const [showBiometric, setShowBiometric] = 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 [showAddModal, setShowAddModal] = useState(false);
const [selectedType, setSelectedType] = useState<VaultAssetType>('custom'); const [selectedType, setSelectedType] = useState<VaultAssetType>('custom');
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
@@ -170,8 +179,9 @@ export default function VaultScreen() {
const [replaceQuery, setReplaceQuery] = useState(''); const [replaceQuery, setReplaceQuery] = useState('');
const [progressIndex, setProgressIndex] = useState(0); const [progressIndex, setProgressIndex] = useState(0);
const [progressAnim] = useState(new Animated.Value(0)); const [progressAnim] = useState(new Animated.Value(0));
const { user } = useAuth(); const { user, token } = useAuth();
const [isCapturing, setIsCapturing] = useState(false); const [isCapturing, setIsCapturing] = useState(false);
const [treasureContent, setTreasureContent] = useState('');
const mnemonicRef = useRef<View>(null); const mnemonicRef = useRef<View>(null);
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -375,29 +385,52 @@ export default function VaultScreen() {
setAccountProvider('bank'); setAccountProvider('bank');
}; };
const handleAddAsset = () => { const handleAddAsset = async () => {
if (!newLabel.trim()) return; if (!newLabel.trim() || !treasureContent.trim()) return;
if (!addVerified) return; if (!addVerified) return;
if (selectedType === 'private_key' && !rehearsalConfirmed) 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 = { const result = await createVaultAsset({
id: Date.now().toString(), title: newLabel.trim(),
type: selectedType, content: treasureContent.trim(),
label: newLabel, });
createdAt: new Date(),
updatedAt: new Date(),
isEncrypted: true,
};
setAssets([newAsset, ...assets]); if (result.success) {
setNewLabel(''); setNewLabel('');
setSelectedType('custom'); setTreasureContent('');
setShowAddModal(false); setSelectedType('custom');
setAddVerified(false); setAddVerified(false);
setRehearsalConfirmed(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;
}
setShowUploadSuccess(true); if (result.isUnauthorized) {
setTimeout(() => setShowUploadSuccess(false), 2500); 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) => { const formatDate = (date: Date) => {
@@ -485,7 +518,9 @@ export default function VaultScreen() {
? '12/24 Words' ? '12/24 Words'
: selectedConfig?.label || '--'; : selectedConfig?.label || '--';
const canSeal = !!newLabel.trim() const canSeal = !!newLabel.trim()
&& !!treasureContent.trim()
&& addVerified && addVerified
&& !isSealing
&& (selectedType !== 'private_key' || rehearsalConfirmed); && (selectedType !== 'private_key' || rehearsalConfirmed);
const mnemonicModal = ( const mnemonicModal = (
@@ -499,10 +534,10 @@ export default function VaultScreen() {
style={styles.mnemonicOverlay} style={styles.mnemonicOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<View ref={mnemonicRef} collapsable={false}>
<LinearGradient <LinearGradient
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]} colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
style={styles.mnemonicCard} style={styles.mnemonicCard}
ref={mnemonicRef}
> >
<TouchableOpacity <TouchableOpacity
style={styles.mnemonicClose} style={styles.mnemonicClose}
@@ -821,6 +856,7 @@ export default function VaultScreen() {
<View style={[styles.stepDot, mnemonicStep !== 1 && styles.stepDotActive]} /> <View style={[styles.stepDot, mnemonicStep !== 1 && styles.stepDotActive]} />
</View> </View>
</LinearGradient> </LinearGradient>
</View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</Modal> </Modal>
); );
@@ -963,6 +999,7 @@ export default function VaultScreen() {
style={styles.addButton} style={styles.addButton}
onPress={() => { onPress={() => {
resetAddFlow(); resetAddFlow();
clearAddError();
setShowAddModal(true); setShowAddModal(true);
}} }}
activeOpacity={0.9} activeOpacity={0.9}
@@ -1005,7 +1042,7 @@ export default function VaultScreen() {
</View> </View>
<View style={styles.stepRow}> <View style={styles.stepRow}>
{['Type', 'Method', 'Verify'].map((label, index) => { {['Title', 'Content', 'Verify'].map((label, index) => {
const stepIndex = index + 1; const stepIndex = index + 1;
const isActive = addStep === stepIndex; const isActive = addStep === stepIndex;
const isDone = addStep > stepIndex; const isDone = addStep > stepIndex;
@@ -1034,6 +1071,14 @@ export default function VaultScreen() {
{addStep === 1 && ( {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> <Text style={styles.modalLabel}>TREASURE TYPE</Text>
<ScrollView <ScrollView
horizontal horizontal
@@ -1093,9 +1138,25 @@ export default function VaultScreen() {
); );
})} })}
</View> </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' && ( {selectedType === 'game_account' && (
<> <>
<Text style={styles.modalLabel}>ACCOUNT PROVIDER</Text> <Text style={styles.modalLabel}>ACCOUNT PROVIDER</Text>
@@ -1135,24 +1196,16 @@ export default function VaultScreen() {
<Ionicons name="log-in-outline" size={18} color={colors.nautical.cream} /> <Ionicons name="log-in-outline" size={18} color={colors.nautical.cream} />
<Text style={styles.loginButtonText}>Open App to Login</Text> <Text style={styles.loginButtonText}>Open App to Login</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.modalLabel}>TREASURE NAME</Text>
<TextInput
style={styles.input}
placeholder="e.g., Main wallet mnemonic"
placeholderTextColor={colors.nautical.sage}
value={newLabel}
onChangeText={setNewLabel}
/>
</> </>
)} )}
<Text style={styles.modalLabel}>TREASURE NAME</Text>
<TextInput
style={styles.input}
placeholder="e.g., Main wallet mnemonic"
placeholderTextColor={colors.nautical.sage}
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 +1254,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}> <View style={styles.modalButtons}>
<TouchableOpacity <TouchableOpacity
style={styles.cancelButton} style={styles.cancelButton}
onPress={() => { onPress={() => {
if (addStep === 1) { if (addStep === 1) {
setShowAddModal(false); setShowAddModal(false);
setTreasureContent('');
clearAddError();
} else { } else {
setAddStep(addStep - 1); setAddStep(addStep - 1);
clearAddError();
} }
}} }}
> >
@@ -1248,7 +1311,7 @@ export default function VaultScreen() {
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
> >
<MaterialCommunityIcons name="lock" size={18} color="#fff" /> <MaterialCommunityIcons name="lock" size={18} color="#fff" />
<Text style={styles.confirmButtonText}>Seal Treasure</Text> <Text style={styles.confirmButtonText}>{isSealing ? 'Sealing...' : 'Seal Treasure'}</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -1344,7 +1407,7 @@ export default function VaultScreen() {
<Text style={styles.actionText}>Export Cipher Pack</Text> <Text style={styles.actionText}>Export Cipher Pack</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionRow} activeOpacity={0.8}> <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> <Text style={styles.actionText}>Reset Sentinel Timer</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -1835,6 +1898,10 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: colors.nautical.lightMint, borderColor: colors.nautical.lightMint,
}, },
inputMultiline: {
minHeight: 120,
paddingTop: spacing.base,
},
encryptionNote: { encryptionNote: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -1850,6 +1917,23 @@ const styles = StyleSheet.create({
color: colors.nautical.teal, color: colors.nautical.teal,
lineHeight: typography.fontSize.sm * 1.4, 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: { modalButtons: {
flexDirection: 'row', flexDirection: 'row',
gap: spacing.md, gap: spacing.md,

View File

@@ -142,11 +142,16 @@ export const assetsService = {
body: JSON.stringify(asset), body: JSON.stringify(asset),
}); });
logApiDebug('Create Asset Response Status', response.status); const responseStatus = response.status;
logApiDebug('Create Asset Response Status', responseStatus);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); 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(); return await response.json();

View File

@@ -23,3 +23,9 @@ export {
type DeclareGualeRequest, type DeclareGualeRequest,
type DeclareGualeResponse type DeclareGualeResponse
} from './admin.service'; } from './admin.service';
export {
createVaultPayload,
createAssetPayload,
type CreateVaultPayloadResult,
type CreateAssetPayloadResult,
} from './vault.service';

View 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_encryptedhex 字符串)
*
* 使用方式:在任意页面调用 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_encryptedAES-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,
};
}

View File

@@ -3,3 +3,4 @@
*/ */
export * from './sss'; export * from './sss';
export * from './vaultAssets';

100
src/utils/vaultAssets.ts Normal file
View 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
View 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;
}