add vault first time control

This commit is contained in:
Ada
2026-01-30 21:26:38 -08:00
parent fb1377eb4b
commit 218b2e8b29
3 changed files with 216 additions and 20 deletions

View File

@@ -14,9 +14,11 @@ import {
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '../context/AuthContext';
import { Heir, HeirStatus, PaymentStrategy } from '../types';
import HeritageScreen from './HeritageScreen';
import { VAULT_STORAGE_KEYS } from './SentinelScreen';
// Mock heirs data
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 (
<View style={styles.container}>
<LinearGradient
@@ -885,6 +899,21 @@ export default function MeScreen() {
<Text style={styles.sanctumValue}>View</Text>
</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>
<View style={styles.tideModalButtons}>
<TouchableOpacity
@@ -1814,6 +1843,23 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm,
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: {
flexDirection: 'row',
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 { SystemStatus, KillSwitchLog } from '../types';
import VaultScreen from './VaultScreen';
import BiometricModal from '../components/common/BiometricModal';
import {
SSSShare,
mnemonicToEntropy,
@@ -31,6 +32,12 @@ import {
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',
@@ -166,9 +173,19 @@ export default function SentinelScreen() {
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(() => {
// Pulse animation
const pulseAnimation = Animated.loop(
@@ -222,7 +239,7 @@ export default function SentinelScreen() {
};
}, [pulseAnim, glowAnim, rotateAnim]);
const openVaultWithMnemonic = () => {
const startFirstTimeSetup = () => {
const words = generateMnemonic();
const shares = generateSSSShares(words);
setMnemonicWords(words);
@@ -231,12 +248,40 @@ export default function SentinelScreen() {
setShowVault(false);
setShowEmailForm(false);
setEmailAddress('');
// Store Share A (device share) locally
setEmailRecipientType('self');
if (shares[0]) {
AsyncStorage.setItem('sentinel_share_device', serializeShare(shares[0])).catch(() => {
// Best-effort local store; UI remains available
});
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();
}
};
@@ -252,8 +297,7 @@ export default function SentinelScreen() {
url: uri,
message: 'Sentinel key backup',
});
setShowMnemonic(false);
setShowVault(true);
completeSetupAndEnterVault();
} catch (error) {
Alert.alert('Screenshot failed', 'Please try again or use email backup.');
} finally {
@@ -265,6 +309,10 @@ export default function SentinelScreen() {
setShowEmailForm(true);
};
const handleCompleteBackupLocal = () => {
completeSetupAndEnterVault();
};
const handleSendEmail = async () => {
const trimmed = emailAddress.trim();
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
@@ -272,16 +320,21 @@ export default function SentinelScreen() {
return;
}
const subject = encodeURIComponent('Sentinel Vault Recovery Key');
const body = encodeURIComponent(`Your 12-word mnemonic:\n${mnemonicWords.join(' ')}`);
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);
setShowMnemonic(false);
setShowEmailForm(false);
setEmailAddress('');
setShowVault(true);
completeSetupAndEnterVault();
} catch (error) {
Alert.alert('Email failed', 'Unable to open email client.');
}
@@ -450,7 +503,7 @@ export default function SentinelScreen() {
</View>
<TouchableOpacity
style={styles.vaultAccessButton}
onPress={openVaultWithMnemonic}
onPress={handleOpenVault}
activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
@@ -536,6 +589,12 @@ export default function SentinelScreen() {
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]}
@@ -602,18 +661,36 @@ export default function SentinelScreen() {
style={styles.mnemonicSecondaryButton}
onPress={handleEmailBackup}
activeOpacity={0.85}
accessibilityLabel="Send mnemonic backup via email"
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="you@email.com"
placeholder={emailRecipientType === 'heir' ? 'heir@email.com' : 'you@email.com'}
placeholderTextColor={colors.sentinel.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
@@ -630,10 +707,30 @@ export default function SentinelScreen() {
</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>
);
}
@@ -900,6 +997,13 @@ const styles = StyleSheet.create({
justifyContent: 'center',
padding: spacing.lg,
},
mnemonicScroll: {
flex: 1,
},
mnemonicScrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
mnemonicCard: {
borderRadius: borderRadius.xl,
padding: spacing.lg,
@@ -1013,6 +1117,47 @@ const styles = StyleSheet.create({
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,
},

View File

@@ -1,11 +1,16 @@
/**
* 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
*
* 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