Generate SSS shares from mnemonic words

This commit is contained in:
Ada
2026-01-30 16:31:09 -08:00
parent c07f1f20d5
commit fb1377eb4b
3 changed files with 334 additions and 20 deletions

View File

@@ -22,6 +22,14 @@ 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 {
SSSShare,
mnemonicToEntropy,
splitSecret,
formatShareCompact,
serializeShare,
verifyShares,
} from '../utils/sss';
// Nautical-themed mnemonic word list (unique words only)
const MNEMONIC_WORDS = [
@@ -52,11 +60,39 @@ const generateMnemonic = (wordCount = 12) => {
return words;
};
const splitMnemonic = (words: string[]) => [
words.slice(0, 4),
words.slice(4, 8),
words.slice(8, 12),
];
/**
* 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
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
@@ -127,7 +163,7 @@ export default function SentinelScreen() {
const [showVault, setShowVault] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]);
const [sssShares, setSssShares] = useState<SSSShare[]>([]);
const [showEmailForm, setShowEmailForm] = useState(false);
const [emailAddress, setEmailAddress] = useState('');
const [isCapturing, setIsCapturing] = useState(false);
@@ -188,16 +224,20 @@ export default function SentinelScreen() {
const openVaultWithMnemonic = () => {
const words = generateMnemonic();
const parts = splitMnemonic(words);
const shares = generateSSSShares(words);
setMnemonicWords(words);
setMnemonicParts(parts);
setSssShares(shares);
setShowMnemonic(true);
setShowVault(false);
setShowEmailForm(false);
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
});
}
};
const handleScreenshot = async () => {
@@ -515,7 +555,7 @@ export default function SentinelScreen() {
<Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text>
</View>
<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>
<View style={styles.mnemonicBlock}>
<Text style={styles.mnemonicBlockText}>
@@ -524,19 +564,25 @@ export default function SentinelScreen() {
</View>
<View style={styles.partGrid}>
<View style={[styles.partCard, styles.partCardStored]}>
<Text style={styles.partLabel}>PART 1 LOCAL</Text>
<Text style={styles.partValue}>{mnemonicParts[0]?.join(' ')}</Text>
<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}>PART 2 CLOUD NODE</Text>
<Text style={styles.partValue}>{mnemonicParts[1]?.join(' ')}</Text>
<Text style={styles.partHint}>To be synced</Text>
<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}>PART 3 HEIR</Text>
<Text style={styles.partValue}>{mnemonicParts[2]?.join(' ')}</Text>
<Text style={styles.partHint}>To be assigned</Text>
<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

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

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

263
src/utils/sss.ts Normal file
View 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;
}