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
↓ peach-android-286146a9.tar.gzatlas at this save
files changed11 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 −0app/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 = Modifierapp/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 }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)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) {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,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 }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) {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+}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 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+}