From 92dfef74bf6c0f2783d4a1a8b7adf652123acaab Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Wed, 4 Mar 2026 23:31:58 +0100 Subject: [PATCH] Separate service autocomplete history from password counters --- .../deterministic/DeterministicViewModel.kt | 40 ++++--- .../deterministic/ServiceCounterRepository.kt | 39 +++++++ .../deterministic/ServiceHistoryRepository.kt | 38 +++---- .../ui/navigation/AppNavigation.kt | 3 +- .../ui/screens/DeterministicScreen.kt | 101 ++++++++++++------ .../ui/screens/ExportImportScreen.kt | 45 ++++++-- .../ui/screens/ServiceHistoryScreen.kt | 21 ++-- app/src/main/res/values-cs/strings.xml | 8 +- app/src/main/res/values/strings.xml | 8 +- 9 files changed, 206 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceCounterRepository.kt diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt index 14a78b0..9a4ea3f 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt @@ -18,12 +18,12 @@ data class DeterministicState( val serviceName: String = "", val masterPassword: CharArray = CharArray(0), val masterPasswordVisible: Boolean = false, - val counter: Int = 1, val generatedPassword: String = "", val isLocked: Boolean = false, val clipboardTimerSeconds: Int = 0, val biometricEnabled: Boolean = false, val lockTimeoutMinutes: Int = 5, + val showRotateWarning: Boolean = false, ) { // CharArray doesn't support structural equality; override manually override fun equals(other: Any?): Boolean { @@ -32,24 +32,24 @@ data class DeterministicState( return serviceName == other.serviceName && masterPassword.contentEquals(other.masterPassword) && masterPasswordVisible == other.masterPasswordVisible && - counter == other.counter && generatedPassword == other.generatedPassword && isLocked == other.isLocked && clipboardTimerSeconds == other.clipboardTimerSeconds && biometricEnabled == other.biometricEnabled && - lockTimeoutMinutes == other.lockTimeoutMinutes + lockTimeoutMinutes == other.lockTimeoutMinutes && + showRotateWarning == other.showRotateWarning } override fun hashCode(): Int { var result = serviceName.hashCode() result = 31 * result + masterPassword.contentHashCode() result = 31 * result + masterPasswordVisible.hashCode() - result = 31 * result + counter result = 31 * result + generatedPassword.hashCode() result = 31 * result + isLocked.hashCode() result = 31 * result + clipboardTimerSeconds result = 31 * result + biometricEnabled.hashCode() result = 31 * result + lockTimeoutMinutes + result = 31 * result + showRotateWarning.hashCode() return result } } @@ -58,6 +58,7 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) { private val deviceSecretManager = DeviceSecretManager(app) val historyRepository = ServiceHistoryRepository(app) + val counterRepository = ServiceCounterRepository(app) private val _state = MutableStateFlow(DeterministicState()) val state: StateFlow = _state.asStateFlow() @@ -82,28 +83,43 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) { fun toggleMasterPasswordVisible() = _state.update { it.copy(masterPasswordVisible = !it.masterPasswordVisible) } - fun incrementCounter() = _state.update { it.copy(counter = it.counter + 1) } - - fun decrementCounter() = _state.update { if (it.counter > 1) it.copy(counter = it.counter - 1) else it } - - fun selectService(name: String, counter: Int) = - _state.update { it.copy(serviceName = name, counter = counter) } + fun selectService(name: String) = _state.update { it.copy(serviceName = name) } fun generate() { val s = _state.value if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return + val counter = counterRepository.getCounter(s.serviceName) val deviceSecret = deviceSecretManager.getOrCreateSecret() val password = DeterministicPasswordGenerator.generate( masterPassword = s.masterPassword, deviceSecret = deviceSecret, serviceName = s.serviceName, - counter = s.counter, + counter = counter, options = SyllableOptions(), ) - historyRepository.upsert(s.serviceName, s.counter) + historyRepository.upsert(s.serviceName) + counterRepository.upsert(s.serviceName, counter) _state.update { it.copy(generatedPassword = password) } } + fun rotatePassword() { + val s = _state.value + if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return + val newCounter = counterRepository.getCounter(s.serviceName) + 1 + counterRepository.upsert(s.serviceName, newCounter) + val deviceSecret = deviceSecretManager.getOrCreateSecret() + val password = DeterministicPasswordGenerator.generate( + masterPassword = s.masterPassword, + deviceSecret = deviceSecret, + serviceName = s.serviceName, + counter = newCounter, + options = SyllableOptions(), + ) + _state.update { it.copy(generatedPassword = password, showRotateWarning = true) } + } + + fun dismissRotateWarning() = _state.update { it.copy(showRotateWarning = false) } + fun copyAndScheduleClear(context: Context) { val password = _state.value.generatedPassword if (password.isEmpty()) return diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceCounterRepository.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceCounterRepository.kt new file mode 100644 index 0000000..2a53184 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceCounterRepository.kt @@ -0,0 +1,39 @@ +package cz.bugsy.passwordzebra.deterministic + +import android.content.Context + +class ServiceCounterRepository(context: Context) { + + private val prefs = context.getSharedPreferences("service_counters", Context.MODE_PRIVATE) + + fun getCounter(name: String): Int = + getAll().firstOrNull { it.first == name }?.second ?: 1 + + fun upsert(name: String, counter: Int) { + val entries = getAll().toMutableList() + val idx = entries.indexOfFirst { it.first == name } + if (idx >= 0) entries[idx] = name to counter else entries.add(name to counter) + save(entries) + } + + fun getAll(): List> { + val raw = prefs.getString(KEY_COUNTERS, "") ?: "" + if (raw.isBlank()) return emptyList() + return raw.split("|").mapNotNull { entry -> + val colonIdx = entry.lastIndexOf(':') + if (colonIdx < 0) return@mapNotNull null + val name = entry.substring(0, colonIdx) + val counter = entry.substring(colonIdx + 1).toIntOrNull() ?: return@mapNotNull null + name to counter + } + } + + private fun save(entries: List>) { + val raw = entries.joinToString("|") { "${it.first}:${it.second}" } + prefs.edit().putString(KEY_COUNTERS, raw).apply() + } + + companion object { + private const val KEY_COUNTERS = "counters" + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt index 5e78438..e66dfc5 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt @@ -6,37 +6,33 @@ class ServiceHistoryRepository(context: Context) { private val prefs = context.getSharedPreferences("service_history", Context.MODE_PRIVATE) - fun getServices(): List> { + fun getNames(): List { val raw = prefs.getString(KEY_SERVICES, "") ?: "" if (raw.isBlank()) return emptyList() - return raw.split("|").mapNotNull { entry -> + return raw.split("|").map { entry -> + // Migration: strip counter from old "name:counter" format val colonIdx = entry.lastIndexOf(':') - if (colonIdx < 0) return@mapNotNull null - val name = entry.substring(0, colonIdx) - val counter = entry.substring(colonIdx + 1).toIntOrNull() ?: return@mapNotNull null - name to counter - } + if (colonIdx >= 0 && entry.substring(colonIdx + 1).toIntOrNull() != null) { + entry.substring(0, colonIdx) + } else { + entry + } + }.filter { it.isNotBlank() } } - fun upsert(name: String, counter: Int) { - val services = getServices().toMutableList() - val idx = services.indexOfFirst { it.first == name } - if (idx >= 0) { - services[idx] = name to counter - } else { - services.add(0, name to counter) - } - save(services) + fun upsert(name: String) { + val names = getNames().toMutableList() + names.remove(name) + names.add(0, name) + save(names) } fun delete(name: String) { - val services = getServices().filter { it.first != name } - save(services) + save(getNames().filter { it != name }) } - private fun save(services: List>) { - val raw = services.joinToString("|") { "${it.first}:${it.second}" } - prefs.edit().putString(KEY_SERVICES, raw).apply() + private fun save(names: List) { + prefs.edit().putString(KEY_SERVICES, names.joinToString("|")).apply() } companion object { diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt index 9f64b48..0131358 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt @@ -98,13 +98,14 @@ fun AppNavigation() { viewModel = detViewModel, onNavigateToHistory = { navController.navigate(ROUTE_SERVICE_HISTORY) }, onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) }, + onNavigateToExport = { navController.navigate(ROUTE_EXPORT_IMPORT) }, ) } composable(ROUTE_SERVICE_HISTORY) { ServiceHistoryScreen( viewModel = detViewModel, onNavigateBack = { navController.popBackStack() }, - onServiceSelected = { _, _ -> navController.popBackStack() }, + onServiceSelected = { _ -> navController.popBackStack() }, ) } composable(ROUTE_SETTINGS) { diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt index d4817bd..c1df7eb 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt @@ -1,5 +1,6 @@ package cz.bugsy.passwordzebra.ui.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,36 +12,40 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -54,6 +59,7 @@ fun DeterministicScreen( viewModel: DeterministicViewModel, onNavigateToHistory: () -> Unit, onNavigateToSettings: () -> Unit, + onNavigateToExport: () -> Unit, ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -62,6 +68,50 @@ fun DeterministicScreen( return } + if (state.showRotateWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissRotateWarning() }, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { + Text( + text = stringResource(R.string.det_rotate_warning_title), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text(stringResource(R.string.det_rotate_warning_body)) + }, + confirmButton = { + Button( + onClick = { + viewModel.dismissRotateWarning() + onNavigateToExport() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.det_rotate_go_to_export)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissRotateWarning() }) { + Text( + text = stringResource(R.string.det_rotate_later), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } + Scaffold( topBar = { TopAppBar( @@ -117,36 +167,6 @@ fun DeterministicScreen( }, ) - // Counter - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.det_counter), - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { viewModel.decrementCounter() }) { - Icon( - imageVector = Icons.Default.Remove, - contentDescription = "-", - tint = MaterialTheme.colorScheme.primary, - ) - } - Text( - text = state.counter.toString(), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp), - ) - IconButton(onClick = { viewModel.incrementCounter() }) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "+", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - // Generate button Button( onClick = { viewModel.generate() }, @@ -198,10 +218,23 @@ fun DeterministicScreen( } } } + + // Rotate password button — shown only after a password has been generated + OutlinedButton( + onClick = { viewModel.rotatePassword() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Default.Warning, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.det_rotate_password)) + } } Spacer(modifier = Modifier.height(16.dp)) } } } - diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt index d920e47..080fd32 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt @@ -3,7 +3,9 @@ package cz.bugsy.passwordzebra.ui.screens import android.app.Activity import android.content.Intent import android.graphics.Bitmap +import android.net.Uri import android.util.Base64 +import org.json.JSONArray import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image @@ -68,17 +70,29 @@ fun ExportImportScreen( val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val rawValue = result.contents ?: return@rememberLauncherForActivityResult - // Parse passwordzebra://device-secret?v=1&secret= - if (rawValue.startsWith("passwordzebra://device-secret")) { - val secretParam = rawValue.substringAfter("secret=", "") - if (secretParam.isNotEmpty()) { - val decoded = try { - Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) - } catch (_: Exception) { null } - if (decoded != null && decoded.size == 32) { - pendingImportSecret = decoded - showConfirmDialog = true + val uri = Uri.parse(rawValue) + val secretParam = uri.getQueryParameter("secret") ?: "" + if (secretParam.isNotEmpty()) { + val decoded = try { + Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } catch (_: Exception) { null } + if (decoded != null && decoded.size == 32) { + pendingImportSecret = decoded + // Parse v2 services if present + val servicesParam = uri.getQueryParameter("services") + if (servicesParam != null) { + try { + val json = String(Base64.decode(servicesParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)) + val arr = JSONArray(json) + for (i in 0 until arr.length()) { + val entry = arr.getJSONArray(i) + val name = entry.getString(0) + val counter = entry.getInt(1) + viewModel.counterRepository.upsert(name, counter) + } + } catch (_: Exception) { /* ignore malformed services */ } } + showConfirmDialog = true } } } @@ -185,7 +199,16 @@ private fun ExportTab(viewModel: DeterministicViewModel) { LaunchedEffect(Unit) { val secret = viewModel.getDeviceSecret() val b64 = Base64.encodeToString(secret, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) - val uri = "passwordzebra://device-secret?v=1&secret=$b64" + val rotatedServices = viewModel.counterRepository.getAll().filter { it.second >= 2 } + val uri = if (rotatedServices.isEmpty()) { + "passwordzebra://backup?v=2&secret=$b64" + } else { + val json = JSONArray().apply { + rotatedServices.forEach { (name, counter) -> put(JSONArray().put(name).put(counter)) } + }.toString() + val servicesB64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + "passwordzebra://backup?v=2&secret=$b64&services=$servicesB64" + } qrBitmap = generateQrBitmap(uri, 512) } diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt index ed4f5b4..57fcb48 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,10 +36,10 @@ import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel fun ServiceHistoryScreen( viewModel: DeterministicViewModel, onNavigateBack: () -> Unit, - onServiceSelected: (name: String, counter: Int) -> Unit, + onServiceSelected: (name: String) -> Unit, ) { - // Read services on each recomposition so deletes reflect immediately - var services by remember { mutableStateOf(viewModel.historyRepository.getServices()) } + // Read names on each recomposition so deletes reflect immediately + var services by remember { mutableStateOf(viewModel.historyRepository.getNames()) } Scaffold( topBar = { @@ -72,13 +71,13 @@ fun ServiceHistoryScreen( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(services, key = { it.first }) { (name, counter) -> + items(services, key = { it }) { name -> Card( modifier = Modifier .fillMaxWidth() .clickable { - viewModel.selectService(name, counter) - onServiceSelected(name, counter) + viewModel.selectService(name) + onServiceSelected(name) }, ) { Row( @@ -92,15 +91,9 @@ fun ServiceHistoryScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), ) - Text( - text = "#$counter", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 8.dp), - ) IconButton(onClick = { viewModel.historyRepository.delete(name) - services = viewModel.historyRepository.getServices() + services = viewModel.historyRepository.getNames() }) { Icon( Icons.Default.Delete, diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f819d6f..02c570b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -22,12 +22,16 @@ Služba / Web Hlavní heslo Zobrazit/skrýt heslo - Čítač Možnosti Generovat Dotekem zkopírujte Historie služeb Nastavení + Heslo kompromitováno — rotovat + ⚠ Aktualizujte QR zálohu — ihned! + Pro tuto službu bylo vygenerováno nové heslo.\n\nVáš záložní QR kód tuto rotaci nezachycuje. Pokud ztratíte zařízení bez aktualizace QR kódu, TOTO HESLO BUDE TRVALE ZTRACENO — žádným jiným způsobem ho nelze obnovit.\n\nOstatní hesla nejsou dotčena. + Aktualizovat QR zálohu nyní + Udělám to později (riskantní) Historie služeb @@ -38,7 +42,7 @@ Export / Import tajemství Export Import - Naskenujte tento QR kód na jiném zařízení pro přenos device secret. Uchovejte jej v tajnosti – kdokoli s tímto kódem může znovu vygenerovat vaše hesla. + Naskenujte tento QR kód na jiném zařízení pro obnovu trezoru. Obsahuje device secret a čítače rotovaných služeb. Uchovejte jej v tajnosti – kdokoli s tímto kódem a vaším hlavním heslem může znovu vygenerovat všechna vaše hesla. QR kód pro export device secret Naskenujte QR kód exportovaný z jiného zařízení pro import device secret. Naskenovat QR kód diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22fc7e8..94c5b96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,12 +21,16 @@ Service / Website Master Password Toggle password visibility - Counter Options Generate Tap to copy Service history Settings + Password Compromised — Rotate + ⚠ Update Your QR Backup — Now! + A new password has been generated for this service.\n\nYour QR backup no longer reflects this rotation. If you lose this device without updating the QR code, THIS PASSWORD WILL BE PERMANENTLY LOST — it cannot be recovered any other way.\n\nYour other passwords are not affected. + Update QR Backup Now + I\'ll do it later (risky) Service History @@ -37,7 +41,7 @@ Export / Import Secret Export Import - Scan this QR code on another device to transfer your device secret. Keep it private — anyone with this code can regenerate your passwords. + Scan this QR code on another device to restore your vault. It contains your device secret and any rotated service counters. Keep it private — anyone with this code and your master password can regenerate all your passwords. QR code for device secret export Scan a QR code exported from another device to import its device secret. Scan QR Code