286146a9c310

fix: Add secure PIN unlock and refine TOTP countdown (v2.14.46)

time
2026-08-14 21:46 UTC · 2 months ago
machine
koh2
parent
b9c49cac9cf8
11 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+38 −5app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+41 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+97 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+49 −2app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+0 −2app/src/main/java/com/peachpasswords/ui/vault/TagListWidgets.kt+0 −28app/src/main/java/com/peachpasswords/ui/vault/TotpInlineEdit.kt+95 −19app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+93 −0app/src/main/java/com/peachpasswords/vault/VaultStore.kt+2 −0app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+14 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.421+2.14.46

view file

app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+38 −5
@@ -132,6 +132,7 @@132132     var password by remember { mutableStateOf("") }133133     var confirm by remember { mutableStateOf("") }134134     var reveal by remember { mutableStateOf(false) }135+    var pinMode by remember(state.pinEnabled, state.pinPreferred) { mutableStateOf(state.pinPreferred) }135136     val createScore = remember(password) { scorePassword(password) }136137     // Why the create button is disabled, shown on the button itself. Without137138     // it the button just silently does nothing (browser parity: Lock.tsx's@@ -217,7 +218,13 @@217218                 modifier = Modifier.offset { IntOffset(shake.value.dp.roundToPx(), 0) },218219             ) {219220                 Row(verticalAlignment = Alignment.CenterVertically) {220-                    FieldLabel(if (state.hasVault) "master password" else "create a master password")221+                    FieldLabel(222+                        when {223+                            state.hasVault && pinMode -> "PIN"224+                            state.hasVault -> "master password"225+                            else -> "create a master password"226+                        },227+                    )221228                     if (!state.hasVault) {222229                         Spacer(Modifier.weight(1f))223230                         Text(@@ -235,10 +242,15 @@235242                 UnderlineField(236243                     value = password,237244                     onChange = { password = it; vm.clearError() },238-                    placeholder = "••••••••••",245+                    placeholder = if (pinMode) "••••" else "••••••••••",239246                     textStyle = if (state.hasVault) PeachType.unlockInput else mono18Spaced(),240247                     imeAction = if (state.hasVault) ImeAction.Go else ImeAction.Next,241-                    onImeAction = { if (state.hasVault) vm.unlock(password) },248+                    onImeAction = {249+                        if (state.hasVault) {250+                            if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)251+                        }252+                    },253+                    keyboardType = if (pinMode) KeyboardType.NumberPassword else KeyboardType.Password,242254                     masked = state.hasVault || !reveal,243255                 )244256 @@ -294,9 +306,29 @@294306                         else -> "create vault"295307                     },296308                     enabled = !state.loading && (state.hasVault || canSubmitCreateVault(password, confirm, createScore)),297-                    onClick = { if (state.hasVault) vm.unlock(password) else vm.create(password, confirm) },309+                    onClick = {310+                        if (state.hasVault) {311+                            if (pinMode) vm.unlockWithPin(password) else vm.unlock(password)312+                        } else {313+                            vm.create(password, confirm)314+                        }315+                    },298316                 )299317 318+                if (state.hasVault && state.pinEnabled) {319+                    Spacer(Modifier.height(8.dp))320+                    Text(321+                        text = if (pinMode) "Use master password" else "Use PIN",322+                        color = accent,323+                        style = PeachType.labelMono.copy(letterSpacing = 0.08.em),324+                        textAlign = TextAlign.Center,325+                        modifier = Modifier.fillMaxWidth()326+                            .clip(RoundedCornerShape(8.dp))327+                            .clickable { password = ""; pinMode = !pinMode }328+                            .padding(vertical = 8.dp),329+                    )330+                }331+300332                 if (state.hasVault && canBiometric && activity != null) {301333                     Spacer(Modifier.height(12.dp))302334                     Text(@@ -363,6 +395,7 @@363395     textStyle: TextStyle,364396     imeAction: ImeAction,365397     onImeAction: () -> Unit,398+    keyboardType: KeyboardType = KeyboardType.Password,366399     masked: Boolean = true,367400 ) {368401     val accent = LocalPeachAccent.current@@ -376,7 +409,7 @@376409         textStyle = textStyle.copy(color = peachColors.T0, textAlign = TextAlign.Center),377410         cursorBrush = SolidColor(accent),378411         visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None,379-        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),412+        keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),380413         keyboardActions = KeyboardActions(onGo = { onImeAction() }, onNext = { onImeAction() }),381414         interactionSource = interaction,382415         modifier = Modifier

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+41 −2
@@ -34,6 +34,8 @@3434 3535 data class LockUiState(3636     val hasVault: Boolean = false,37+    val pinEnabled: Boolean = false,38+    val pinPreferred: Boolean = false,3739     val isPro: Boolean = false,3840     val loading: Boolean = false,3941     val error: String? = null,@@ -92,10 +94,16 @@9294     private val lastViewed: LastViewedEntryStore,9395     private val foregroundSync: ForegroundSyncCoordinator,9496     private val syncFreshStart: SyncFreshStartResetter,97+    private val pinUnlock: com.peachpasswords.vault.PinUnlockStore,9598     @ApplicationContext private val appContext: Context,9699 ) : ViewModel() {97100     private val _state = MutableStateFlow(98-        LockUiState(hasVault = vault.hasVault(), isPro = license.isProActive()),101+        LockUiState(102+            hasVault = vault.hasVault(),103+            pinEnabled = pinUnlock.isConfigured(),104+            pinPreferred = pinUnlock.isPinPreferred(),105+            isPro = license.isProActive(),106+        ),99107     )100108     val state: StateFlow<LockUiState> = _state.asStateFlow()101109 @@ -201,7 +209,8 @@201209                                     lockTimer.start()202210                                     vault.clearUiGate()203211                                     foregroundSync.triggerForegroundSync()204-                                    _state.update { it.copy(loading = false, unlocked = true) }212+                                    pinUnlock.markPasswordUsed()213+                                    _state.update { it.copy(loading = false, unlocked = true, pinPreferred = false) }205214                                 }206215                                 is UnlockResult.Error -> _state.update {207216                                     it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)@@ -231,6 +240,36 @@231240                 // owner, covering cancellation at either await and cancellation232241                 // while the winner waits on the repository mutex.233242                 candidates.close()243+            }244+        }245+    }246+247+    fun unlockWithPin(pin: String) {248+        if (pin.isBlank() || _state.value.loading) return249+        _state.update { it.copy(loading = true, error = null) }250+        viewModelScope.launch {251+            val key = withContext(Dispatchers.Default) {252+                pinUnlock.unlockVaultKey(pin)253+            }254+            if (key == null) {255+                _state.update { it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1) }256+                return@launch257+            }258+            try {259+                when (val result = vault.unlockWithKey(key)) {260+                    is UnlockResult.Success -> {261+                        lockTimer.start()262+                        vault.clearUiGate()263+                        foregroundSync.triggerForegroundSync()264+                        pinUnlock.markPinUsed()265+                        _state.update { it.copy(loading = false, unlocked = true, pinPreferred = true) }266+                    }267+                    else -> _state.update {268+                        it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1)269+                    }270+                }271+            } finally {272+                VaultCrypto.zero(key)234273             }235274         }236275     }

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+97 −0
@@ -54,6 +54,7 @@5454 import androidx.compose.ui.text.TextStyle5555 import androidx.compose.ui.text.font.FontStyle5656 import androidx.compose.ui.text.input.PasswordVisualTransformation57+import androidx.compose.ui.text.input.KeyboardType5758 import androidx.compose.ui.unit.Dp5859 import androidx.compose.ui.unit.dp5960 import androidx.compose.ui.unit.em@@ -348,6 +349,15 @@348349 private fun ProtectionBody(fg: Color, vm: SettingsViewModel, s: VaultSettings, onNavigate: (PeachNav) -> Unit) {349350     val lockOptions = listOf(1 to "1 minute", 5 to "5 minutes", 15 to "15 minutes", 30 to "30 minutes", 0 to "Never")350351     val current = s.autoLockMinutes ?: 0352+    val pinEnabled by vm.pinEnabled.collectAsState()353+    var pinOpen by remember { mutableStateOf(false) }354+    var pinRemovalOpen by remember { mutableStateOf(false) }355+    var pinMaster by remember { mutableStateOf("") }356+    var pin by remember { mutableStateOf("") }357+    var pinConfirm by remember { mutableStateOf("") }358+    var pinRemovalMaster by remember { mutableStateOf("") }359+    var pinError by remember { mutableStateOf<String?>(null) }360+    val pinScope = rememberCoroutineScope()351361 352362     SubLabel("LOCKING", fg, top = 0.dp)353363     RowLine(fg) {@@ -360,6 +370,93 @@360370         SubtleButton(lockOptions[idx].second, fg) {361371             val next = lockOptions[(idx + 1) % lockOptions.size].first362372             vm.setAutoLockMinutes(if (next == 0) 0 else next)373+        }374+    }375+    SubLabel("PIN UNLOCK", fg)376+    RowLine(fg) {377+        Name("Unlock with PIN", fg)378+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {379+            ValueText(if (pinEnabled) "enabled" else "off", fg)380+            SubtleButton(if (pinEnabled) "change" else "set", fg) {381+                pin = ""382+                pinConfirm = ""383+                pinMaster = ""384+                pinError = null385+                pinOpen = !pinOpen386+            }387+        }388+    }389+    if (pinEnabled) {390+        RowLine(fg) {391+            Name("Remove PIN unlock", fg)392+            SubtleButton("turn off", fg) {393+                pinRemovalMaster = ""394+                pinError = null395+                pinRemovalOpen = !pinRemovalOpen396+            }397+        }398+    }399+    AnimatedVisibility(pinRemovalOpen) {400+        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {401+            PeachField(402+                label = "master password",403+                value = pinRemovalMaster,404+                onValueChange = { pinRemovalMaster = it; pinError = null },405+                keyboardType = KeyboardType.Password,406+                visualTransformation = PasswordVisualTransformation(),407+            )408+            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }409+            SubtleButton("remove PIN", fg) {410+                pinScope.launch {411+                    val error = vm.disablePin(pinRemovalMaster)412+                    if (error == null) {413+                        pinRemovalMaster = ""414+                        pinRemovalOpen = false415+                    } else {416+                        pinError = error417+                    }418+                }419+            }420+        }421+    }422+    AnimatedVisibility(pinOpen) {423+        Column(Modifier.fillMaxWidth().padding(top = PeachSpacing.sm), verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {424+            PeachField(425+                label = "master password",426+                value = pinMaster,427+                onValueChange = { pinMaster = it; pinError = null },428+                keyboardType = KeyboardType.Password,429+                visualTransformation = PasswordVisualTransformation(),430+            )431+            PeachField(432+                label = "new PIN (4–12 digits)",433+                value = pin,434+                onValueChange = { pin = it.filter(Char::isDigit).take(12); pinError = null },435+                keyboardType = KeyboardType.NumberPassword,436+                visualTransformation = PasswordVisualTransformation(),437+            )438+            PeachField(439+                label = "confirm PIN",440+                value = pinConfirm,441+                onValueChange = { pinConfirm = it.filter(Char::isDigit).take(12); pinError = null },442+                keyboardType = KeyboardType.NumberPassword,443+                visualTransformation = PasswordVisualTransformation(),444+            )445+            pinError?.let { Text(it, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 10.sp) }446+            SubtleButton("save PIN", fg) {447+                pinError = if (pin != pinConfirm) "PINs do not match" else null448+                if (pinError == null) pinScope.launch {449+                    val error = vm.savePin(pinMaster, pin)450+                    if (error == null) {451+                        pinMaster = ""452+                        pin = ""453+                        pinConfirm = ""454+                        pinOpen = false455+                    } else {456+                        pinError = error457+                    }458+                }459+            }363460         }364461     }365462     SubLabel("BIOMETRICS", fg)

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsViewModel.kt+49 −2
@@ -14,6 +14,8 @@1414 import com.peachpasswords.vault.VaultRepository1515 import com.peachpasswords.vault.VaultSnapshot1616 import com.peachpasswords.vault.VaultSettings17+import com.peachpasswords.vault.PinUnlockStore18+import com.peachpasswords.vault.pinValidationError1719 import dagger.hilt.android.lifecycle.HiltViewModel1820 import dagger.hilt.android.qualifiers.ApplicationContext1921 import kotlinx.coroutines.flow.MutableStateFlow@@ -61,12 +63,16 @@6163     private val license: LicenseRepository,6264     private val feedback: FeedbackRepository,6365     private val duress: com.peachpasswords.vault.DuressConfigStore,66+    private val pinUnlock: PinUnlockStore,6467     @ApplicationContext private val appContext: Context,6568 ) : ViewModel() {6669 6770     private val _duressConfigured = MutableStateFlow(duress.isConfigured())6871     val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()6972 73+    private val _pinEnabled = MutableStateFlow(pinUnlock.isConfigured())74+    val pinEnabled: StateFlow<Boolean> = _pinEnabled.asStateFlow()75+7076     /**7177      * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the7278      * action policy. The passphrase keys slot B directly and is never stored as a hash.@@ -324,8 +330,49 @@324330     fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }325331     fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }326332 327-    suspend fun changePassword(current: String, next: String): Boolean =328-        vault.changePassword(current, next)333+    suspend fun changePassword(current: String, next: String): Boolean {334+        val changed = vault.changePassword(current, next)335+        if (changed && pinUnlock.isConfigured()) {336+            pinUnlock.clear()337+            _pinEnabled.value = false338+        }339+        return changed340+    }341+342+    suspend fun savePin(masterPassword: String, pin: String): String? {343+        if (masterPassword.isBlank()) return "enter your master password"344+        pinValidationError(pin)?.let { return it }345+        val masterMatches = withContext(Dispatchers.Default) {346+            vault.tryUnlockSlotA(masterPassword)?.let {347+                vault.discardSlotAUnlock(it)348+                true349+            } ?: false350+        }351+        if (!masterMatches) return "incorrect master password"352+        val key = vault.currentVaultKey() ?: return "unlock the vault before setting a PIN"353+        val saved = try {354+            withContext(Dispatchers.Default) { pinUnlock.configure(pin, key) }355+        } finally {356+            VaultCrypto.zero(key)357+        }358+        if (!saved) return "couldn't enable PIN unlock"359+        _pinEnabled.value = true360+        return null361+    }362+363+    suspend fun disablePin(masterPassword: String): String? {364+        if (masterPassword.isBlank()) return "enter your master password"365+        val masterMatches = withContext(Dispatchers.Default) {366+            vault.tryUnlockSlotA(masterPassword)?.let {367+                vault.discardSlotAUnlock(it)368+                true369+            } ?: false370+        }371+        if (!masterMatches) return "incorrect master password"372+        pinUnlock.clear()373+        _pinEnabled.value = false374+        return null375+    }329376 330377     /** One-line summary shown under each card title; mirrors the browser ledes. */331378     fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {

view file

app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+0 −2
@@ -204,8 +204,6 @@204204                 }205205                 Spacer(Modifier.height(PeachSpacing.sm))206206 207-                EntryTagCards(e.tags.orEmpty())208-209207                 if (e.isCard()) {210208                     CardDetailFields(211209                         entry = e,

view file

app/src/main/java/com/peachpasswords/ui/vault/TagListWidgets.kt+0 −28
@@ -4,8 +4,6 @@44 import androidx.compose.foundation.border55 import androidx.compose.foundation.clickable66 import androidx.compose.foundation.layout.Arrangement7-import androidx.compose.foundation.layout.ExperimentalLayoutApi8-import androidx.compose.foundation.layout.FlowRow97 import androidx.compose.foundation.layout.Row108 import androidx.compose.foundation.layout.fillMaxWidth119 import androidx.compose.foundation.layout.padding@@ -92,32 +90,6 @@9290                     .clip(RoundedCornerShape(4.dp))9391                     .border(0.5.dp, peachColors.T3, RoundedCornerShape(4.dp))9492                     .padding(horizontal = 5.dp, vertical = 1.dp),95-            )96-        }97-    }98-}99-100-/** Larger read-only tag cards shown above an Entry's credential card. */101-@OptIn(ExperimentalLayoutApi::class)102-@Composable103-internal fun EntryTagCards(tags: List<String>) {104-    if (tags.isEmpty()) return105-    val accent = LocalPeachAccent.current106-    FlowRow(107-        modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 10.dp),108-        horizontalArrangement = Arrangement.spacedBy(8.dp),109-        verticalArrangement = Arrangement.spacedBy(8.dp),110-    ) {111-        tags.forEach { tag ->112-            Text(113-                text = tag,114-                color = accent,115-                style = PeachType.bodyMono,116-                modifier = Modifier117-                    .clip(RoundedCornerShape(8.dp))118-                    .background(accent.copy(alpha = 0.16f))119-                    .border(1.dp, accent.copy(alpha = 0.34f), RoundedCornerShape(8.dp))120-                    .padding(horizontal = 11.dp, vertical = 8.dp),12193             )12294         }12395     }

view file

app/src/main/java/com/peachpasswords/ui/vault/TotpInlineEdit.kt+95 −19
@@ -25,12 +25,13 @@2525  */2626 2727 import androidx.compose.animation.core.RepeatMode28+import androidx.compose.animation.core.Animatable29+import androidx.compose.animation.core.FastOutSlowInEasing30+import androidx.compose.animation.core.LinearEasing2831 import androidx.compose.animation.core.animateFloat29-import androidx.compose.animation.core.animateFloatAsState3032 import androidx.compose.animation.core.infiniteRepeatable3133 import androidx.compose.animation.core.rememberInfiniteTransition3234 import androidx.compose.animation.core.tween33-import androidx.compose.foundation.border3435 import androidx.compose.foundation.clickable3536 import androidx.compose.foundation.gestures.detectTapGestures3637 import androidx.compose.foundation.layout.Arrangement@@ -50,6 +51,7 @@5051 import androidx.compose.material3.Icon5152 import androidx.compose.material3.Text5253 import androidx.compose.runtime.Composable54+import androidx.compose.runtime.LaunchedEffect5355 import androidx.compose.runtime.getValue5456 import androidx.compose.runtime.mutableStateOf5557 import androidx.compose.runtime.produceState@@ -59,9 +61,18 @@5961 import androidx.compose.ui.Modifier6062 import androidx.compose.ui.draw.clip6163 import androidx.compose.ui.draw.drawBehind64+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas65+import androidx.compose.ui.geometry.CornerRadius6266 import androidx.compose.ui.geometry.Offset63-import androidx.compose.ui.geometry.Size67+import androidx.compose.ui.geometry.Rect68+import androidx.compose.ui.geometry.RoundRect69+import androidx.compose.ui.graphics.Path70+import androidx.compose.ui.graphics.PathEffect71+import androidx.compose.ui.graphics.Paint72+import androidx.compose.ui.graphics.PaintingStyle6473 import androidx.compose.ui.graphics.SolidColor74+import androidx.compose.ui.graphics.StrokeCap75+import androidx.compose.ui.graphics.drawscope.Stroke6576 import androidx.compose.ui.graphics.graphicsLayer6677 import androidx.compose.ui.input.pointer.pointerInput6778 import androidx.compose.ui.text.input.PasswordVisualTransformation@@ -74,12 +85,15 @@7485 import com.peachpasswords.ui.theme.peachColors7586 import com.peachpasswords.ui.theme.PeachType7687 import com.peachpasswords.vault.TOTPGenerator88+import kotlinx.coroutines.coroutineScope89+import kotlinx.coroutines.isActive90+import kotlinx.coroutines.launch7791 7892 /**7993  * Accent-tinted TOTP band with double-tap-to-edit of the underlying 2FA secret.8094  *81- * View mode is a liquid countdown card: the accent background drains right-to-left82- * over the 30-second period and refills left-to-right when the code resets. A 14dp95+ * View mode is a liquid countdown card: the accent outline drains smoothly around96+ * the 30-second period, then gently refills with a subtle edge-only flash. A 14dp8397  * pencil sits beside the code, and a double-tap anywhere on the code/band edits it.8498  *8599  * Edit mode shows a masked [BasicTextField] (eye toggle to reveal) seeded with the@@ -98,7 +112,6 @@98112  * @param onCopy    copies the live code (NOT the secret) — wire to `vm::copyTotp`.99113  * @param onSaveSecret called with the trimmed, validated new secret on ✓ save.100114  */101-@Suppress("ProduceStateDoesNotAssignValue")102115 @Composable103116 fun TotpBandEditable(104117     totpField: String,@@ -109,17 +122,43 @@109122     val accent = LocalPeachAccent.current110123     val reduceMotion = LocalReduceMotion.current111124     val surfaceCard = peachColors.SurfaceCard112-    val liquidFraction by produceState(1f, totpField) {113-        while (true) {114-            value = 1f - TOTPGenerator.periodProgress()115-            kotlinx.coroutines.delay(100)125+    val liquidFraction = remember(totpField) { Animatable(1f) }126+    val edgeFlash = remember(totpField) { Animatable(0f) }127+    LaunchedEffect(totpField, reduceMotion) {128+        if (reduceMotion) {129+            liquidFraction.snapTo(1f)130+            edgeFlash.snapTo(0f)131+        } else {132+            val periodMillis = TOTPGenerator.PERIOD_SECONDS * 1_000L133+            while (isActive) {134+                val elapsedMillis = System.currentTimeMillis() % periodMillis135+                val remainingMillis = periodMillis - elapsedMillis136+                liquidFraction.snapTo(remainingMillis.toFloat() / periodMillis)137+                liquidFraction.animateTo(138+                    targetValue = 0f,139+                    animationSpec = tween(140+                        durationMillis = remainingMillis.toInt(),141+                        easing = LinearEasing,142+                    ),143+                )144+                edgeFlash.snapTo(1f)145+                coroutineScope {146+                    launch {147+                        liquidFraction.animateTo(148+                            targetValue = 1f,149+                            animationSpec = tween(700, easing = FastOutSlowInEasing),150+                        )151+                    }152+                    launch {153+                        edgeFlash.animateTo(154+                            targetValue = 0f,155+                            animationSpec = tween(900, easing = FastOutSlowInEasing),156+                        )157+                    }158+                }159+            }116160         }117161     }118-    val animatedLiquidFraction by animateFloatAsState(119-        targetValue = liquidFraction,120-        animationSpec = tween(if (liquidFraction > 0.9f) 450 else 120),121-        label = "totp-liquid",122-    )123162 124163     var editing by remember(totpField) { mutableStateOf(false) }125164     val shape = RoundedCornerShape(8.dp)@@ -130,13 +169,50 @@130169             .padding(vertical = 10.dp)131170             .clip(shape)132171             .drawBehind {133-                drawRect(surfaceCard)134-                drawRect(172+                val radius = 8.dp.toPx()173+                val perimeter = 2f * (size.width - radius * 2f) +174+                    2f * (size.height - radius * 2f) +175+                    (2f * Math.PI.toFloat() * radius)176+                val baseStroke = 1.dp.toPx()177+                val ringPath = Path().apply {178+                    addRoundRect(RoundRect(Rect(Offset.Zero, size), radius, radius))179+                }180+                drawRoundRect(181+                    color = surfaceCard,182+                    cornerRadius = CornerRadius(radius),183+                )184+                drawRoundRect(135185                     color = accent.copy(alpha = 0.16f),136-                    size = Size(size.width * animatedLiquidFraction, size.height),186+                    cornerRadius = CornerRadius(radius),187+                    style = Stroke(width = baseStroke),137188                 )189+                drawIntoCanvas { canvas ->190+                    canvas.drawPath(191+                        ringPath,192+                        Paint().apply {193+                            color = accent.copy(alpha = 0.74f + edgeFlash.value * 0.12f)194+                            style = PaintingStyle.Stroke195+                            strokeWidth = 1.5.dp.toPx()196+                            strokeCap = StrokeCap.Round197+                            pathEffect = PathEffect.dashPathEffect(198+                                floatArrayOf((perimeter * liquidFraction.value).coerceAtLeast(0.1f), perimeter),199+                            )200+                        },201+                    )202+                }203+                if (edgeFlash.value > 0f) {204+                    drawIntoCanvas { canvas ->205+                        canvas.drawPath(206+                            ringPath,207+                            Paint().apply {208+                                color = accent.copy(alpha = edgeFlash.value * 0.16f)209+                                style = PaintingStyle.Stroke210+                                strokeWidth = 4.dp.toPx()211+                            },212+                        )213+                    }214+                }138215             }139-            .border(1.dp, accent.copy(alpha = 0.20f), shape)140216             .padding(horizontal = 14.dp, vertical = 10.dp),141217     ) {142218         if (!editing) {

view file

app/src/main/java/com/peachpasswords/vault/PinUnlockStore.kt+93 −0
@@ -0,0 +1,93 @@1+package com.peachpasswords.vault2+3+import android.content.Context4+import dagger.hilt.android.qualifiers.ApplicationContext5+import java.util.Base646+import javax.inject.Inject7+import javax.inject.Singleton8+9+internal const val PIN_MIN_LENGTH = 410+internal const val PIN_MAX_LENGTH = 1211+12+internal fun pinValidationError(pin: String): String? = when {13+    pin.length !in PIN_MIN_LENGTH..PIN_MAX_LENGTH -> "PIN must be 4–12 digits"14+    pin.any { it !in '0'..'9' } -> "PIN must contain digits only"15+    else -> null16+}17+18+/** Device-local Argon2id PIN key and AES-GCM-wrapped copy of the live vault key. */19+@Singleton20+class PinUnlockStore @Inject constructor(21+    @ApplicationContext context: Context,22+) {23+    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)24+25+    fun isConfigured(): Boolean =26+        prefs.contains(KEY_SALT) && prefs.contains(KEY_WRAPPED_KEY)27+28+    fun isPinPreferred(): Boolean = prefs.getBoolean(KEY_PREFERRED, false)29+30+    fun markPinUsed() {31+        prefs.edit().putBoolean(KEY_PREFERRED, true).apply()32+    }33+34+    fun markPasswordUsed() {35+        prefs.edit().putBoolean(KEY_PREFERRED, false).apply()36+    }37+38+    fun configure(pin: String, vaultKey: ByteArray): Boolean {39+        if (pinValidationError(pin) != null) return false40+        val salt = VaultCrypto.generateSalt()41+        val pinKey = VaultCrypto.deriveKey(pin, salt)42+        return try {43+            val wrapped = wrap(vaultKey, pinKey) ?: return false44+            prefs.edit()45+                .putString(KEY_SALT, Base64.getEncoder().withoutPadding().encodeToString(salt))46+                .putString(KEY_WRAPPED_KEY, wrapped)47+                .commit()48+        } finally {49+            VaultCrypto.zero(salt)50+            VaultCrypto.zero(pinKey)51+        }52+    }53+54+    fun unlockVaultKey(pin: String): ByteArray? {55+        if (pinValidationError(pin) != null) return null56+        val salt = prefs.getString(KEY_SALT, null)?.let(::decode) ?: return null57+        val blob = prefs.getString(KEY_WRAPPED_KEY, null)?.let(::decode) ?: return null58+        val pinKey = VaultCrypto.deriveKey(pin, salt)59+        return try {60+            unwrap(blob, pinKey)61+        } finally {62+            VaultCrypto.zero(salt)63+            VaultCrypto.zero(blob)64+            VaultCrypto.zero(pinKey)65+        }66+    }67+68+    fun clear() {69+        prefs.edit().remove(KEY_SALT).remove(KEY_WRAPPED_KEY).remove(KEY_PREFERRED).commit()70+    }71+72+    private fun wrap(vaultKey: ByteArray, pinKey: ByteArray): String? = runCatching {73+        val blob = VaultCrypto.encrypt(vaultKey, pinKey, AAD)74+        try {75+            Base64.getEncoder().withoutPadding().encodeToString(blob)76+        } finally {77+            VaultCrypto.zero(blob)78+        }79+    }.getOrNull()80+81+    private fun unwrap(blob: ByteArray, pinKey: ByteArray): ByteArray? =82+        runCatching { VaultCrypto.decrypt(blob, pinKey, AAD) }.getOrNull()83+84+    private fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)85+86+    private companion object {87+        const val PREFS = "peach_vault_store"88+        const val KEY_SALT = "peach_pin_salt"89+        const val KEY_WRAPPED_KEY = "peach_pin_wrapped_key"90+        const val KEY_PREFERRED = "peach_pin_preferred"91+        const val AAD = "pin_unlock_v1"92+    }93+}

view file

app/src/main/java/com/peachpasswords/vault/VaultStore.kt+2 −0
@@ -213,6 +213,8 @@213213         prefs.edit()214214             .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)215215             .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)216+            .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key")217+            .remove("peach_pin_preferred")216218             .commit()217219     }218220 

view file

app/src/test/java/com/peachpasswords/vault/PinUnlockStoreTest.kt+14 −0
@@ -0,0 +1,14 @@1+package com.peachpasswords.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PinUnlockStoreTest {8+    @Test fun pin_policy_requires_four_to_twelve_ascii_digits() {9+        assertEquals("PIN must be 4–12 digits", pinValidationError("123"))10+        assertEquals("PIN must contain digits only", pinValidationError("12a4"))11+        assertNull(pinValidationError("1234"))12+        assertNull(pinValidationError("123456789012"))13+    }14+}

view file