Compare commits
2 Commits
Steven
...
dev_pr_tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1377eb4b | ||
|
|
c07f1f20d5 |
@@ -22,7 +22,16 @@ 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 {
|
||||||
|
SSSShare,
|
||||||
|
mnemonicToEntropy,
|
||||||
|
splitSecret,
|
||||||
|
formatShareCompact,
|
||||||
|
serializeShare,
|
||||||
|
verifyShares,
|
||||||
|
} from '../utils/sss';
|
||||||
|
|
||||||
|
// 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 +39,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 +60,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,7 +163,7 @@ 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 [isCapturing, setIsCapturing] = useState(false);
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
@@ -123,59 +171,73 @@ export default function SentinelScreen() {
|
|||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
// Cleanup animations on unmount to prevent memory leaks
|
||||||
|
return () => {
|
||||||
|
pulseAnimation.stop();
|
||||||
|
glowAnimation.stop();
|
||||||
|
rotateAnimation.stop();
|
||||||
|
};
|
||||||
|
}, [pulseAnim, glowAnim, rotateAnim]);
|
||||||
|
|
||||||
const openVaultWithMnemonic = () => {
|
const openVaultWithMnemonic = () => {
|
||||||
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(() => {
|
|
||||||
|
// Store Share A (device share) locally
|
||||||
|
if (shares[0]) {
|
||||||
|
AsyncStorage.setItem('sentinel_share_device', serializeShare(shares[0])).catch(() => {
|
||||||
// Best-effort local store; UI remains available
|
// Best-effort local store; UI remains available
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScreenshot = async () => {
|
const handleScreenshot = async () => {
|
||||||
@@ -230,23 +292,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 +386,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 }]}>
|
||||||
@@ -390,6 +452,8 @@ export default function SentinelScreen() {
|
|||||||
style={styles.vaultAccessButton}
|
style={styles.vaultAccessButton}
|
||||||
onPress={openVaultWithMnemonic}
|
onPress={openVaultWithMnemonic}
|
||||||
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 +464,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 +517,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 +536,17 @@ export default function SentinelScreen() {
|
|||||||
style={styles.mnemonicOverlay}
|
style={styles.mnemonicOverlay}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
|
<View ref={mnemonicRef} collapsable={false}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
|
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
|
||||||
style={styles.mnemonicCard}
|
style={styles.mnemonicCard}
|
||||||
ref={mnemonicRef}
|
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.mnemonicClose}
|
style={styles.mnemonicClose}
|
||||||
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 +555,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 +564,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 +590,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,6 +602,8 @@ 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"
|
||||||
|
accessibilityRole="button"
|
||||||
>
|
>
|
||||||
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
|
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -542,12 +623,15 @@ 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}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
5
src/utils/index.ts
Normal file
5
src/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for Sentinel
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './sss';
|
||||||
263
src/utils/sss.ts
Normal file
263
src/utils/sss.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user