diff --git a/app/src/main/java/com/gorunjinian/metrovault/domain/Wallet.kt b/app/src/main/java/com/gorunjinian/metrovault/domain/Wallet.kt index e70e67f..890fdc1 100644 --- a/app/src/main/java/com/gorunjinian/metrovault/domain/Wallet.kt +++ b/app/src/main/java/com/gorunjinian/metrovault/domain/Wallet.kt @@ -188,6 +188,11 @@ class Wallet(context: Context) { return bitcoinService.generateMnemonicWithUserEntropy(wordCount, userEntropy) } + /** Physical-only path; caller must validate source strength before supplying entropy. */ + fun generateMnemonicFromEntropy(wordCount: Int, entropy: ByteArray): List { + return bitcoinService.generateMnemonicFromEntropy(wordCount, entropy) + } + /** * Creates a wallet from mnemonic. * diff --git a/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/BitcoinService.kt b/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/BitcoinService.kt index 81107b1..33db7ef 100644 --- a/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/BitcoinService.kt +++ b/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/BitcoinService.kt @@ -44,6 +44,9 @@ class BitcoinService { fun generateMnemonicWithUserEntropy(wordCount: Int = 24, userEntropy: ByteArray?): List = mnemonicService.generateMnemonicWithUserEntropy(wordCount, userEntropy) + fun generateMnemonicFromEntropy(wordCount: Int, entropy: ByteArray): List = + mnemonicService.generateMnemonicFromEntropy(wordCount, entropy) + fun validateMnemonic(words: List): Boolean = mnemonicService.validateMnemonic(words) diff --git a/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/MnemonicService.kt b/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/MnemonicService.kt index d2ab76c..2334da0 100644 --- a/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/MnemonicService.kt +++ b/app/src/main/java/com/gorunjinian/metrovault/domain/service/bitcoin/MnemonicService.kt @@ -75,6 +75,26 @@ class MnemonicService { } } + /** + * Encodes caller-supplied, already-normalized entropy without adding system randomness. + * The caller must enforce source-strength requirements before invoking this method. + */ + fun generateMnemonicFromEntropy(wordCount: Int, entropy: ByteArray): List { + val expectedSize = when (wordCount) { + 12 -> 16 + 24 -> 32 + else -> throw IllegalArgumentException("Physical-only generation supports 12 or 24 words") + } + require(entropy.size == expectedSize) { "Entropy length does not match word count" } + + val entropyCopy = entropy.copyOf() + return try { + MnemonicCode.toMnemonics(entropyCopy) + } finally { + entropyCopy.fill(0) + } + } + /** * Validates a BIP39 mnemonic phrase. */ diff --git a/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletScreen.kt b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletScreen.kt index ff127cd..b1a8e90 100644 --- a/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletScreen.kt +++ b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletScreen.kt @@ -1,18 +1,30 @@ package com.gorunjinian.metrovault.feature.wallet.create import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp @@ -34,6 +46,17 @@ fun CreateWalletScreen( ) { // Collect state from ViewModel val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showDiscardDraftDialog by remember { mutableStateOf(false) } + + fun handleBack() { + when { + uiState.currentStep > 1 -> viewModel.goToPreviousStep() + uiState.hasUnsavedDraft -> showDiscardDraftDialog = true + else -> viewModel.discardDraftAndExit() + } + } + + BackHandler(onBack = ::handleBack) // Handle events from ViewModel LaunchedEffect(Unit) { @@ -60,7 +83,7 @@ fun CreateWalletScreen( topBar = { MetroTopBar( title = "Create Wallet", - onBack = { viewModel.goToPreviousStep() }, + onBack = ::handleBack, colors = TopAppBarDefaults.topAppBarColors() ) } @@ -88,12 +111,20 @@ fun CreateWalletScreen( 2 -> Step2Entropy( entropyType = uiState.entropyType, collectedEntropy = uiState.collectedEntropy, + cardsWithReplacement = uiState.cardsWithReplacement, + physicalEntropyMode = uiState.physicalEntropyMode, entropyProgress = uiState.entropyProgress, bitsCollected = uiState.bitsCollected, requiredEntropyBits = uiState.requiredEntropyBits, entropyInputCount = uiState.entropyInputCount, + entropyErrorMessage = uiState.entropyErrorMessage, + canGenerateMnemonic = uiState.canGenerateMnemonic, onEntropyTypeChange = { viewModel.setEntropyType(it) }, onAddEntropy = { viewModel.addEntropyInput(it) }, + onRemoveLastEntropy = { viewModel.removeLastEntropyInput() }, + onRemoveEntropyAt = { viewModel.removeEntropyInput(it) }, + onCardsWithReplacementChange = { viewModel.setCardsWithReplacement(it) }, + onPhysicalEntropyModeChange = { viewModel.setPhysicalEntropyMode(it) }, onResetEntropy = { viewModel.resetEntropy() }, onRevealSeed = { viewModel.showSecurityWarning() } ) @@ -128,6 +159,31 @@ fun CreateWalletScreen( } } + if (showDiscardDraftDialog) { + AlertDialog( + onDismissRequest = { showDiscardDraftDialog = false }, + title = { Text("Discard unfinished wallet?") }, + text = { + Text( + "Leaving now will permanently wipe this wallet draft, including its captured " + + "entropy, generated seed phrase, passphrase, and configuration." + ) + }, + confirmButton = { + TextButton( + onClick = { + showDiscardDraftDialog = false + viewModel.discardDraftAndExit() + } + ) { Text("Discard wallet draft") } + }, + dismissButton = { + TextButton(onClick = { showDiscardDraftDialog = false }) { Text("Continue editing") } + }, + icon = { Icon(painterResource(R.drawable.ic_warning), contentDescription = null) } + ) + } + // Entropy explanation dialog, shown before the entropy step can be used if (uiState.showEntropyInfoDialog) { AlertDialog( @@ -136,11 +192,11 @@ fun CreateWalletScreen( text = { Text( text = androidx.compose.ui.text.buildAnnotatedString { - append("Your seed phrase is generated from your device's cryptographically secure random number generator.\n\nOptionally, you can add your own randomness with coin tosses or dice rolls. Your input is combined with the device's randomness using SHA-256 — ") + append("By default, your seed phrase uses the device's cryptographically secure random number generator. Optional coin tosses, dice rolls, or card draws are normalized and mixed into that randomness.\n\n") withStyle(androidx.compose.ui.text.SpanStyle(fontWeight = FontWeight.Bold)) { - append("it is added on top of system entropy, never used alone") + append("Physical only (reproducible)") } - append(", so it can only strengthen the result.\n\nSkipping this step is safe: your seed will still use full system entropy.") + append(" instead derives the wallet deterministically from the recorded sequence without device randomness. It is available only after enough estimated physical entropy is entered. Hashing does not create entropy.\n\nSkipping physical input is safe in the default mode.") } ) }, @@ -162,6 +218,12 @@ fun CreateWalletScreen( Text( text = androidx.compose.ui.text.buildAnnotatedString { append("Your seed phrase is the master key to your funds. Never share it with anyone.\n\nEnsure you are in a private location and no one is watching your screen.\n\n") + if (uiState.physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY) { + withStyle(androidx.compose.ui.text.SpanStyle(fontWeight = FontWeight.Bold)) { + append("Reproducible physical-only mode is active. ") + } + append("No device randomness will be added. Security is limited by the quality and secrecy of your recorded physical sequence. The same source, sequence, mode, and word count reproduce the same mnemonic.\n\n") + } withStyle(androidx.compose.ui.text.SpanStyle(fontWeight = FontWeight.Bold)) { append("Write it down and keep it somewhere secure and private") } @@ -185,20 +247,63 @@ fun CreateWalletScreen( // ========== Step 2: Entropy ========== +private sealed class PendingEntropyChange { + data class Source(val type: String) : PendingEntropyChange() + data class CardMode(val withReplacement: Boolean) : PendingEntropyChange() + data object Reset : PendingEntropyChange() +} + +private val CARD_SUIT_DISPLAY_ORDER = listOf( + CardSuit.SPADES, + CardSuit.HEARTS, + CardSuit.CLUBS, + CardSuit.DIAMONDS +) + +@Composable +private fun cardSuitColor(suit: CardSuit): Color = + if (suit.isRed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + @SuppressLint("DefaultLocale") @Composable private fun Step2Entropy( entropyType: String, collectedEntropy: List, + cardsWithReplacement: Boolean, + physicalEntropyMode: PhysicalEntropyMode, entropyProgress: Float, bitsCollected: Double, requiredEntropyBits: Int, entropyInputCount: String, + entropyErrorMessage: String, + canGenerateMnemonic: Boolean, onEntropyTypeChange: (String) -> Unit, onAddEntropy: (Int) -> Unit, + onRemoveLastEntropy: () -> Unit, + onRemoveEntropyAt: (Int) -> Unit, + onCardsWithReplacementChange: (Boolean) -> Unit, + onPhysicalEntropyModeChange: (PhysicalEntropyMode) -> Unit, onResetEntropy: () -> Unit, onRevealSeed: () -> Unit ) { + var pendingChange by remember { mutableStateOf(null) } + + fun requestSourceChange(type: String) { + if (type == entropyType) return + if (collectedEntropy.isEmpty()) onEntropyTypeChange(type) + else pendingChange = PendingEntropyChange.Source(type) + } + + fun requestCardModeChange(withReplacement: Boolean) { + if (withReplacement == cardsWithReplacement) return + if (collectedEntropy.isEmpty()) onCardsWithReplacementChange(withReplacement) + else pendingChange = PendingEntropyChange.CardMode(withReplacement) + } + + fun requestReset() { + if (collectedEntropy.isNotEmpty()) pendingChange = PendingEntropyChange.Reset + } + Column( modifier = Modifier .fillMaxSize() @@ -216,11 +321,28 @@ private fun Step2Entropy( ) InfoCard( - text = "Add your own randomness to the wallet generation. This is optional but can provide additional security assurance.", - tone = InfoTone.Neutral, + text = if (physicalEntropyMode == PhysicalEntropyMode.MIX_WITH_DEVICE) { + "Recommended: secure device randomness generates the seed. Any physical sequence is SHA-256-normalized and mixed into it." + } else { + "Reproducible mode: the recorded physical sequence alone determines the BIP39 mnemonic. Device randomness is not added, and hashing does not create entropy." + }, + tone = if (physicalEntropyMode == PhysicalEntropyMode.MIX_WITH_DEVICE) InfoTone.Neutral else InfoTone.Warning, textStyle = MaterialTheme.typography.bodyMedium ) + Text("Seed Randomness", style = MaterialTheme.typography.titleMedium) + + SegmentedToggle( + options = listOf("Device + physical", "Physical only"), + selectedIndex = if (physicalEntropyMode == PhysicalEntropyMode.MIX_WITH_DEVICE) 0 else 1, + onSelect = { index -> + onPhysicalEntropyModeChange( + if (index == 0) PhysicalEntropyMode.MIX_WITH_DEVICE else PhysicalEntropyMode.PHYSICAL_ONLY + ) + }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) Text( @@ -229,36 +351,74 @@ private fun Step2Entropy( ) SegmentedToggle( - options = listOf("Coin Toss", "Dice Rolls"), + options = listOf("Coin Toss", "Dice Rolls", "Cards"), selectedIndex = when (entropyType) { "coin" -> 0 "dice" -> 1 + "cards" -> 2 else -> -1 // Nothing selected until the user picks a source }, - onSelect = { index -> onEntropyTypeChange(if (index == 0) "coin" else "dice") }, + onSelect = { index -> + requestSourceChange(when (index) { + 0 -> "coin" + 1 -> "dice" + else -> "cards" + }) + }, modifier = Modifier.fillMaxWidth() ) if (entropyType.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) + if (collectedEntropy.isNotEmpty()) { + RecordedEntropySequence( + entropyType = entropyType, + inputs = collectedEntropy, + onRemoveAt = onRemoveEntropyAt + ) + Spacer(modifier = Modifier.height(8.dp)) + } + if (entropyType == "coin") { Text( - text = "Tap to record your coin tosses", + text = "Tap anywhere on the left for Heads or anywhere on the right for Tails", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier + .fillMaxWidth() + .height(128.dp) + .clip(RoundedCornerShape(20.dp)) ) { - CoinButton(label = "Heads", onClick = { onAddEntropy(0) }) - CoinButton(label = "Tails", onClick = { onAddEntropy(1) }) + CoinCaptureZone( + label = "Heads", + shortcut = "LEFT • H", + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.weight(1f), + onClick = { onAddEntropy(0) } + ) + Spacer( + modifier = Modifier + .fillMaxHeight() + .width(3.dp) + .background(MaterialTheme.colorScheme.surface) + ) + CoinCaptureZone( + label = "Tails", + shortcut = "RIGHT • T", + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + modifier = Modifier.weight(1f), + onClick = { onAddEntropy(1) } + ) } - } else { + } else if (entropyType == "dice") { Text( - text = "Tap to record your dice rolls", + text = "Tap a die or type each roll using the numeric keyboard", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -271,6 +431,77 @@ private fun Step2Entropy( DiceFace(value = value, onClick = { onAddEntropy(value) }) } } + + var diceKeyboardBuffer by remember { mutableStateOf("") } + var rejectedDiceKey by remember { mutableStateOf(false) } + var keyboardRequest by remember { mutableIntStateOf(0) } + val diceKeyboardFocusRequester = remember { FocusRequester() } + val softwareKeyboardController = LocalSoftwareKeyboardController.current + + DisposableEffect(Unit) { + onDispose { diceKeyboardBuffer = "" } + } + + LaunchedEffect(keyboardRequest) { + if (keyboardRequest > 0) { + diceKeyboardFocusRequester.requestFocus() + withFrameNanos { } + softwareKeyboardController?.show() + } + } + + BasicTextField( + value = diceKeyboardBuffer, + onValueChange = { newText -> + val inserted = insertedText(diceKeyboardBuffer, newText) + var rejected = false + inserted.forEach { character -> + if (character in '1'..'6') onAddEntropy(character.digitToInt()) + else if (!character.isWhitespace()) rejected = true + } + diceKeyboardBuffer = newText.filter { it in '1'..'6' } + rejectedDiceKey = rejected + }, + modifier = Modifier + .size(1.dp) + .focusRequester(diceKeyboardFocusRequester) + .clearAndSetSemantics { }, + textStyle = MaterialTheme.typography.bodySmall.copy(color = Color.Transparent), + cursorBrush = SolidColor(Color.Transparent), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + singleLine = true, + decorationBox = { innerTextField -> innerTextField() } + ) + + OutlinedButton( + onClick = { keyboardRequest++ }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(R.drawable.ic_keyboard), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open numeric keyboard (1–6)") + } + Text( + text = if (rejectedDiceKey) { + "Only dice values 1–6 are recorded." + } else { + "Keyboard presses appear only as captured dice above; use × to delete a roll." + }, + style = MaterialTheme.typography.bodySmall, + color = if (rejectedDiceKey) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + CardEntropyInput( + selectedCardIds = collectedEntropy, + withReplacement = cardsWithReplacement, + onWithReplacementChange = ::requestCardModeChange, + onCardSelected = onAddEntropy + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -296,7 +527,11 @@ private fun Step2Entropy( style = MaterialTheme.typography.titleSmall ) if (collectedEntropy.isNotEmpty()) { - TextButton(onClick = onResetEntropy) { + Row { + TextButton(onClick = onRemoveLastEntropy) { + Text("Undo") + } + TextButton(onClick = ::requestReset) { Icon( painter = painterResource(R.drawable.ic_refresh), contentDescription = "Reset", @@ -304,6 +539,7 @@ private fun Step2Entropy( ) Spacer(modifier = Modifier.width(4.dp)) Text("Reset") + } } } } @@ -314,7 +550,7 @@ private fun Step2Entropy( ) Text( - text = "${String.format("%.0f", bitsCollected)} bits collected ($requiredEntropyBits bits recommended)", + text = "${String.format("%.2f", bitsCollected)} estimated bits ($requiredEntropyBits bits ${if (physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY) "required" else "recommended"})", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) @@ -324,26 +560,333 @@ private fun Step2Entropy( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) + + if (entropyType == "cards" && !cardsWithReplacement) { + Text( + text = "A full shuffled deck has a maximum of about 225.58 bits, so this mode cannot independently reach 256 bits.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } } } } + if (entropyErrorMessage.isNotEmpty()) { + Text( + text = entropyErrorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } Spacer(modifier = Modifier.height(16.dp)) Button( onClick = onRevealSeed, + enabled = canGenerateMnemonic, modifier = Modifier.fillMaxWidth() ) { Text( - if (collectedEntropy.isNotEmpty()) "Reveal Seed Phrase" - else "Skip to reveal seed" + when { + physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY && canGenerateMnemonic -> + "Reveal Reproducible Seed Phrase" + physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY -> + "Enter $requiredEntropyBits bits to continue" + collectedEntropy.isNotEmpty() -> "Reveal Seed Phrase" + else -> "Skip to reveal seed" + } + ) + } + + pendingChange?.let { change -> + val actionDescription = when (change) { + is PendingEntropyChange.Source -> "switch entropy sources" + is PendingEntropyChange.CardMode -> "change the card draw mode" + PendingEntropyChange.Reset -> "reset the captured sequence" + } + AlertDialog( + onDismissRequest = { pendingChange = null }, + title = { Text("Discard captured entropy?") }, + text = { + Text( + "You have ${collectedEntropy.size} captured ${if (collectedEntropy.size == 1) "entry" else "entries"}. " + + "If you $actionDescription, this partial sequence will be permanently cleared." + ) + }, + confirmButton = { + TextButton( + onClick = { + when (change) { + is PendingEntropyChange.Source -> onEntropyTypeChange(change.type) + is PendingEntropyChange.CardMode -> onCardsWithReplacementChange(change.withReplacement) + PendingEntropyChange.Reset -> onResetEntropy() + } + pendingChange = null + } + ) { Text("Discard and continue") } + }, + dismissButton = { + TextButton(onClick = { pendingChange = null }) { Text("Keep sequence") } + }, + icon = { Icon(painterResource(R.drawable.ic_warning), contentDescription = null) } ) } } } +private fun insertedText(previous: String, current: String): String { + var commonPrefix = 0 + while ( + commonPrefix < previous.length && + commonPrefix < current.length && + previous[commonPrefix] == current[commonPrefix] + ) { + commonPrefix++ + } + + var commonSuffix = 0 + val previousRemaining = previous.length - commonPrefix + val currentRemaining = current.length - commonPrefix + while ( + commonSuffix < previousRemaining && + commonSuffix < currentRemaining && + previous[previous.lastIndex - commonSuffix] == current[current.lastIndex - commonSuffix] + ) { + commonSuffix++ + } + + return current.substring(commonPrefix, current.length - commonSuffix) +} + +@Composable +private fun RecordedEntropySequence( + entropyType: String, + inputs: List, + onRemoveAt: (Int) -> Unit +) { + val scrollState = rememberScrollState() + + LaunchedEffect(inputs.size) { + withFrameNanos { } + scrollState.animateScrollTo(scrollState.maxValue) + } + + Text("Captured sequence", style = MaterialTheme.typography.titleSmall) + Text( + "Newest entry is shown at the right. Tap × on any item to remove it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + inputs.forEachIndexed { index, value -> + CapturedEntropyItem( + entropyType = entropyType, + value = value, + position = index + 1, + onRemove = { onRemoveAt(index) } + ) + } + } + if (scrollState.maxValue > 0) { + LinearProgressIndicator( + progress = { scrollState.value.toFloat() / scrollState.maxValue.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + Text( + "Swipe the sequence left or right to review all ${inputs.size} entries.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun CapturedEntropyItem( + entropyType: String, + value: Int, + position: Int, + onRemove: () -> Unit +) { + val label = when (entropyType) { + "coin" -> if (value == 0) "H" else "T" + "cards" -> PlayingCard.fromId(value).label + else -> value.toString() + } + val spokenType = when (entropyType) { + "coin" -> "coin toss" + "cards" -> "card draw" + else -> "dice roll" + } + val capturedCard = if (entropyType == "cards") PlayingCard.fromId(value) else null + val containerColor = when { + entropyType == "coin" && value == 0 -> MaterialTheme.colorScheme.primary + entropyType == "coin" -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = when { + entropyType == "coin" && value == 0 -> MaterialTheme.colorScheme.onPrimary + entropyType == "coin" -> MaterialTheme.colorScheme.onTertiary + capturedCard?.suit?.isRed == true -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.padding(top = 8.dp, end = 5.dp)) { + if (entropyType == "dice") { + DiceFace(value = value, onClick = {}) + } else { + Surface( + shape = RoundedCornerShape(8.dp), + color = containerColor, + modifier = Modifier + .height(48.dp) + .widthIn(min = 48.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 10.dp) + ) { + Text( + label, + style = MaterialTheme.typography.titleMedium, + color = contentColor + ) + } + } + } + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = "Delete $spokenType $label at position $position", + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 7.dp, y = (-7).dp) + .size(22.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error) + .clickable(onClick = onRemove) + .padding(4.dp) + ) + } + Text("#$position", style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun CardEntropyInput( + selectedCardIds: List, + withReplacement: Boolean, + onWithReplacementChange: (Boolean) -> Unit, + onCardSelected: (Int) -> Unit +) { + var selectedSuit by remember { mutableStateOf(CardSuit.SPADES) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Draw with replacement", style = MaterialTheme.typography.titleSmall) + Text( + if (withReplacement) "Repeated cards are allowed (default)" + else "Each card can be drawn once", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = withReplacement, onCheckedChange = onWithReplacementChange) + } + + InfoCard( + text = if (withReplacement) { + "After recording each draw, put that card at the bottom of the deck. Before the next draw, cut/split the deck at unpredictable positions a random 1–5 times. Choose the number of cuts independently each round. The 5.70-bit estimate assumes the next card is effectively uniform." + } else { + "Keep each drawn card out of the deck. Previously drawn cards are disabled below." + }, + tone = InfoTone.Info, + textStyle = MaterialTheme.typography.bodySmall + ) + + Text("1. Choose the suit", style = MaterialTheme.typography.titleSmall) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CARD_SUIT_DISPLAY_ORDER.forEach { suit -> + val isSelected = selectedSuit == suit + OutlinedButton( + onClick = { selectedSuit = suit }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 2.dp, vertical = 8.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + contentColor = cardSuitColor(suit) + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) cardSuitColor(suit) else MaterialTheme.colorScheme.outlineVariant + ) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${if (isSelected) "✓ " else ""}${suit.symbol}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text(suit.displayName, style = MaterialTheme.typography.labelSmall) + } + } + } + } + + Text( + "2. Tap the ${selectedSuit.displayName.lowercase()} card drawn", + style = MaterialTheme.typography.titleSmall + ) + + selectedSuit.let { suit -> + PlayingCard.RANKS.chunked(4).forEach { ranks -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ranks.forEach { rank -> + val cardId = suit.ordinal * PlayingCard.RANKS.size + PlayingCard.RANKS.indexOf(rank) + val alreadyUsed = !withReplacement && cardId in selectedCardIds + OutlinedButton( + onClick = { onCardSelected(cardId) }, + enabled = !alreadyUsed, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 10.dp) + ) { + Text( + text = "$rank${suit.symbol}", + color = if (alreadyUsed) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } else { + cardSuitColor(suit) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + repeat(4 - ranks.size) { Spacer(modifier = Modifier.weight(1f)) } + } + } + } +} + // ========== Step 3: Seed Phrase ========== @Composable @@ -441,25 +984,36 @@ private fun Step3SeedPhrase( // ========== Helper Composables ========== @Composable -private fun CoinButton( +private fun CoinCaptureZone( label: String, + shortcut: String, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier, onClick: () -> Unit ) { Surface( onClick = onClick, - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(100.dp) + shape = RoundedCornerShape(0.dp), + color = containerColor, + modifier = modifier.fillMaxHeight() ) { - Box( - contentAlignment = Alignment.Center, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize() ) { Text( text = label, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = contentColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = shortcut, + style = MaterialTheme.typography.labelMedium, + color = contentColor.copy(alpha = 0.85f) ) } } diff --git a/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletViewModel.kt b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletViewModel.kt index 0d41e2a..c4fed0d 100644 --- a/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletViewModel.kt +++ b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/CreateWalletViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.math.log2 /** * ViewModel for the Create Wallet multi-step wizard. @@ -44,8 +43,9 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica val isTestnet: Boolean = false, // Testnet wallet toggle // Step 2: Entropy - val entropyType: String = "", // "coin" or "dice" - val collectedEntropy: List = emptyList(), + val physicalEntropy: PhysicalEntropyState = PhysicalEntropyState(), + val physicalEntropyMode: PhysicalEntropyMode = PhysicalEntropyMode.MIX_WITH_DEVICE, + val entropyErrorMessage: String = "", // Step 3: Generated mnemonic val generatedMnemonic: List = emptyList(), @@ -67,24 +67,55 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica // Derived properties val requiredEntropyBits: Int get() = if (wordCount == 12) 128 else 256 - val entropyBytes: ByteArray get() = calculateEntropyBytes(entropyType, collectedEntropy) + val entropyType: String get() = when (physicalEntropy.source) { + EntropySource.COIN -> "coin" + EntropySource.DICE -> "dice" + EntropySource.CARDS -> "cards" + null -> "" + } + val collectedEntropy: List get() = physicalEntropy.inputs + val cardsWithReplacement: Boolean get() = physicalEntropy.cardsWithReplacement + + val entropyBytes: ByteArray get() = if (collectedEntropy.isEmpty()) { + ByteArray(0) + } else { + PhysicalEntropy.normalizedHash(physicalEntropy) + } // Calculate bits collected based on entropy type // Coin flip: 1 bit per flip (log2(2) = 1) // Dice roll: ~2.58 bits per roll (log2(6) ≈ 2.585) - val bitsCollected: Double get() = if (entropyType == "coin") { - collectedEntropy.size.toDouble() - } else { - collectedEntropy.size * log2(6.0) - } + val bitsCollected: Double get() = physicalEntropy.bitsCollected // Progress is based on bits collected, not packed byte array size val entropyProgress: Float get() = (bitsCollected.toFloat() / requiredEntropyBits).coerceIn(0f, 1f) + val canGenerateMnemonic: Boolean get() = + physicalEntropyMode == PhysicalEntropyMode.MIX_WITH_DEVICE || + PhysicalEntropy.hasRequiredEntropy(physicalEntropy, wordCount) + val entropyInputCount: String get() = "${collectedEntropy.size} ${ - if (entropyType == "coin") "coin flips" else "dice rolls" + when (physicalEntropy.source) { + EntropySource.COIN -> "coin tosses" + EntropySource.DICE -> "dice rolls" + EntropySource.CARDS -> "card draws" + null -> "inputs" + } }" + + val hasUnsavedDraft: Boolean get() = + currentStep > 1 || + hasShownEntropyInfo || + wordCount != 12 || + selectedDerivationPath != DerivationPaths.NATIVE_SEGWIT || + accountNumber != 0 || + isTestnet || + physicalEntropy.source != null || + generatedMnemonic.isNotEmpty() || + useBip39Passphrase || + bip39Passphrase.isNotEmpty() || + confirmBip39Passphrase.isNotEmpty() } private val _uiState = MutableStateFlow(UiState()) @@ -120,16 +151,21 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica if (currentStep > 1) { _uiState.update { it.copy(currentStep = currentStep - 1) } } else { - viewModelScope.launch { - _events.emit(CreateWalletEvent.NavigateBack) - } + discardDraftAndExit() + } + } + + fun discardDraftAndExit() { + clearSensitiveData() + viewModelScope.launch { + _events.emit(CreateWalletEvent.NavigateBack) } } // ========== Step 1: Configuration ========== fun setWordCount(count: Int) { - _uiState.update { it.copy(wordCount = count) } + _uiState.update { it.copy(wordCount = count, entropyErrorMessage = "") } } fun setDerivationPath(path: String) { @@ -156,19 +192,55 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica // ========== Step 2: Entropy ========== fun setEntropyType(type: String) { + val source = when (type) { + "coin" -> EntropySource.COIN + "dice" -> EntropySource.DICE + "cards" -> EntropySource.CARDS + else -> return + } _uiState.update { - it.copy(entropyType = type, collectedEntropy = emptyList()) + it.copy( + physicalEntropy = it.physicalEntropy.selectSource(source), + entropyErrorMessage = "" + ) } } fun addEntropyInput(value: Int) { _uiState.update { - it.copy(collectedEntropy = it.collectedEntropy + value) + it.copy(physicalEntropy = it.physicalEntropy.add(value), entropyErrorMessage = "") + } + } + + fun removeLastEntropyInput() { + _uiState.update { + it.copy(physicalEntropy = it.physicalEntropy.removeLast(), entropyErrorMessage = "") + } + } + + fun removeEntropyInput(index: Int) { + _uiState.update { + it.copy(physicalEntropy = it.physicalEntropy.removeAt(index), entropyErrorMessage = "") + } + } + + fun setCardsWithReplacement(enabled: Boolean) { + _uiState.update { + it.copy( + physicalEntropy = it.physicalEntropy.setCardsWithReplacement(enabled), + entropyErrorMessage = "" + ) } } + fun setPhysicalEntropyMode(mode: PhysicalEntropyMode) { + _uiState.update { it.copy(physicalEntropyMode = mode, entropyErrorMessage = "") } + } + fun resetEntropy() { - _uiState.update { it.copy(collectedEntropy = emptyList()) } + _uiState.update { + it.copy(physicalEntropy = it.physicalEntropy.reset(), entropyErrorMessage = "") + } } fun dismissEntropyInfo() { @@ -176,7 +248,15 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica } fun showSecurityWarning() { - _uiState.update { it.copy(showWarningDialog = true) } + _uiState.update { state -> + if (state.canGenerateMnemonic) { + state.copy(showWarningDialog = true, entropyErrorMessage = "") + } else { + state.copy( + entropyErrorMessage = "Physical-only mode requires at least ${state.requiredEntropyBits} estimated bits." + ) + } + } } fun dismissSecurityWarning() { @@ -188,21 +268,41 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica _uiState.update { it.copy(showWarningDialog = false) } val state = _uiState.value - val userEntropyBytes = if (state.collectedEntropy.isNotEmpty()) { - state.entropyBytes - } else { - null + if (!state.canGenerateMnemonic) { + _uiState.update { + it.copy( + showWarningDialog = false, + entropyErrorMessage = "Physical-only mode requires at least ${state.requiredEntropyBits} estimated bits." + ) + } + return@launch } - val mnemonic = withContext(Dispatchers.IO) { - wallet.generateMnemonic(state.wordCount, userEntropyBytes) + val entropyBytes = when { + state.physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY -> + PhysicalEntropy.deterministicBip39Entropy(state.physicalEntropy, state.wordCount) + state.collectedEntropy.isNotEmpty() -> state.entropyBytes + else -> null + } + + val mnemonic = try { + withContext(Dispatchers.IO) { + if (state.physicalEntropyMode == PhysicalEntropyMode.PHYSICAL_ONLY) { + wallet.generateMnemonicFromEntropy(state.wordCount, requireNotNull(entropyBytes)) + } else { + wallet.generateMnemonic(state.wordCount, entropyBytes) + } + } + } finally { + entropyBytes?.fill(0) } _uiState.update { it.copy( generatedMnemonic = mnemonic, currentStep = 3, - errorMessage = "" + errorMessage = "", + entropyErrorMessage = "" ) } } @@ -278,7 +378,9 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica generatedMnemonic = emptyList(), bip39Passphrase = "", confirmBip39Passphrase = "", - collectedEntropy = emptyList() + physicalEntropy = PhysicalEntropyState(), + physicalEntropyMode = PhysicalEntropyMode.MIX_WITH_DEVICE, + entropyErrorMessage = "" ) } _events.emit(CreateWalletEvent.WalletCreated) @@ -293,62 +395,6 @@ class CreateWalletViewModel(application: Application) : AndroidViewModel(applica // ========== Cleanup ========== fun clearSensitiveData() { - _uiState.update { - it.copy( - generatedMnemonic = emptyList(), - bip39Passphrase = "", - confirmBip39Passphrase = "", - collectedEntropy = emptyList() - ) - } - } - - companion object { - /** - * Converts collected entropy inputs to a byte array. - * For coins: packs bits (0=Heads, 1=Tails) into bytes - * For dice: converts dice values to bytes using the raw values - */ - private fun calculateEntropyBytes(entropyType: String, inputs: List): ByteArray { - if (inputs.isEmpty()) return ByteArray(0) - - return when (entropyType) { - "coin" -> { - // Pack coin flips as bits into bytes - val bytes = mutableListOf() - var currentByte = 0 - var bitCount = 0 - - for (flip in inputs) { - currentByte = (currentByte shl 1) or flip - bitCount++ - - if (bitCount == 8) { - bytes.add(currentByte.toByte()) - currentByte = 0 - bitCount = 0 - } - } - - bytes.toByteArray() - } - "dice" -> { - // Use dice values directly, each roll contributes ~2.58 bits - // Pack pairs of dice rolls into bytes for efficiency - val bytes = mutableListOf() - - for (i in inputs.indices step 2) { - if (i + 1 < inputs.size) { - // Combine two dice values (1-6) into one byte - val combined = (inputs[i] - 1) * 6 + (inputs[i + 1] - 1) - bytes.add(combined.toByte()) - } - } - - bytes.toByteArray() - } - else -> ByteArray(0) - } - } + _uiState.value = UiState() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropy.kt b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropy.kt new file mode 100644 index 0000000..e131abe --- /dev/null +++ b/app/src/main/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropy.kt @@ -0,0 +1,215 @@ +package com.gorunjinian.metrovault.feature.wallet.create + +import java.nio.ByteBuffer +import java.security.MessageDigest +import kotlin.math.log2 + +/** Physical entropy sources supported by the wallet-creation wizard. */ +enum class EntropySource(val wireId: Byte) { + COIN(1), + DICE(2), + CARDS(3) +} + +enum class PhysicalEntropyMode { + MIX_WITH_DEVICE, + PHYSICAL_ONLY +} + +enum class CardSuit(val symbol: String) { + CLUBS("♣"), + DIAMONDS("♦"), + HEARTS("♥"), + SPADES("♠"); + + val isRed: Boolean get() = this == DIAMONDS || this == HEARTS + + val displayName: String get() = name.lowercase().replaceFirstChar { it.titlecase() } +} + +/** + * Canonical card IDs are suit-major in [CardSuit] order, then rank-major in [RANKS] order. + */ +data class PlayingCard(val id: Int, val suit: CardSuit, val rank: String) { + val label: String get() = "$rank${suit.symbol}" + + companion object { + val RANKS = listOf("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K") + val DECK: List = CardSuit.entries.flatMapIndexed { suitIndex, suit -> + RANKS.mapIndexed { rankIndex, rank -> + PlayingCard(suitIndex * RANKS.size + rankIndex, suit, rank) + } + } + + fun fromId(id: Int): PlayingCard = DECK[id] + } +} + +/** + * Pure state for physical entropy entry. Source or card-mode changes clear the old sequence so + * symbols can never be interpreted under a different serialization mode. + */ +data class PhysicalEntropyState( + val source: EntropySource? = null, + val inputs: List = emptyList(), + val cardsWithReplacement: Boolean = true +) { + val bitsCollected: Double get() = PhysicalEntropy.entropyBits(source, inputs.size, cardsWithReplacement) + + fun selectSource(newSource: EntropySource): PhysicalEntropyState = + if (source == newSource) this else copy( + source = newSource, + inputs = emptyList(), + cardsWithReplacement = if (newSource == EntropySource.CARDS) true else cardsWithReplacement + ) + + fun setCardsWithReplacement(enabled: Boolean): PhysicalEntropyState = + if (cardsWithReplacement == enabled) this + else copy(cardsWithReplacement = enabled, inputs = emptyList()) + + fun add(value: Int): PhysicalEntropyState { + val activeSource = source ?: return this + if (!PhysicalEntropy.isValidSymbol(activeSource, value)) return this + if (activeSource == EntropySource.CARDS && !cardsWithReplacement && value in inputs) return this + return copy(inputs = inputs + value) + } + + fun removeLast(): PhysicalEntropyState = copy(inputs = inputs.dropLast(1)) + + fun removeAt(index: Int): PhysicalEntropyState = + if (index in inputs.indices) copy(inputs = inputs.filterIndexed { inputIndex, _ -> inputIndex != index }) + else this + + fun reset(): PhysicalEntropyState = copy(inputs = emptyList()) +} + +/** + * Frozen physical-entropy serialization, version 1: + * + * `"BitSawan physical entropy" || 0x00 || version || source || mode || count_be32 || symbols` + * + * Source IDs: coin=1, dice=2, cards=3. Mode is 0 for coin/dice, 1 for cards with + * replacement, and 2 for cards without replacement. Symbols are one byte each: coin 0/1, dice + * 1..6, and canonical card ID 0..51. In the recommended mode, the serialization is + * SHA-256-normalized before MnemonicService independently mixes it with full-size SecureRandom + * output. The separately documented deterministic formats are used only for physical-only mode. + */ +object PhysicalEntropy { + private val DOMAIN = "BitSawan physical entropy".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + private const val VERSION: Byte = 1 + + fun entropyBits(source: EntropySource?, count: Int, cardsWithReplacement: Boolean = true): Double = + when (source) { + EntropySource.COIN -> count.toDouble() + EntropySource.DICE -> count * log2(6.0) + EntropySource.CARDS -> if (cardsWithReplacement) { + count * log2(52.0) + } else { + (0 until count.coerceAtMost(52)).sumOf { draw -> log2((52 - draw).toDouble()) } + } + null -> 0.0 + } + + fun isValidSymbol(source: EntropySource, value: Int): Boolean = when (source) { + EntropySource.COIN -> value in 0..1 + EntropySource.DICE -> value in 1..6 + EntropySource.CARDS -> value in 0..51 + } + + fun canonicalSerialize(state: PhysicalEntropyState): ByteArray { + val source = requireNotNull(state.source) { "Entropy source is required" } + require(state.inputs.all { isValidSymbol(source, it) }) { "Invalid entropy symbol" } + require(source != EntropySource.CARDS || state.cardsWithReplacement || state.inputs.distinct().size == state.inputs.size) { + "Duplicate card without replacement" + } + + val mode: Byte = when { + source != EntropySource.CARDS -> 0 + state.cardsWithReplacement -> 1 + else -> 2 + } + val header = ByteBuffer.allocate(DOMAIN.size + 1 + 1 + 1 + Int.SIZE_BYTES) + .put(DOMAIN) + .put(VERSION) + .put(source.wireId) + .put(mode) + .putInt(state.inputs.size) + .array() + return header + state.inputs.map { it.toByte() }.toByteArray() + } + + fun normalizedHash(state: PhysicalEntropyState): ByteArray { + val serialized = canonicalSerialize(state) + return try { + MessageDigest.getInstance("SHA-256").digest(serialized) + } finally { + serialized.fill(0) + } + } + + fun hasRequiredEntropy(state: PhysicalEntropyState, wordCount: Int): Boolean { + val requiredBits = if (wordCount == 12) 128 else 256 + return state.source != null && state.bitsCollected >= requiredBits + } + + /** + * Deterministic physical-only BIP39 entropy. + * + * Coin and dice payloads deliberately match `bitcoin_seed_converter_fully_offline.html`: + * `COIN:` followed by Heads=1/Tails=0 bits, or `DICE:` followed by roll digits. Cards use the + * frozen ASCII formats `CARDS-WITH-REPLACEMENT-V1:` and + * `CARDS-WITHOUT-REPLACEMENT-V1:` followed by comma-separated, zero-padded canonical IDs. + */ + fun deterministicBip39Entropy(state: PhysicalEntropyState, wordCount: Int): ByteArray { + require(wordCount == 12 || wordCount == 24) { "Physical-only generation supports 12 or 24 words" } + require(hasRequiredEntropy(state, wordCount)) { "Insufficient physical entropy" } + + val payload = deterministicPayload(state) + var digest: ByteArray? = null + return try { + digest = MessageDigest.getInstance("SHA-256").digest(payload) + digest.copyOf(if (wordCount == 12) 16 else 32) + } finally { + payload.fill(0) + digest?.fill(0) + } + } + + internal fun deterministicPayload(state: PhysicalEntropyState): ByteArray { + val source = requireNotNull(state.source) { "Entropy source is required" } + require(state.inputs.all { isValidSymbol(source, it) }) { "Invalid entropy symbol" } + require(source != EntropySource.CARDS || state.cardsWithReplacement || state.inputs.distinct().size == state.inputs.size) { + "Duplicate card without replacement" + } + + val prefix = when (source) { + EntropySource.COIN -> "COIN:" + EntropySource.DICE -> "DICE:" + EntropySource.CARDS -> if (state.cardsWithReplacement) { + "CARDS-WITH-REPLACEMENT-V1:" + } else { + "CARDS-WITHOUT-REPLACEMENT-V1:" + } + }.toByteArray(Charsets.US_ASCII) + + val symbolSize = when (source) { + EntropySource.COIN, EntropySource.DICE -> state.inputs.size + EntropySource.CARDS -> state.inputs.size * 2 + (state.inputs.size - 1).coerceAtLeast(0) + } + return ByteArray(prefix.size + symbolSize).also { output -> + prefix.copyInto(output) + var offset = prefix.size + state.inputs.forEachIndexed { index, value -> + when (source) { + EntropySource.COIN -> output[offset++] = if (value == 0) '1'.code.toByte() else '0'.code.toByte() + EntropySource.DICE -> output[offset++] = ('0'.code + value).toByte() + EntropySource.CARDS -> { + if (index > 0) output[offset++] = ','.code.toByte() + output[offset++] = ('0'.code + value / 10).toByte() + output[offset++] = ('0'.code + value % 10).toByte() + } + } + } + } + } +} diff --git a/app/src/test/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropyTest.kt b/app/src/test/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropyTest.kt new file mode 100644 index 0000000..7c26b7d --- /dev/null +++ b/app/src/test/java/com/gorunjinian/metrovault/feature/wallet/create/PhysicalEntropyTest.kt @@ -0,0 +1,255 @@ +package com.gorunjinian.metrovault.feature.wallet.create + +import com.gorunjinian.metrovault.domain.service.bitcoin.MnemonicService +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Test +import java.security.MessageDigest +import kotlin.math.abs + +class PhysicalEntropyTest { + @Test + fun `replacement entropy is draw count times log2 52`() { + assertClose(5.700439718, PhysicalEntropy.entropyBits(EntropySource.CARDS, 1, true)) + assertClose(57.004397182, PhysicalEntropy.entropyBits(EntropySource.CARDS, 10, true)) + assertTrue(PhysicalEntropy.entropyBits(EntropySource.CARDS, 23, true) >= 128.0) + assertTrue(PhysicalEntropy.entropyBits(EntropySource.CARDS, 45, true) >= 256.0) + } + + @Test + fun `full deck without replacement is about 225 point 58 bits`() { + assertClose(225.581003124, PhysicalEntropy.entropyBits(EntropySource.CARDS, 52, false)) + } + + @Test + fun `replacement allows duplicate cards`() { + val state = PhysicalEntropyState(source = EntropySource.CARDS) + .add(17) + .add(17) + assertEquals(listOf(17, 17), state.inputs) + } + + @Test + fun `without replacement rejects duplicate cards`() { + val state = PhysicalEntropyState(source = EntropySource.CARDS, cardsWithReplacement = false) + .add(17) + .add(17) + assertEquals(listOf(17), state.inputs) + } + + @Test + fun `source and card mode changes clear the sequence safely`() { + val dice = PhysicalEntropyState().selectSource(EntropySource.DICE).add(6).add(1) + val cards = dice.selectSource(EntropySource.CARDS) + assertTrue(cards.inputs.isEmpty()) + assertTrue(cards.cardsWithReplacement) + + val withoutReplacement = cards.add(0).add(0).setCardsWithReplacement(false) + assertFalse(withoutReplacement.cardsWithReplacement) + assertTrue(withoutReplacement.inputs.isEmpty()) + + val cardsAgain = withoutReplacement.selectSource(EntropySource.DICE) + .selectSource(EntropySource.CARDS) + assertTrue(cardsAgain.cardsWithReplacement) + assertTrue(cardsAgain.inputs.isEmpty()) + } + + @Test + fun `remove last and reset work for every source`() { + EntropySource.entries.forEach { source -> + val first = if (source == EntropySource.DICE) 1 else 0 + val second = if (source == EntropySource.DICE) 6 else 1 + val state = PhysicalEntropyState(source = source).add(first).add(second) + assertEquals(listOf(first), state.removeLast().inputs) + assertTrue(state.reset().inputs.isEmpty()) + } + } + + @Test + fun `canonical coin vector is stable`() { + assertVector( + PhysicalEntropyState(EntropySource.COIN, listOf(0, 1, 1, 0, 1)), + "426974536177616e20706879736963616c20656e74726f707900010100000000050001010001", + "75b5c17b34f89bcb2f655242874925f9bc63d20d38903a585f910b53532c08af" + ) + } + + @Test + fun `canonical dice vector is stable and keeps odd final roll`() { + assertVector( + PhysicalEntropyState(EntropySource.DICE, listOf(1, 6, 3, 2, 5)), + "426974536177616e20706879736963616c20656e74726f707900010200000000050106030205", + "6776399c985ece3c9c52bd6d12a6783b674b3fbf5fc6d7cc7545dcc282a42f1d" + ) + } + + @Test + fun `canonical cards vectors distinguish replacement modes`() { + assertVector( + PhysicalEntropyState(EntropySource.CARDS, listOf(0, 51, 0), cardsWithReplacement = true), + "426974536177616e20706879736963616c20656e74726f70790001030100000003003300", + "28a491d6f95e7aeb9b83dc9248ff9583e6db7b7813b578b896aefa5623938740" + ) + assertVector( + PhysicalEntropyState(EntropySource.CARDS, listOf(0, 13, 26, 39), cardsWithReplacement = false), + "426974536177616e20706879736963616c20656e74726f70790001030200000004000d1a27", + "2ac1eaef9f2b0f0c23d2e0fb57a827d38e8ef0874efbaaedaccf5b883995ee21" + ) + } + + @Test + fun `coin and dice deterministic payloads match offline converter`() { + assertDeterministicPayload( + PhysicalEntropyState(EntropySource.COIN, listOf(0, 1, 1, 0, 1)), + "COIN:10010", + "4a6918dc3c41f732237c6bcb1fbbf5838a0bd173ff1a9df8209ba919a1424aed" + ) + assertDeterministicPayload( + PhysicalEntropyState(EntropySource.DICE, listOf(1, 6, 3, 2, 5)), + "DICE:16325", + "43ad60c536ec4ff2521f3dfc6dabf057237fa153381d9611461187e311047cbd" + ) + } + + @Test + fun `card deterministic payloads are frozen and domain separated`() { + assertDeterministicPayload( + PhysicalEntropyState(EntropySource.CARDS, listOf(0, 51, 0), cardsWithReplacement = true), + "CARDS-WITH-REPLACEMENT-V1:00,51,00", + "1af2168d2fe1a8a9950c69b946dd14ecd51b6f8f66d2d2b832a7e406a5f6c73c" + ) + assertDeterministicPayload( + PhysicalEntropyState(EntropySource.CARDS, listOf(0, 13, 26, 39), cardsWithReplacement = false), + "CARDS-WITHOUT-REPLACEMENT-V1:00,13,26,39", + "7fd828b6abe18800f26f7e3c0a060d45dda9c1d53f3098796f2a5e9fbd8c18cf" + ) + } + + @Test + fun `physical only requires the selected mnemonic entropy strength`() { + assertFalse(PhysicalEntropy.hasRequiredEntropy(PhysicalEntropyState(), 12)) + assertFalse(PhysicalEntropy.hasRequiredEntropy(PhysicalEntropyState(EntropySource.COIN, List(127) { 0 }), 12)) + assertTrue(PhysicalEntropy.hasRequiredEntropy(PhysicalEntropyState(EntropySource.COIN, List(128) { 0 }), 12)) + assertFalse(PhysicalEntropy.hasRequiredEntropy(PhysicalEntropyState(EntropySource.CARDS, List(22) { 0 }), 12)) + assertTrue(PhysicalEntropy.hasRequiredEntropy(PhysicalEntropyState(EntropySource.CARDS, List(23) { 0 }), 12)) + assertFalse( + PhysicalEntropy.hasRequiredEntropy( + PhysicalEntropyState(EntropySource.CARDS, (0..51).toList(), cardsWithReplacement = false), + 24 + ) + ) + assertThrows(IllegalArgumentException::class.java) { + PhysicalEntropy.deterministicBip39Entropy( + PhysicalEntropyState(EntropySource.COIN, List(127) { 0 }), + 12 + ) + } + } + + @Test + fun `remove at deletes only the selected captured input`() { + val state = PhysicalEntropyState(EntropySource.DICE, listOf(1, 6, 3, 6)) + + assertEquals(listOf(1, 3, 6), state.removeAt(1).inputs) + assertEquals(state, state.removeAt(-1)) + assertEquals(state, state.removeAt(4)) + } + + @Test + fun `wallet creation defaults to device mixed and gates physical only`() { + val defaultState = CreateWalletViewModel.UiState() + assertEquals(PhysicalEntropyMode.MIX_WITH_DEVICE, defaultState.physicalEntropyMode) + assertTrue(defaultState.canGenerateMnemonic) + + val insufficient = defaultState.copy( + physicalEntropyMode = PhysicalEntropyMode.PHYSICAL_ONLY, + physicalEntropy = PhysicalEntropyState(EntropySource.COIN, List(127) { 0 }) + ) + assertFalse(insufficient.canGenerateMnemonic) + assertTrue(insufficient.copy(physicalEntropy = insufficient.physicalEntropy.add(0)).canGenerateMnemonic) + } + + @Test + fun `wallet draft detection ignores untouched defaults and catches wizard progress`() { + assertFalse(CreateWalletViewModel.UiState().hasUnsavedDraft) + assertTrue(CreateWalletViewModel.UiState(currentStep = 2).hasUnsavedDraft) + assertTrue(CreateWalletViewModel.UiState(hasShownEntropyInfo = true).hasUnsavedDraft) + assertTrue(CreateWalletViewModel.UiState(wordCount = 24).hasUnsavedDraft) + assertTrue( + CreateWalletViewModel.UiState( + physicalEntropy = PhysicalEntropyState(EntropySource.DICE, listOf(6)) + ).hasUnsavedDraft + ) + } + + @Test + fun `converter compatible coin sequence produces stable 12 word mnemonic`() { + val state = PhysicalEntropyState(EntropySource.COIN, List(128) { 0 }) + val entropy = PhysicalEntropy.deterministicBip39Entropy(state, 12) + try { + assertEquals("c66758ff0cf8badb6a03c6f81f832b6e", entropy.toHex()) + assertEquals( + "shoe depart divert boring merry horror pool juice way winter skull syrup", + MnemonicService().generateMnemonicFromEntropy(12, entropy).joinToString(" ") + ) + } finally { + entropy.fill(0) + } + } + + @Test + fun `converter compatible dice sequence produces stable 24 word mnemonic`() { + val state = PhysicalEntropyState(EntropySource.DICE, List(100) { 1 }) + val entropy = PhysicalEntropy.deterministicBip39Entropy(state, 24) + try { + assertEquals("9a6bf1da5140b9ab185e407aec7e558d1326c474019bee28aca1506a58482227", entropy.toHex()) + assertEquals( + "omit garbage isolate penalty argue stereo gesture siege kit glue nice boss crash giraffe source cricket until earth choose patch pitch catch mass usual", + MnemonicService().generateMnemonicFromEntropy(24, entropy).joinToString(" ") + ) + } finally { + entropy.fill(0) + } + } + + @Test + fun `card replacement sequence produces stable 24 word mnemonic`() { + val state = PhysicalEntropyState(EntropySource.CARDS, List(45) { 0 }) + val entropy = PhysicalEntropy.deterministicBip39Entropy(state, 24) + try { + assertEquals("f3ce156c55ea5abb9dac5ef0c08555254822d271b83ad991519b23526acc06fb", entropy.toHex()) + assertEquals( + "video idle force profit pizza fruit issue mesh valid aerobic fetch enhance lion hard shoulder also sunset melody grocery effort chase gravity brief grit", + MnemonicService().generateMnemonicFromEntropy(24, entropy).joinToString(" ") + ) + } finally { + entropy.fill(0) + } + } + + private fun assertVector(state: PhysicalEntropyState, serializationHex: String, hashHex: String) { + assertArrayEquals(serializationHex.hexToBytes(), PhysicalEntropy.canonicalSerialize(state)) + assertArrayEquals(hashHex.hexToBytes(), PhysicalEntropy.normalizedHash(state)) + } + + private fun assertDeterministicPayload(state: PhysicalEntropyState, payload: String, hashHex: String) { + val bytes = PhysicalEntropy.deterministicPayload(state) + try { + assertEquals(payload, bytes.toString(Charsets.US_ASCII)) + assertEquals(hashHex, MessageDigest.getInstance("SHA-256").digest(bytes).toHex()) + } finally { + bytes.fill(0) + } + } + + private fun assertClose(expected: Double, actual: Double) { + assertTrue("expected $expected, got $actual", abs(expected - actual) < 1e-9) + } + + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/docs/SEED_GENERATION.md b/docs/SEED_GENERATION.md index 61301fd..5110853 100644 --- a/docs/SEED_GENERATION.md +++ b/docs/SEED_GENERATION.md @@ -28,7 +28,57 @@ MetroVault follows the standard Bitcoin wallet derivation process: ## Entropy Sources -MetroVault provides three methods for generating entropy: +The recommended **Device + physical** mode always obtains the full BIP39 entropy size from +`SecureRandom`. Optional physical input never replaces that device randomness. The physical +sequence is serialized and SHA-256-normalized, then `MnemonicService` computes +`SHA256(normalizedPhysicalEntropy || systemEntropy)` and uses the required 16 or 32 bytes as BIP39 +entropy. + +The explicit **Physical only (reproducible)** mode does not use `SecureRandom`. It is enabled only +after the estimated physical source reaches the selected BIP39 strength (128 bits for 12 words or +256 bits for 24 words). The resulting mnemonic is deterministic: the same source, exact sequence, +mode, and word count produce the same wallet. Hashing normalizes input; it does not increase the +entropy supplied by the user. + +### Physical entropy serialization (version 1) + +The frozen byte format is: + +```text +ASCII("BitSawan physical entropy") || 00 || version || source || mode || count_be32 || symbols +``` + +- `version`: `01` +- `source`: coin `01`, dice `02`, cards `03` +- `mode`: `00` for coin/dice, cards with replacement `01`, cards without replacement `02` +- `symbols`: coin Heads/Tails=`0/1`; dice=`1..6`; cards=`0..51` +- Card IDs are suit-major in Clubs, Diamonds, Hearts, Spades order and rank-major in + `A,2,3,4,5,6,7,8,9,10,J,Q,K` order. + +Every symbol is retained, including incomplete coin bytes and odd final dice rolls. Deterministic +vectors in `PhysicalEntropyTest` freeze both serialization and normalization. + +### Physical-only deterministic formats + +For compatibility with `bitcoin_seed_converter_fully_offline.html`, coin and dice use: + +```text +COIN: # Heads=1, Tails=0 +DICE: # each recorded result is 1..6 +``` + +Cards use comma-separated, zero-padded canonical IDs and an explicit mode/version domain: + +```text +CARDS-WITH-REPLACEMENT-V1: +CARDS-WITHOUT-REPLACEMENT-V1: +``` + +SHA-256 of the selected payload supplies 32 deterministic bytes; 12-word generation uses the +first 16 bytes, while 24-word generation uses all 32. Fixed payload, digest, entropy, and mnemonic +vectors are tested. + +BitSawan uses system entropy and supports three optional physical sources: ### 1. System Entropy (Default) @@ -55,11 +105,9 @@ For users who prefer verifiable randomness: ├─────────────────────────────────────────────────────────────────┤ │ • Heads = 0, Tails = 1 │ │ • Each flip = 1 bit of entropy │ -│ • 12-word mnemonic: requires 128 flips │ -│ • 24-word mnemonic: requires 256 flips │ -│ │ -│ Packing: 8 flips → 1 byte │ -│ Example: H,T,T,H,T,H,H,T → 0b01101001 → 0x69 │ +│ • About 128 flips supply 128 bits of physical entropy │ +│ • About 256 flips supply 256 bits of physical entropy │ +│ • Every flip is retained in the canonical serialization │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -72,24 +120,39 @@ Casino-grade dice provide excellent physical randomness: │ Dice Roll Method │ ├─────────────────────────────────────────────────────────────────┤ │ • Each roll (1-6) contributes ~2.58 bits (log₂(6)) │ -│ • Two rolls combined: (roll1 - 1) × 6 + (roll2 - 1) │ -│ • Result: 0-35 packed into one byte │ +│ • Every roll is retained in the canonical serialization │ │ │ │ 12-word mnemonic: ~50 rolls (128 bits / 2.58 bits per roll) │ │ 24-word mnemonic: ~100 rolls │ └─────────────────────────────────────────────────────────────────┘ ``` -### Entropy Mixing (When User Entropy Is Provided) +### 4. Playing Card Entropy + +The default mode draws from a standard 52-card deck **with replacement**, so repeated cards are +allowed and each independent draw contributes `log2(52) ≈ 5.70044` bits. About 23 draws reach 128 +bits and about 45 reach 256 bits. + +After recording a with-replacement draw, return that card to the bottom of the deck. Before the +next draw, cut/split the deck at unpredictable positions a randomly selected 1–5 times, choosing a +fresh number of cuts for each round. The entropy estimate assumes this makes the next card +effectively uniform; a predictable or poorly mixed deck contributes less entropy than displayed. + +Without replacement, repeated cards are rejected and the entropy estimate after `n` draws is +`log2(52! / (52-n)!)`. A complete shuffled deck has about 225.58 bits, so this mode cannot itself +supply 256 bits of physical entropy. Device randomness is added only when Seed Randomness is set +to the recommended Device + physical mode. + +### Entropy Mixing (Device + physical mode) -User entropy is **never used alone**. It is always mixed with system entropy: +In the recommended mode, user entropy is never used alone and is mixed with system entropy: ``` ┌─────────────────────────────────────────────────────────────────┐ │ Entropy Mixing Process │ ├─────────────────────────────────────────────────────────────────┤ │ │ -│ userEntropy (coin/dice) + systemEntropy (SecureRandom) │ +│ normalized physical input + systemEntropy (SecureRandom) │ │ │ │ │ │ └────────┬─────────┘ │ │ │ │ @@ -105,7 +168,9 @@ User entropy is **never used alone**. It is always mixed with system entropy: └─────────────────────────────────────────────────────────────────┘ ``` -**Security Guarantee**: Even if the user's coin flips are biased or dice are loaded, the result is cryptographically secure because system entropy is always included. +**Device + physical security guarantee**: Even if the physical source is poor, full-size system +entropy is still included. This guarantee does not apply to Physical only mode, whose security is +bounded by the actual unpredictability of the recorded sequence. ---