Compare commits
8 Commits
22dc3abf65
...
feature/va
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e78c84c9 | ||
|
|
8e6c621f7b | ||
|
|
7b8511f080 | ||
|
|
f6fa19d0b2 | ||
|
|
536513ab3f | ||
|
|
240a7eea8b | ||
| d64a6557a8 | |||
| 56bb72aab8 |
5
App.tsx
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
@@ -15,6 +16,10 @@ import AuthNavigator from './src/navigation/AuthNavigator';
|
|||||||
import { AuthProvider, useAuth } from './src/context/AuthContext';
|
import { AuthProvider, useAuth } from './src/context/AuthContext';
|
||||||
import { colors } from './src/theme/colors';
|
import { colors } from './src/theme/colors';
|
||||||
|
|
||||||
|
if (typeof globalThis !== 'undefined' && !globalThis.Buffer) {
|
||||||
|
globalThis.Buffer = Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading screen shown while restoring auth state
|
* Loading screen shown while restoring auth state
|
||||||
*/
|
*/
|
||||||
|
|||||||
6
app.json
@@ -19,14 +19,10 @@
|
|||||||
"bundleIdentifier": "com.sentinel.app"
|
"bundleIdentifier": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#459E9E"
|
|
||||||
},
|
|
||||||
"package": "com.sentinel.app"
|
"package": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png",
|
"favicon": "./assets/icon.png",
|
||||||
"bundler": "metro"
|
"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 |
55
package-lock.json
generated
@@ -14,6 +14,8 @@
|
|||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^6.1.18",
|
||||||
"@react-navigation/native-stack": "^6.11.0",
|
"@react-navigation/native-stack": "^6.11.0",
|
||||||
|
"bip39": "^3.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
"expo-asset": "~11.0.5",
|
"expo-asset": "~11.0.5",
|
||||||
"expo-constants": "~17.0.8",
|
"expo-constants": "~17.0.8",
|
||||||
@@ -3209,6 +3211,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -4447,6 +4461,15 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bip39": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bplist-creator": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz",
|
||||||
@@ -4533,9 +4556,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4553,7 +4576,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.3.1",
|
"base64-js": "^1.3.1",
|
||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-alloc": {
|
"node_modules/buffer-alloc": {
|
||||||
@@ -11057,6 +11080,30 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-url-without-unicode/node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": {
|
"node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^6.1.18",
|
||||||
"@react-navigation/native-stack": "^6.11.0",
|
"@react-navigation/native-stack": "^6.11.0",
|
||||||
|
"bip39": "^3.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
"expo-asset": "~11.0.5",
|
"expo-asset": "~11.0.5",
|
||||||
"expo-constants": "~17.0.8",
|
"expo-constants": "~17.0.8",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const DEBUG_MODE = true;
|
|||||||
/**
|
/**
|
||||||
* Base URL for the backend API server
|
* 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
|
* API request timeout in milliseconds
|
||||||
@@ -64,6 +64,39 @@ export const API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -112,6 +145,7 @@ export const MOCK_CONFIG = {
|
|||||||
USER: {
|
USER: {
|
||||||
id: 999,
|
id: 999,
|
||||||
username: 'MockCaptain',
|
username: 'MockCaptain',
|
||||||
|
email: 'captain@sentinel.local',
|
||||||
public_key: 'mock_public_key',
|
public_key: 'mock_public_key',
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
guale: false,
|
guale: false,
|
||||||
|
|||||||
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 { useAuth } from '../context/AuthContext';
|
||||||
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
||||||
import HeritageScreen from './HeritageScreen';
|
import HeritageScreen from './HeritageScreen';
|
||||||
import { VAULT_STORAGE_KEYS } from './SentinelScreen';
|
import { getVaultStorageKeys } from '../config';
|
||||||
|
|
||||||
// Mock heirs data
|
// Mock heirs data
|
||||||
const initialHeirs: Heir[] = [
|
const initialHeirs: Heir[] = [
|
||||||
@@ -248,6 +248,7 @@ export default function MeScreen() {
|
|||||||
});
|
});
|
||||||
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
||||||
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
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 [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
||||||
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
||||||
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
||||||
@@ -308,17 +309,30 @@ export default function MeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResetVault = async () => {
|
const handleResetVault = async () => {
|
||||||
|
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||||
|
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.multiRemove([
|
await AsyncStorage.multiRemove([
|
||||||
VAULT_STORAGE_KEYS.INITIALIZED,
|
vaultKeys.INITIALIZED,
|
||||||
VAULT_STORAGE_KEYS.SHARE_DEVICE,
|
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) {
|
} 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -760,7 +774,7 @@ export default function MeScreen() {
|
|||||||
visible={showSanctumModal}
|
visible={showSanctumModal}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
transparent
|
transparent
|
||||||
onRequestClose={() => setShowSanctumModal(false)}
|
onRequestClose={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<View style={styles.spiritOverlay}>
|
<View style={styles.spiritOverlay}>
|
||||||
<View style={styles.spiritModal}>
|
<View style={styles.spiritModal}>
|
||||||
@@ -908,7 +922,31 @@ export default function MeScreen() {
|
|||||||
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
|
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
|
||||||
<Text style={styles.devResetText}>Reset Vault State</Text>
|
<Text style={styles.devResetText}>Reset Vault State</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -916,7 +954,7 @@ export default function MeScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Save</Text>
|
<Text style={styles.confirmPulseText}>Save</Text>
|
||||||
@@ -924,7 +962,7 @@ export default function MeScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Close</Text>
|
<Text style={styles.confirmPulseText}>Close</Text>
|
||||||
@@ -1910,6 +1948,34 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.nautical.coral,
|
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: {
|
confirmPulseButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,47 +8,12 @@ import {
|
|||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
Animated,
|
Animated,
|
||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Share,
|
|
||||||
Alert,
|
|
||||||
Linking,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
||||||
import { captureRef } from 'react-native-view-shot';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||||
import { SystemStatus, KillSwitchLog } from '../types';
|
import { SystemStatus, KillSwitchLog } from '../types';
|
||||||
import VaultScreen from './VaultScreen';
|
import VaultScreen from './VaultScreen';
|
||||||
import BiometricModal from '../components/common/BiometricModal';
|
|
||||||
import {
|
|
||||||
SSSShare,
|
|
||||||
mnemonicToEntropy,
|
|
||||||
splitSecret,
|
|
||||||
formatShareCompact,
|
|
||||||
serializeShare,
|
|
||||||
verifyShares,
|
|
||||||
} from '../utils/sss';
|
|
||||||
|
|
||||||
// Vault storage keys (for testing: clear these to simulate first-open)
|
|
||||||
export const VAULT_STORAGE_KEYS = {
|
|
||||||
INITIALIZED: 'sentinel_vault_initialized',
|
|
||||||
SHARE_DEVICE: 'sentinel_share_device',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Nautical-themed mnemonic word list (unique words only)
|
|
||||||
const MNEMONIC_WORDS = [
|
|
||||||
'anchor', 'harbor', 'compass', 'lighthouse', 'current', 'ocean', 'tide', 'voyage',
|
|
||||||
'keel', 'stern', 'bow', 'mast', 'sail', 'port', 'starboard', 'reef',
|
|
||||||
'signal', 'beacon', 'chart', 'helm', 'gale', 'calm', 'cove', 'isle',
|
|
||||||
'horizon', 'sextant', 'sound', 'drift', 'wake', 'mariner', 'pilot', 'fathom',
|
|
||||||
'buoy', 'lantern', 'harpoon', 'lagoon', 'bay', 'strait', 'riptide', 'foam',
|
|
||||||
'coral', 'pearl', 'trident', 'ebb', 'flow', 'vault', 'cipher', 'shroud',
|
|
||||||
'salt', 'wave', 'grotto', 'storm', 'north', 'south', 'east', 'west',
|
|
||||||
'ember', 'cabin', 'ledger', 'torch', 'sanctum', 'oath', 'depths', 'captain',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Animation timing constants
|
// Animation timing constants
|
||||||
const ANIMATION_DURATION = {
|
const ANIMATION_DURATION = {
|
||||||
@@ -58,56 +23,13 @@ const ANIMATION_DURATION = {
|
|||||||
heartbeatPress: 150,
|
heartbeatPress: 150,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const generateMnemonic = (wordCount = 12) => {
|
|
||||||
const words: string[] = [];
|
|
||||||
for (let i = 0; i < wordCount; i += 1) {
|
|
||||||
const index = Math.floor(Math.random() * MNEMONIC_WORDS.length);
|
|
||||||
words.push(MNEMONIC_WORDS[index]);
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SSS shares from mnemonic words
|
|
||||||
* Uses Shamir's Secret Sharing (3,2) threshold scheme
|
|
||||||
*/
|
|
||||||
const generateSSSShares = (words: string[]): SSSShare[] => {
|
|
||||||
try {
|
|
||||||
// Convert mnemonic to entropy (big integer)
|
|
||||||
const entropy = mnemonicToEntropy(words, MNEMONIC_WORDS);
|
|
||||||
|
|
||||||
// Split entropy into 3 shares using SSS
|
|
||||||
const shares = splitSecret(entropy);
|
|
||||||
|
|
||||||
// Verify shares can recover the original (optional, for debugging)
|
|
||||||
if (__DEV__) {
|
|
||||||
const isValid = verifyShares(shares, entropy);
|
|
||||||
if (!isValid) {
|
|
||||||
console.warn('SSS verification failed!');
|
|
||||||
} else {
|
|
||||||
console.log('SSS shares verified successfully');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return shares;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate SSS shares:', error);
|
|
||||||
// Fallback: return empty shares (should not happen in production)
|
|
||||||
return [
|
|
||||||
{ x: 1, y: BigInt(0), label: 'device' },
|
|
||||||
{ x: 2, y: BigInt(0), label: 'cloud' },
|
|
||||||
{ x: 3, y: BigInt(0), label: 'heir' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Icon names type for type safety
|
// Icon names type for type safety
|
||||||
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
|
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
|
||||||
|
|
||||||
// Status configuration with nautical theme
|
// Status configuration with nautical theme
|
||||||
const statusConfig: Record<SystemStatus, {
|
const statusConfig: Record<SystemStatus, {
|
||||||
color: string;
|
color: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: StatusIconName;
|
icon: StatusIconName;
|
||||||
description: string;
|
description: string;
|
||||||
gradientColors: [string, string];
|
gradientColors: [string, string];
|
||||||
@@ -137,28 +59,14 @@ const statusConfig: Record<SystemStatus, {
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const initialLogs: KillSwitchLog[] = [
|
const initialLogs: KillSwitchLog[] = [
|
||||||
{
|
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30:00') },
|
||||||
id: '1',
|
{ id: '2', action: 'SUBSCRIPTION_VERIFIED', timestamp: new Date('2024-01-17T00:00:00') },
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
{ id: '3', action: 'JOURNAL_ACTIVITY', timestamp: new Date('2024-01-16T15:42:00') },
|
||||||
timestamp: new Date('2024-01-18T09:30:00'),
|
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
action: 'SUBSCRIPTION_VERIFIED',
|
|
||||||
timestamp: new Date('2024-01-17T00:00:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
action: 'JOURNAL_ACTIVITY',
|
|
||||||
timestamp: new Date('2024-01-16T15:42:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
|
||||||
timestamp: new Date('2024-01-15T11:20:00'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { VAULT_STORAGE_KEYS } from '../config';
|
||||||
|
|
||||||
export default function SentinelScreen() {
|
export default function SentinelScreen() {
|
||||||
const [status, setStatus] = useState<SystemStatus>('normal');
|
const [status, setStatus] = useState<SystemStatus>('normal');
|
||||||
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
||||||
@@ -168,26 +76,8 @@ export default function SentinelScreen() {
|
|||||||
const [glowAnim] = useState(new Animated.Value(0.5));
|
const [glowAnim] = useState(new Animated.Value(0.5));
|
||||||
const [rotateAnim] = useState(new Animated.Value(0));
|
const [rotateAnim] = useState(new Animated.Value(0));
|
||||||
const [showVault, setShowVault] = useState(false);
|
const [showVault, setShowVault] = useState(false);
|
||||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
|
||||||
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
|
|
||||||
const [sssShares, setSssShares] = useState<SSSShare[]>([]);
|
|
||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
|
||||||
const [emailAddress, setEmailAddress] = useState('');
|
|
||||||
const [emailRecipientType, setEmailRecipientType] = useState<'self' | 'heir'>('self');
|
|
||||||
const [isCapturing, setIsCapturing] = useState(false);
|
|
||||||
const [hasVaultInitialized, setHasVaultInitialized] = useState<boolean | null>(null);
|
|
||||||
const [showSetupBiometric, setShowSetupBiometric] = useState(false);
|
|
||||||
const mnemonicRef = useRef<View>(null);
|
|
||||||
|
|
||||||
// Load vault init status on mount (1.1)
|
|
||||||
useEffect(() => {
|
|
||||||
AsyncStorage.getItem(VAULT_STORAGE_KEYS.INITIALIZED)
|
|
||||||
.then((val) => setHasVaultInitialized(val === 'true'))
|
|
||||||
.catch(() => setHasVaultInitialized(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Pulse animation
|
|
||||||
const pulseAnimation = Animated.loop(
|
const pulseAnimation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
@@ -204,7 +94,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
pulseAnimation.start();
|
pulseAnimation.start();
|
||||||
|
|
||||||
// Glow animation
|
|
||||||
const glowAnimation = Animated.loop(
|
const glowAnimation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(glowAnim, {
|
Animated.timing(glowAnim, {
|
||||||
@@ -221,7 +110,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
glowAnimation.start();
|
glowAnimation.start();
|
||||||
|
|
||||||
// Slow rotate for ship wheel
|
|
||||||
const rotateAnimation = Animated.loop(
|
const rotateAnimation = Animated.loop(
|
||||||
Animated.timing(rotateAnim, {
|
Animated.timing(rotateAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
@@ -231,7 +119,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
rotateAnimation.start();
|
rotateAnimation.start();
|
||||||
|
|
||||||
// Cleanup animations on unmount to prevent memory leaks
|
|
||||||
return () => {
|
return () => {
|
||||||
pulseAnimation.stop();
|
pulseAnimation.stop();
|
||||||
glowAnimation.stop();
|
glowAnimation.stop();
|
||||||
@@ -239,109 +126,9 @@ export default function SentinelScreen() {
|
|||||||
};
|
};
|
||||||
}, [pulseAnim, glowAnim, rotateAnim]);
|
}, [pulseAnim, glowAnim, rotateAnim]);
|
||||||
|
|
||||||
const startFirstTimeSetup = () => {
|
const openVault = () => setShowVault(true);
|
||||||
const words = generateMnemonic();
|
|
||||||
const shares = generateSSSShares(words);
|
|
||||||
setMnemonicWords(words);
|
|
||||||
setSssShares(shares);
|
|
||||||
setShowMnemonic(true);
|
|
||||||
setShowVault(false);
|
|
||||||
setShowEmailForm(false);
|
|
||||||
setEmailAddress('');
|
|
||||||
setEmailRecipientType('self');
|
|
||||||
|
|
||||||
if (shares[0]) {
|
|
||||||
AsyncStorage.setItem(VAULT_STORAGE_KEYS.SHARE_DEVICE, serializeShare(shares[0])).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeSetupAndEnterVault = () => {
|
|
||||||
setShowMnemonic(false);
|
|
||||||
setShowEmailForm(false);
|
|
||||||
setEmailAddress('');
|
|
||||||
setShowSetupBiometric(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetupBiometricSuccess = () => {
|
|
||||||
setShowSetupBiometric(false);
|
|
||||||
AsyncStorage.setItem(VAULT_STORAGE_KEYS.INITIALIZED, 'true').catch(() => {});
|
|
||||||
setHasVaultInitialized(true);
|
|
||||||
setShowVault(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetupBiometricSkip = () => {
|
|
||||||
setShowSetupBiometric(false);
|
|
||||||
AsyncStorage.setItem(VAULT_STORAGE_KEYS.INITIALIZED, 'true').catch(() => {});
|
|
||||||
setHasVaultInitialized(true);
|
|
||||||
setShowVault(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenVault = () => {
|
|
||||||
if (hasVaultInitialized === true) {
|
|
||||||
setShowVault(true);
|
|
||||||
setShowMnemonic(false);
|
|
||||||
} else {
|
|
||||||
startFirstTimeSetup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScreenshot = async () => {
|
|
||||||
try {
|
|
||||||
setIsCapturing(true);
|
|
||||||
const uri = await captureRef(mnemonicRef, {
|
|
||||||
format: 'png',
|
|
||||||
quality: 1,
|
|
||||||
result: 'tmpfile',
|
|
||||||
});
|
|
||||||
await Share.share({
|
|
||||||
url: uri,
|
|
||||||
message: 'Sentinel key backup',
|
|
||||||
});
|
|
||||||
completeSetupAndEnterVault();
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Screenshot failed', 'Please try again or use email backup.');
|
|
||||||
} finally {
|
|
||||||
setIsCapturing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailBackup = () => {
|
|
||||||
setShowEmailForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCompleteBackupLocal = () => {
|
|
||||||
completeSetupAndEnterVault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
|
||||||
const trimmed = emailAddress.trim();
|
|
||||||
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
||||||
Alert.alert('Invalid email', 'Please enter a valid email address.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = encodeURIComponent(
|
|
||||||
emailRecipientType === 'heir'
|
|
||||||
? 'Sentinel Vault - Share C (Heir)'
|
|
||||||
: 'Sentinel Vault Recovery Key'
|
|
||||||
);
|
|
||||||
const body = encodeURIComponent(
|
|
||||||
emailRecipientType === 'heir'
|
|
||||||
? `Share C (for heir, 2-of-3 required to recover):\n${sssShares[2] ? serializeShare(sssShares[2]) : ''}\n\nKeep this secure. Combined with Share B from Sentinel cloud, this can restore the vault.`
|
|
||||||
: `Your 12-word mnemonic (backup for yourself):\n${mnemonicWords.join(' ')}`
|
|
||||||
);
|
|
||||||
const mailtoUrl = `mailto:${trimmed}?subject=${subject}&body=${body}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Linking.openURL(mailtoUrl);
|
|
||||||
completeSetupAndEnterVault();
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Email failed', 'Unable to open email client.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHeartbeat = () => {
|
const handleHeartbeat = () => {
|
||||||
// Animate pulse
|
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1.15,
|
toValue: 1.15,
|
||||||
@@ -355,43 +142,34 @@ export default function SentinelScreen() {
|
|||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
// Add new log using functional update to avoid stale closure
|
|
||||||
const newLog: KillSwitchLog = {
|
const newLog: KillSwitchLog = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
action: 'HEARTBEAT_CONFIRMED',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||||
|
|
||||||
// Reset status if warning
|
|
||||||
if (status === 'warning') {
|
if (status === 'warning') {
|
||||||
setStatus('normal');
|
setStatus('normal');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (date: Date) => {
|
const formatDateTime = (date: Date) =>
|
||||||
return date.toLocaleString('en-US', {
|
date.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimeAgo = (date: Date) => {
|
const formatTimeAgo = (date: Date) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
|
||||||
if (hours > 24) {
|
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days} days ago`;
|
|
||||||
}
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ago`;
|
|
||||||
}
|
|
||||||
return `${minutes}m ago`;
|
return `${minutes}m ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -408,7 +186,7 @@ export default function SentinelScreen() {
|
|||||||
style={styles.gradient}
|
style={styles.gradient}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
@@ -424,14 +202,14 @@ export default function SentinelScreen() {
|
|||||||
|
|
||||||
{/* Status Display */}
|
{/* Status Display */}
|
||||||
<View style={styles.statusContainer}>
|
<View style={styles.statusContainer}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.statusCircleOuter,
|
styles.statusCircleOuter,
|
||||||
{
|
{
|
||||||
transform: [{ scale: pulseAnim }],
|
transform: [{ scale: pulseAnim }],
|
||||||
opacity: glowAnim,
|
opacity: glowAnim,
|
||||||
backgroundColor: `${currentStatus.color}20`,
|
backgroundColor: `${currentStatus.color}20`,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||||
@@ -453,10 +231,10 @@ export default function SentinelScreen() {
|
|||||||
{/* Ship Wheel Watermark */}
|
{/* Ship Wheel Watermark */}
|
||||||
<View style={styles.wheelWatermark}>
|
<View style={styles.wheelWatermark}>
|
||||||
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="ship-wheel"
|
name="ship-wheel"
|
||||||
size={200}
|
size={200}
|
||||||
color={colors.sentinel.primary}
|
color={colors.sentinel.primary}
|
||||||
style={{ opacity: 0.03 }}
|
style={{ opacity: 0.03 }}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -469,24 +247,16 @@ export default function SentinelScreen() {
|
|||||||
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
|
||||||
{formatTimeAgo(lastSubscriptionCheck)}
|
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastSubscriptionCheck)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metricCard}>
|
<View style={styles.metricCard}>
|
||||||
<View style={styles.metricIconContainer}>
|
<View style={styles.metricIconContainer}>
|
||||||
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
|
||||||
{formatTimeAgo(lastFlowActivity)}
|
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastFlowActivity)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -503,7 +273,7 @@ export default function SentinelScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.vaultAccessButton}
|
style={styles.vaultAccessButton}
|
||||||
onPress={handleOpenVault}
|
onPress={openVault}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Open Shadow Vault"
|
accessibilityLabel="Open Shadow Vault"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -547,9 +317,7 @@ export default function SentinelScreen() {
|
|||||||
<View style={styles.logDot} />
|
<View style={styles.logDot} />
|
||||||
<View style={styles.logContent}>
|
<View style={styles.logContent}>
|
||||||
<Text style={styles.logAction}>{log.action}</Text>
|
<Text style={styles.logAction}>{log.action}</Text>
|
||||||
<Text style={styles.logTime}>
|
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
|
||||||
{formatDateTime(log.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -565,7 +333,7 @@ export default function SentinelScreen() {
|
|||||||
onRequestClose={() => setShowVault(false)}
|
onRequestClose={() => setShowVault(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.vaultModalContainer}>
|
<View style={styles.vaultModalContainer}>
|
||||||
<VaultScreen />
|
{showVault ? <VaultScreen /> : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.vaultCloseButton}
|
style={styles.vaultCloseButton}
|
||||||
onPress={() => setShowVault(false)}
|
onPress={() => setShowVault(false)}
|
||||||
@@ -577,184 +345,20 @@ export default function SentinelScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Mnemonic Modal */}
|
|
||||||
<Modal
|
|
||||||
visible={showMnemonic}
|
|
||||||
animationType="fade"
|
|
||||||
transparent
|
|
||||||
onRequestClose={() => setShowMnemonic(false)}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.mnemonicOverlay}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.mnemonicScroll}
|
|
||||||
contentContainerStyle={styles.mnemonicScrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<View ref={mnemonicRef} collapsable={false}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
|
|
||||||
style={styles.mnemonicCard}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.mnemonicClose}
|
|
||||||
onPress={() => setShowMnemonic(false)}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Close mnemonic modal"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={18} color={colors.sentinel.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.mnemonicHeader}>
|
|
||||||
<MaterialCommunityIcons name="key-variant" size={22} color={colors.sentinel.primary} />
|
|
||||||
<Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.mnemonicSubtitle}>
|
|
||||||
Your seed is protected by SSS (3,2) threshold encryption. Any 2 shares can restore your vault.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.mnemonicBlock}>
|
|
||||||
<Text style={styles.mnemonicBlockText}>
|
|
||||||
{mnemonicWords.join(' ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partGrid}>
|
|
||||||
<View style={[styles.partCard, styles.partCardStored]}>
|
|
||||||
<Text style={styles.partLabel}>SHARE A • DEVICE</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[0] ? formatShareCompact(sssShares[0]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>Stored on this device</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partCard}>
|
|
||||||
<Text style={styles.partLabel}>SHARE B • CLOUD</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[1] ? formatShareCompact(sssShares[1]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>To be synced to Sentinel</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partCard}>
|
|
||||||
<Text style={styles.partLabel}>SHARE C • HEIR</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[2] ? formatShareCompact(sssShares[2]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>For your heir (2-of-3 required)</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.mnemonicPrimaryButton, isCapturing && styles.mnemonicButtonDisabled]}
|
|
||||||
onPress={handleScreenshot}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
disabled={isCapturing}
|
|
||||||
accessibilityLabel="Take screenshot backup of mnemonic"
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ disabled: isCapturing }}
|
|
||||||
>
|
|
||||||
<Text style={styles.mnemonicPrimaryText}>
|
|
||||||
{isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.mnemonicSecondaryButton}
|
|
||||||
onPress={handleEmailBackup}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Send backup via email"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{showEmailForm ? (
|
|
||||||
<View style={styles.emailForm}>
|
|
||||||
<View style={styles.emailTypeRow}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.emailTypeButton, emailRecipientType === 'self' && styles.emailTypeButtonActive]}
|
|
||||||
onPress={() => setEmailRecipientType('self')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.emailTypeText, emailRecipientType === 'self' && styles.emailTypeTextActive]}>
|
|
||||||
To Myself
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.emailTypeButton, emailRecipientType === 'heir' && styles.emailTypeButtonActive]}
|
|
||||||
onPress={() => setEmailRecipientType('heir')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.emailTypeText, emailRecipientType === 'heir' && styles.emailTypeTextActive]}>
|
|
||||||
To Heir
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<TextInput
|
|
||||||
style={styles.emailInput}
|
|
||||||
value={emailAddress}
|
|
||||||
onChangeText={setEmailAddress}
|
|
||||||
placeholder={emailRecipientType === 'heir' ? 'heir@email.com' : 'you@email.com'}
|
|
||||||
placeholderTextColor={colors.sentinel.textSecondary}
|
|
||||||
keyboardType="email-address"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.emailSendButton}
|
|
||||||
onPress={handleSendEmail}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Send backup email"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.emailSendText}>SEND EMAIL</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.mnemonicTertiaryButton}
|
|
||||||
onPress={handleCompleteBackupLocal}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="I've completed backup locally"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.mnemonicTertiaryText}>COMPLETE BACKUP (LOCAL)</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Biometric setup prompt after first-time backup (1.4) */}
|
|
||||||
<BiometricModal
|
|
||||||
visible={showSetupBiometric}
|
|
||||||
onSuccess={handleSetupBiometricSuccess}
|
|
||||||
onCancel={handleSetupBiometricSkip}
|
|
||||||
title="Quick Unlock"
|
|
||||||
message="Enable Face ID / Touch ID for faster access next time?"
|
|
||||||
isDark
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: { flex: 1 },
|
||||||
flex: 1,
|
gradient: { flex: 1 },
|
||||||
},
|
safeArea: { flex: 1 },
|
||||||
gradient: {
|
scrollView: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingBottom: 120,
|
paddingBottom: 120,
|
||||||
},
|
},
|
||||||
header: {
|
header: { marginBottom: spacing.xl },
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
headerTitleRow: {
|
headerTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -860,9 +464,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.xl,
|
marginBottom: spacing.xl,
|
||||||
...shadows.medium,
|
...shadows.medium,
|
||||||
},
|
},
|
||||||
heartbeatGradient: {
|
heartbeatGradient: { padding: spacing.lg },
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
heartbeatContent: {
|
heartbeatContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -916,9 +518,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
logContent: {
|
logContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
logAction: {
|
logAction: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.sentinel.text,
|
color: colors.sentinel.text,
|
||||||
@@ -931,7 +531,6 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.sentinel.textSecondary,
|
color: colors.sentinel.textSecondary,
|
||||||
fontFamily: typography.fontFamily.mono,
|
fontFamily: typography.fontFamily.mono,
|
||||||
},
|
},
|
||||||
// Shadow Vault Access Card
|
|
||||||
vaultAccessCard: {
|
vaultAccessCard: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -951,9 +550,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
vaultAccessContent: {
|
vaultAccessContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
vaultAccessTitle: {
|
vaultAccessTitle: {
|
||||||
fontSize: typography.fontSize.base,
|
fontSize: typography.fontSize.base,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -975,7 +572,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
},
|
},
|
||||||
// Vault Modal
|
|
||||||
vaultModalContainer: {
|
vaultModalContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.vault.background,
|
backgroundColor: colors.vault.background,
|
||||||
@@ -991,196 +587,4 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
mnemonicOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(11, 20, 24, 0.72)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
mnemonicScroll: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mnemonicScrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
mnemonicCard: {
|
|
||||||
borderRadius: borderRadius.xl,
|
|
||||||
padding: spacing.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
...shadows.glow,
|
|
||||||
},
|
|
||||||
mnemonicHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
mnemonicClose: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: spacing.sm,
|
|
||||||
right: spacing.sm,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(26, 58, 74, 0.35)',
|
|
||||||
},
|
|
||||||
mnemonicTitle: {
|
|
||||||
fontSize: typography.fontSize.lg,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
mnemonicSubtitle: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
mnemonicBlock: {
|
|
||||||
backgroundColor: colors.sentinel.cardBackground,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
partGrid: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
partCard: {
|
|
||||||
backgroundColor: colors.sentinel.cardBackground,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
},
|
|
||||||
partCardStored: {
|
|
||||||
borderColor: colors.sentinel.primary,
|
|
||||||
},
|
|
||||||
partLabel: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
marginBottom: 4,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
partValue: {
|
|
||||||
fontSize: typography.fontSize.md,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontFamily: typography.fontFamily.mono,
|
|
||||||
fontWeight: '700',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
partHint: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
},
|
|
||||||
mnemonicBlockText: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontFamily: typography.fontFamily.mono,
|
|
||||||
fontWeight: '600',
|
|
||||||
lineHeight: 22,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
mnemonicPrimaryButton: {
|
|
||||||
backgroundColor: colors.sentinel.primary,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
mnemonicButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
mnemonicPrimaryText: {
|
|
||||||
color: colors.nautical.cream,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
mnemonicSecondaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
},
|
|
||||||
mnemonicSecondaryText: {
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
mnemonicTertiaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
},
|
|
||||||
mnemonicTertiaryText: {
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
},
|
|
||||||
emailTypeRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
emailTypeButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
},
|
|
||||||
emailTypeButtonActive: {
|
|
||||||
borderColor: colors.sentinel.primary,
|
|
||||||
backgroundColor: `${colors.sentinel.primary}15`,
|
|
||||||
},
|
|
||||||
emailTypeText: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
emailTypeTextActive: {
|
|
||||||
color: colors.sentinel.primary,
|
|
||||||
},
|
|
||||||
emailForm: {
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
|
||||||
emailInput: {
|
|
||||||
height: 44,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
emailSendButton: {
|
|
||||||
backgroundColor: colors.nautical.teal,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emailSendText: {
|
|
||||||
color: colors.nautical.cream,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ export interface ProtocolInfo {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
guale: boolean;
|
guale: boolean;
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './sss';
|
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;
|
||||||
|
}
|
||||||