3 Commits

Author SHA1 Message Date
Ada
218b2e8b29 add vault first time control 2026-01-30 21:26:38 -08:00
Ada
fb1377eb4b Generate SSS shares from mnemonic words 2026-01-30 16:31:09 -08:00
Ada
c07f1f20d5 fix the warning 2026-01-30 15:22:13 -08:00
4 changed files with 605 additions and 57 deletions

View File

@@ -14,9 +14,11 @@ import {
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 { colors, typography, spacing, borderRadius, shadows } from '../theme/colors'; import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
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';
// Mock heirs data // Mock heirs data
const initialHeirs: Heir[] = [ const initialHeirs: Heir[] = [
@@ -308,6 +310,18 @@ export default function MeScreen() {
); );
}; };
const handleResetVault = async () => {
try {
await AsyncStorage.multiRemove([
VAULT_STORAGE_KEYS.INITIALIZED,
VAULT_STORAGE_KEYS.SHARE_DEVICE,
]);
Alert.alert('Done', 'Vault state reset. Go to Sentinel → Open Shadow Vault to see first-time flow.');
} catch (e) {
Alert.alert('Error', 'Failed to reset vault state.');
}
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<LinearGradient <LinearGradient
@@ -885,6 +899,21 @@ export default function MeScreen() {
<Text style={styles.sanctumValue}>View</Text> <Text style={styles.sanctumValue}>View</Text>
</View> </View>
</View> </View>
{__DEV__ && (
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>DEV ONLY</Text>
<TouchableOpacity
style={styles.devResetButton}
onPress={handleResetVault}
activeOpacity={0.85}
>
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
<Text style={styles.devResetText}>Reset Vault State</Text>
</TouchableOpacity>
<Text style={styles.sanctumHint}>Clear hasVaultInitialized & Share A. Test first-open flow.</Text>
</View>
)}
</ScrollView> </ScrollView>
<View style={styles.tideModalButtons}> <View style={styles.tideModalButtons}>
<TouchableOpacity <TouchableOpacity
@@ -1814,6 +1843,23 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm, fontSize: typography.fontSize.sm,
color: colors.me.textSecondary, color: colors.me.textSecondary,
}, },
devResetButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: `${colors.nautical.coral}15`,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: `${colors.nautical.coral}40`,
alignSelf: 'flex-start',
},
devResetText: {
fontSize: typography.fontSize.sm,
color: colors.nautical.coral,
fontWeight: '600',
},
sanctumAlert: { sanctumAlert: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -22,7 +22,23 @@ 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 = [ const MNEMONIC_WORDS = [
'anchor', 'harbor', 'compass', 'lighthouse', 'current', 'ocean', 'tide', 'voyage', 'anchor', 'harbor', 'compass', 'lighthouse', 'current', 'ocean', 'tide', 'voyage',
'keel', 'stern', 'bow', 'mast', 'sail', 'port', 'starboard', 'reef', 'keel', 'stern', 'bow', 'mast', 'sail', 'port', 'starboard', 'reef',
@@ -30,9 +46,17 @@ const MNEMONIC_WORDS = [
'horizon', 'sextant', 'sound', 'drift', 'wake', 'mariner', 'pilot', 'fathom', 'horizon', 'sextant', 'sound', 'drift', 'wake', 'mariner', 'pilot', 'fathom',
'buoy', 'lantern', 'harpoon', 'lagoon', 'bay', 'strait', 'riptide', 'foam', 'buoy', 'lantern', 'harpoon', 'lagoon', 'bay', 'strait', 'riptide', 'foam',
'coral', 'pearl', 'trident', 'ebb', 'flow', 'vault', 'cipher', 'shroud', 'coral', 'pearl', 'trident', 'ebb', 'flow', 'vault', 'cipher', 'shroud',
'salt', 'wave', 'grotto', 'lagoon', 'storm', 'north', 'south', 'east', 'salt', 'wave', 'grotto', 'storm', 'north', 'south', 'east', 'west',
'west', 'ember', 'cabin', 'signal', 'ledger', 'torch', 'sanctum', 'oath', 'ember', 'cabin', 'ledger', 'torch', 'sanctum', 'oath', 'depths', 'captain',
]; ] as const;
// Animation timing constants
const ANIMATION_DURATION = {
pulse: 1200,
glow: 1500,
rotate: 30000,
heartbeatPress: 150,
} as const;
const generateMnemonic = (wordCount = 12) => { const generateMnemonic = (wordCount = 12) => {
const words: string[] = []; const words: string[] = [];
@@ -43,17 +67,48 @@ const generateMnemonic = (wordCount = 12) => {
return words; return words;
}; };
const splitMnemonic = (words: string[]) => [ /**
words.slice(0, 4), * Generate SSS shares from mnemonic words
words.slice(4, 8), * Uses Shamir's Secret Sharing (3,2) threshold scheme
words.slice(8, 12), */
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
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: string; icon: StatusIconName;
description: string; description: string;
gradientColors: [string, string]; gradientColors: [string, string];
}> = { }> = {
@@ -115,67 +170,119 @@ export default function SentinelScreen() {
const [showVault, setShowVault] = useState(false); const [showVault, setShowVault] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false); const [showMnemonic, setShowMnemonic] = useState(false);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]); const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]); const [sssShares, setSssShares] = useState<SSSShare[]>([]);
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [emailAddress, setEmailAddress] = useState(''); const [emailAddress, setEmailAddress] = useState('');
const [emailRecipientType, setEmailRecipientType] = useState<'self' | 'heir'>('self');
const [isCapturing, setIsCapturing] = useState(false); const [isCapturing, setIsCapturing] = useState(false);
const [hasVaultInitialized, setHasVaultInitialized] = useState<boolean | null>(null);
const [showSetupBiometric, setShowSetupBiometric] = useState(false);
const mnemonicRef = useRef<View>(null); 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 // Pulse animation
Animated.loop( const pulseAnimation = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1.06, toValue: 1.06,
duration: 1200, duration: ANIMATION_DURATION.pulse,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1, toValue: 1,
duration: 1200, duration: ANIMATION_DURATION.pulse,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ])
).start(); );
pulseAnimation.start();
// Glow animation // Glow animation
Animated.loop( const glowAnimation = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(glowAnim, { Animated.timing(glowAnim, {
toValue: 1, toValue: 1,
duration: 1500, duration: ANIMATION_DURATION.glow,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(glowAnim, { Animated.timing(glowAnim, {
toValue: 0.5, toValue: 0.5,
duration: 1500, duration: ANIMATION_DURATION.glow,
useNativeDriver: true, useNativeDriver: true,
}), }),
]) ])
).start(); );
glowAnimation.start();
// Slow rotate for ship wheel // Slow rotate for ship wheel
Animated.loop( const rotateAnimation = Animated.loop(
Animated.timing(rotateAnim, { Animated.timing(rotateAnim, {
toValue: 1, toValue: 1,
duration: 30000, duration: ANIMATION_DURATION.rotate,
useNativeDriver: true, useNativeDriver: true,
}) })
).start(); );
}, []); rotateAnimation.start();
const openVaultWithMnemonic = () => { // Cleanup animations on unmount to prevent memory leaks
return () => {
pulseAnimation.stop();
glowAnimation.stop();
rotateAnimation.stop();
};
}, [pulseAnim, glowAnim, rotateAnim]);
const startFirstTimeSetup = () => {
const words = generateMnemonic(); const words = generateMnemonic();
const parts = splitMnemonic(words); const shares = generateSSSShares(words);
setMnemonicWords(words); setMnemonicWords(words);
setMnemonicParts(parts); setSssShares(shares);
setShowMnemonic(true); setShowMnemonic(true);
setShowVault(false); setShowVault(false);
setShowEmailForm(false); setShowEmailForm(false);
setEmailAddress(''); setEmailAddress('');
AsyncStorage.setItem('sentinel_mnemonic_part_local', parts[0].join(' ')).catch(() => { setEmailRecipientType('self');
// Best-effort local store; UI remains available
}); 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 () => { const handleScreenshot = async () => {
@@ -190,8 +297,7 @@ export default function SentinelScreen() {
url: uri, url: uri,
message: 'Sentinel key backup', message: 'Sentinel key backup',
}); });
setShowMnemonic(false); completeSetupAndEnterVault();
setShowVault(true);
} catch (error) { } catch (error) {
Alert.alert('Screenshot failed', 'Please try again or use email backup.'); Alert.alert('Screenshot failed', 'Please try again or use email backup.');
} finally { } finally {
@@ -203,6 +309,10 @@ export default function SentinelScreen() {
setShowEmailForm(true); setShowEmailForm(true);
}; };
const handleCompleteBackupLocal = () => {
completeSetupAndEnterVault();
};
const handleSendEmail = async () => { const handleSendEmail = async () => {
const trimmed = emailAddress.trim(); const trimmed = emailAddress.trim();
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
@@ -210,16 +320,21 @@ export default function SentinelScreen() {
return; return;
} }
const subject = encodeURIComponent('Sentinel Vault Recovery Key'); const subject = encodeURIComponent(
const body = encodeURIComponent(`Your 12-word mnemonic:\n${mnemonicWords.join(' ')}`); 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}`; const mailtoUrl = `mailto:${trimmed}?subject=${subject}&body=${body}`;
try { try {
await Linking.openURL(mailtoUrl); await Linking.openURL(mailtoUrl);
setShowMnemonic(false); completeSetupAndEnterVault();
setShowEmailForm(false);
setEmailAddress('');
setShowVault(true);
} catch (error) { } catch (error) {
Alert.alert('Email failed', 'Unable to open email client.'); Alert.alert('Email failed', 'Unable to open email client.');
} }
@@ -230,23 +345,23 @@ export default function SentinelScreen() {
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1.15, toValue: 1.15,
duration: 150, duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {
toValue: 1, toValue: 1,
duration: 150, duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true, useNativeDriver: true,
}), }),
]).start(); ]).start();
// Add new log // 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([newLog, ...logs]); setLogs((prevLogs) => [newLog, ...prevLogs]);
// Reset status if warning // Reset status if warning
if (status === 'warning') { if (status === 'warning') {
@@ -324,7 +439,7 @@ export default function SentinelScreen() {
colors={currentStatus.gradientColors} colors={currentStatus.gradientColors}
style={styles.statusCircle} style={styles.statusCircle}
> >
<Ionicons name={currentStatus.icon as any} size={56} color="#fff" /> <Ionicons name={currentStatus.icon} size={56} color="#fff" />
</LinearGradient> </LinearGradient>
</Animated.View> </Animated.View>
<Text style={[styles.statusLabel, { color: currentStatus.color }]}> <Text style={[styles.statusLabel, { color: currentStatus.color }]}>
@@ -388,8 +503,10 @@ export default function SentinelScreen() {
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.vaultAccessButton} style={styles.vaultAccessButton}
onPress={openVaultWithMnemonic} onPress={handleOpenVault}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
> >
<Text style={styles.vaultAccessButtonText}>Open</Text> <Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -400,6 +517,8 @@ export default function SentinelScreen() {
style={styles.heartbeatButton} style={styles.heartbeatButton}
onPress={handleHeartbeat} onPress={handleHeartbeat}
activeOpacity={0.9} activeOpacity={0.9}
accessibilityLabel="Signal the watch - Confirm your presence"
accessibilityRole="button"
> >
<LinearGradient <LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]} colors={[colors.nautical.teal, colors.nautical.seafoam]}
@@ -451,6 +570,8 @@ export default function SentinelScreen() {
style={styles.vaultCloseButton} style={styles.vaultCloseButton}
onPress={() => setShowVault(false)} onPress={() => setShowVault(false)}
activeOpacity={0.85} activeOpacity={0.85}
accessibilityLabel="Close vault"
accessibilityRole="button"
> >
<Ionicons name="close" size={20} color={colors.nautical.cream} /> <Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity> </TouchableOpacity>
@@ -468,15 +589,23 @@ export default function SentinelScreen() {
style={styles.mnemonicOverlay} style={styles.mnemonicOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView
style={styles.mnemonicScroll}
contentContainerStyle={styles.mnemonicScrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<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}
onPress={() => setShowMnemonic(false)} onPress={() => setShowMnemonic(false)}
activeOpacity={0.85} activeOpacity={0.85}
accessibilityLabel="Close mnemonic modal"
accessibilityRole="button"
> >
<Ionicons name="close" size={18} color={colors.sentinel.textSecondary} /> <Ionicons name="close" size={18} color={colors.sentinel.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
@@ -485,7 +614,7 @@ export default function SentinelScreen() {
<Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text> <Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text>
</View> </View>
<Text style={styles.mnemonicSubtitle}> <Text style={styles.mnemonicSubtitle}>
Your mnemonic is split into 3 parts (4/4/4). Part 1 is stored locally. Your seed is protected by SSS (3,2) threshold encryption. Any 2 shares can restore your vault.
</Text> </Text>
<View style={styles.mnemonicBlock}> <View style={styles.mnemonicBlock}>
<Text style={styles.mnemonicBlockText}> <Text style={styles.mnemonicBlockText}>
@@ -494,19 +623,25 @@ export default function SentinelScreen() {
</View> </View>
<View style={styles.partGrid}> <View style={styles.partGrid}>
<View style={[styles.partCard, styles.partCardStored]}> <View style={[styles.partCard, styles.partCardStored]}>
<Text style={styles.partLabel}>PART 1 LOCAL</Text> <Text style={styles.partLabel}>SHARE A DEVICE</Text>
<Text style={styles.partValue}>{mnemonicParts[0]?.join(' ')}</Text> <Text style={styles.partValue}>
{sssShares[0] ? formatShareCompact(sssShares[0]) : '---'}
</Text>
<Text style={styles.partHint}>Stored on this device</Text> <Text style={styles.partHint}>Stored on this device</Text>
</View> </View>
<View style={styles.partCard}> <View style={styles.partCard}>
<Text style={styles.partLabel}>PART 2 CLOUD NODE</Text> <Text style={styles.partLabel}>SHARE B CLOUD</Text>
<Text style={styles.partValue}>{mnemonicParts[1]?.join(' ')}</Text> <Text style={styles.partValue}>
<Text style={styles.partHint}>To be synced</Text> {sssShares[1] ? formatShareCompact(sssShares[1]) : '---'}
</Text>
<Text style={styles.partHint}>To be synced to Sentinel</Text>
</View> </View>
<View style={styles.partCard}> <View style={styles.partCard}>
<Text style={styles.partLabel}>PART 3 HEIR</Text> <Text style={styles.partLabel}>SHARE C HEIR</Text>
<Text style={styles.partValue}>{mnemonicParts[2]?.join(' ')}</Text> <Text style={styles.partValue}>
<Text style={styles.partHint}>To be assigned</Text> {sssShares[2] ? formatShareCompact(sssShares[2]) : '---'}
</Text>
<Text style={styles.partHint}>For your heir (2-of-3 required)</Text>
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
@@ -514,6 +649,9 @@ export default function SentinelScreen() {
onPress={handleScreenshot} onPress={handleScreenshot}
activeOpacity={0.85} activeOpacity={0.85}
disabled={isCapturing} disabled={isCapturing}
accessibilityLabel="Take screenshot backup of mnemonic"
accessibilityRole="button"
accessibilityState={{ disabled: isCapturing }}
> >
<Text style={styles.mnemonicPrimaryText}> <Text style={styles.mnemonicPrimaryText}>
{isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'} {isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
@@ -523,16 +661,36 @@ export default function SentinelScreen() {
style={styles.mnemonicSecondaryButton} style={styles.mnemonicSecondaryButton}
onPress={handleEmailBackup} onPress={handleEmailBackup}
activeOpacity={0.85} activeOpacity={0.85}
accessibilityLabel="Send backup via email"
accessibilityRole="button"
> >
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text> <Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
</TouchableOpacity> </TouchableOpacity>
{showEmailForm ? ( {showEmailForm ? (
<View style={styles.emailForm}> <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 <TextInput
style={styles.emailInput} style={styles.emailInput}
value={emailAddress} value={emailAddress}
onChangeText={setEmailAddress} onChangeText={setEmailAddress}
placeholder="you@email.com" placeholder={emailRecipientType === 'heir' ? 'heir@email.com' : 'you@email.com'}
placeholderTextColor={colors.sentinel.textSecondary} placeholderTextColor={colors.sentinel.textSecondary}
keyboardType="email-address" keyboardType="email-address"
autoCapitalize="none" autoCapitalize="none"
@@ -542,14 +700,37 @@ export default function SentinelScreen() {
style={styles.emailSendButton} style={styles.emailSendButton}
onPress={handleSendEmail} onPress={handleSendEmail}
activeOpacity={0.85} activeOpacity={0.85}
accessibilityLabel="Send backup email"
accessibilityRole="button"
> >
<Text style={styles.emailSendText}>SEND EMAIL</Text> <Text style={styles.emailSendText}>SEND EMAIL</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null} ) : 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> </LinearGradient>
</View>
</ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</Modal> </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>
); );
} }
@@ -816,6 +997,13 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
padding: spacing.lg, padding: spacing.lg,
}, },
mnemonicScroll: {
flex: 1,
},
mnemonicScrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
mnemonicCard: { mnemonicCard: {
borderRadius: borderRadius.xl, borderRadius: borderRadius.xl,
padding: spacing.lg, padding: spacing.lg,
@@ -929,6 +1117,47 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
letterSpacing: typography.letterSpacing.wide, 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: { emailForm: {
marginTop: spacing.sm, marginTop: spacing.sm,
}, },

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* Utility functions for Sentinel
*/
export * from './sss';

268
src/utils/sss.ts Normal file
View File

@@ -0,0 +1,268 @@
/**
* Shamir's Secret Sharing (SSS) Implementation
*
* This implements a (3,2) threshold scheme where:
* - Secret is split into 3 shares
* - Any 2 shares can recover the original secret
*
* Correspondence with crypto_core_demo (Python):
* - sp_trust_sharding.py: split_to_shares(), recover_from_shares()
* - Same algorithm: f(x) = secret + a*x (mod P), Lagrange interpolation
* - Difference: entropy conversion. Python uses BIP-39 (mnemonic.to_entropy);
* we use custom word list index-based encoding for compatibility with
* existing MNEMONIC_WORDS. SSS split/recover logic is identical.
*/
// Use a large prime for the finite field
// We use 2^127 - 1 (a Mersenne prime) which fits well in BigInt
// This is smaller than the Python version's 2^521 - 1 but sufficient for our 128-bit entropy
const PRIME = BigInt('170141183460469231731687303715884105727'); // 2^127 - 1
/**
* Represents an SSS share as a coordinate point (x, y)
*/
export interface SSSShare {
x: number;
y: bigint;
label: string; // 'device' | 'cloud' | 'heir'
}
/**
* Generate a cryptographically secure random BigInt in range [0, max)
*/
function secureRandomBigInt(max: bigint): bigint {
// Get the number of bytes needed
const byteLength = Math.ceil(max.toString(2).length / 8);
const randomBytes = new Uint8Array(byteLength);
// Use crypto.getRandomValues for secure randomness
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(randomBytes);
} else {
// Fallback for environments without crypto
for (let i = 0; i < byteLength; i++) {
randomBytes[i] = Math.floor(Math.random() * 256);
}
}
// Convert to BigInt
let result = BigInt(0);
for (let i = 0; i < randomBytes.length; i++) {
result = (result << BigInt(8)) + BigInt(randomBytes[i]);
}
// Ensure result is within range
return result % max;
}
/**
* Convert mnemonic words to entropy (as BigInt)
* Each word is mapped to its index, then combined into a single large number
*/
export function mnemonicToEntropy(words: string[], wordList: readonly string[]): bigint {
let entropy = BigInt(0);
const wordListLength = BigInt(wordList.length);
for (const word of words) {
const index = wordList.indexOf(word);
if (index === -1) {
throw new Error(`Word "${word}" not found in word list`);
}
entropy = entropy * wordListLength + BigInt(index);
}
return entropy;
}
/**
* Convert entropy back to mnemonic words
*/
export function entropyToMnemonic(entropy: bigint, wordCount: number, wordList: readonly string[]): string[] {
const words: string[] = [];
const wordListLength = BigInt(wordList.length);
let remaining = entropy;
for (let i = 0; i < wordCount; i++) {
const index = Number(remaining % wordListLength);
words.unshift(wordList[index]);
remaining = remaining / wordListLength;
}
return words;
}
/**
* Modular inverse using extended Euclidean algorithm
* Returns x such that (a * x) % p === 1
*/
function modInverse(a: bigint, p: bigint): bigint {
let [oldR, r] = [a % p, p];
let [oldS, s] = [BigInt(1), BigInt(0)];
while (r !== BigInt(0)) {
const quotient = oldR / r;
[oldR, r] = [r, oldR - quotient * r];
[oldS, s] = [s, oldS - quotient * s];
}
// Ensure positive result
return ((oldS % p) + p) % p;
}
/**
* Modular arithmetic helper to ensure positive results
*/
function mod(n: bigint, p: bigint): bigint {
return ((n % p) + p) % p;
}
/**
* Split a secret into 3 shares using SSS (3,2) threshold scheme
*
* Uses linear polynomial: f(x) = secret + a*x (mod p)
* where 'a' is a random coefficient
*
* Any 2 points on this line can recover the y-intercept (secret)
*/
export function splitSecret(secret: bigint): SSSShare[] {
// Generate random coefficient for the polynomial
const a = secureRandomBigInt(PRIME);
// Polynomial: f(x) = secret + a*x (mod PRIME)
const f = (x: number): bigint => {
return mod(secret + a * BigInt(x), PRIME);
};
// Generate 3 shares at x = 1, 2, 3
return [
{ x: 1, y: f(1), label: 'device' },
{ x: 2, y: f(2), label: 'cloud' },
{ x: 3, y: f(3), label: 'heir' },
];
}
/**
* Recover the secret from any 2 shares using Lagrange interpolation
*
* For 2 points (x1, y1) and (x2, y2), the secret (y-intercept) is:
* S = (x2*y1 - x1*y2) / (x2 - x1) (mod p)
*/
export function recoverSecret(shareA: SSSShare, shareB: SSSShare): bigint {
const { x: x1, y: y1 } = shareA;
const { x: x2, y: y2 } = shareB;
// Numerator: x2*y1 - x1*y2
const numerator = mod(
BigInt(x2) * y1 - BigInt(x1) * y2,
PRIME
);
// Denominator: x2 - x1
const denominator = mod(BigInt(x2 - x1), PRIME);
// Division in modular arithmetic = multiply by modular inverse
const invDenominator = modInverse(denominator, PRIME);
return mod(numerator * invDenominator, PRIME);
}
/**
* Format a share for display (truncated for readability)
* Shows first 8 and last 4 characters of the y-value
*/
export function formatShareForDisplay(share: SSSShare): string {
const yStr = share.y.toString();
if (yStr.length <= 16) {
return `(${share.x}, ${yStr})`;
}
return `(${share.x}, ${yStr.slice(0, 8)}...${yStr.slice(-4)})`;
}
/**
* Format a share as a compact display string (for UI cards)
* Returns a shorter format showing the share index and a hash-like preview
*/
export function formatShareCompact(share: SSSShare): string {
const yStr = share.y.toString();
// Create a "fingerprint" from the y value
const fingerprint = yStr.slice(0, 4) + '-' + yStr.slice(4, 8) + '-' + yStr.slice(-4);
return fingerprint;
}
/**
* Serialize a share to a string for storage/transmission
*/
export function serializeShare(share: SSSShare): string {
return JSON.stringify({
x: share.x,
y: share.y.toString(),
label: share.label,
});
}
/**
* Deserialize a share from a string
*/
export function deserializeShare(str: string): SSSShare {
const parsed = JSON.parse(str);
return {
x: parsed.x,
y: BigInt(parsed.y),
label: parsed.label,
};
}
/**
* Main function to generate mnemonic and SSS shares
* This is the entry point for the vault initialization flow
*/
export interface VaultKeyData {
mnemonic: string[];
shares: SSSShare[];
entropy: bigint;
}
export function generateVaultKeys(
wordList: readonly string[],
wordCount: number = 12
): VaultKeyData {
// Generate random mnemonic
const mnemonic: string[] = [];
for (let i = 0; i < wordCount; i++) {
const index = Math.floor(Math.random() * wordList.length);
mnemonic.push(wordList[index]);
}
// Convert to entropy
const entropy = mnemonicToEntropy(mnemonic, wordList);
// Split into shares
const shares = splitSecret(entropy);
return { mnemonic, shares, entropy };
}
/**
* Verify that shares can recover the original entropy
* Useful for testing and validation
*/
export function verifyShares(
shares: SSSShare[],
originalEntropy: bigint
): boolean {
// Test all 3 combinations of 2 shares
const combinations = [
[shares[0], shares[1]], // Device + Cloud
[shares[1], shares[2]], // Cloud + Heir
[shares[0], shares[2]], // Device + Heir
];
for (const [a, b] of combinations) {
const recovered = recoverSecret(a, b);
if (recovered !== originalEntropy) {
return false;
}
}
return true;
}