1 Commits

Author SHA1 Message Date
Ada
218b2e8b29 add vault first time control 2026-01-30 21:26:38 -08:00
3 changed files with 216 additions and 20 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,6 +22,7 @@ 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 { import {
SSSShare, SSSShare,
mnemonicToEntropy, mnemonicToEntropy,
@@ -31,6 +32,12 @@ import {
verifyShares, verifyShares,
} from '../utils/sss'; } 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) // 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',
@@ -166,9 +173,19 @@ export default function SentinelScreen() {
const [sssShares, setSssShares] = useState<SSSShare[]>([]); 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
const pulseAnimation = Animated.loop( const pulseAnimation = Animated.loop(
@@ -222,7 +239,7 @@ export default function SentinelScreen() {
}; };
}, [pulseAnim, glowAnim, rotateAnim]); }, [pulseAnim, glowAnim, rotateAnim]);
const openVaultWithMnemonic = () => { const startFirstTimeSetup = () => {
const words = generateMnemonic(); const words = generateMnemonic();
const shares = generateSSSShares(words); const shares = generateSSSShares(words);
setMnemonicWords(words); setMnemonicWords(words);
@@ -231,12 +248,40 @@ export default function SentinelScreen() {
setShowVault(false); setShowVault(false);
setShowEmailForm(false); setShowEmailForm(false);
setEmailAddress(''); setEmailAddress('');
setEmailRecipientType('self');
// Store Share A (device share) locally
if (shares[0]) { if (shares[0]) {
AsyncStorage.setItem('sentinel_share_device', serializeShare(shares[0])).catch(() => { AsyncStorage.setItem(VAULT_STORAGE_KEYS.SHARE_DEVICE, serializeShare(shares[0])).catch(() => {});
// Best-effort local store; UI remains available }
}); };
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();
} }
}; };
@@ -252,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 {
@@ -265,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)) {
@@ -272,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.');
} }
@@ -450,7 +503,7 @@ 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" accessibilityLabel="Open Shadow Vault"
accessibilityRole="button" accessibilityRole="button"
@@ -536,6 +589,12 @@ 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}> <View ref={mnemonicRef} collapsable={false}>
<LinearGradient <LinearGradient
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]} colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
@@ -602,18 +661,36 @@ export default function SentinelScreen() {
style={styles.mnemonicSecondaryButton} style={styles.mnemonicSecondaryButton}
onPress={handleEmailBackup} onPress={handleEmailBackup}
activeOpacity={0.85} activeOpacity={0.85}
accessibilityLabel="Send mnemonic backup via email" accessibilityLabel="Send backup via email"
accessibilityRole="button" 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"
@@ -630,10 +707,30 @@ export default function SentinelScreen() {
</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> </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>
); );
} }
@@ -900,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,
@@ -1013,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,
}, },

View File

@@ -5,7 +5,12 @@
* - Secret is split into 3 shares * - Secret is split into 3 shares
* - Any 2 shares can recover the original secret * - Any 2 shares can recover the original secret
* *
* Based on the Sentinel crypto_core_demo Python implementation. * 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 // Use a large prime for the finite field