Compare commits

..

4 Commits

14 changed files with 395 additions and 328 deletions

3
.gitignore vendored
View File

@ -10,4 +10,7 @@
local.properties local.properties
.claude .claude
CLAUDE.md CLAUDE.md
keystore.properties
*.jks
*.keystore

20
LICENSE Normal file
View File

@ -0,0 +1,20 @@
Password Zebra
Copyright (C) 2024 Pavel Bubenicek
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
[Append the full text of the GNU General Public License v3 below.
Download it from: https://www.gnu.org/licenses/gpl-3.0.txt]

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# Password Zebra
An open-source Android password manager with two core features: a syllable-based random password generator and a deterministic password vault. No passwords are stored — they are derived on demand.
**Minimum Android version:** 10 (API 29)
<!-- Screenshots -->
## Features
### Random Password Generator
Generates memorable, pronounceable passwords from a syllable corpus. Options:
- Word count (110)
- Remove spaces
- Add special characters (uppercase letter, digit, special symbol inserted at random positions)
### Deterministic Password Vault
Derives passwords reproducibly from three inputs:
- **Master password** — known only to you, never stored
- **Device secret** — random key generated once and stored in Android Keystore via EncryptedSharedPreferences
- **Service name + counter** — identifies the account and allows rotation
The derivation uses **Argon2id** (memory: 64 MB, iterations: 3) so the same inputs always produce the same password, on any device that has the same device secret.
**Service history** is saved locally so you can quickly regenerate passwords for known services.
### Export / Import
Transfer your device secret and service history to another device using an encrypted QR code, secured with Android device credentials (PIN/pattern/password).
## Security
- Screen content is protected with `FLAG_SECURE` (no screenshots, no recent apps preview)
- Master password is held as `CharArray` and wiped from memory immediately after derivation
- Device secret lives exclusively in Android Keystore-backed EncryptedSharedPreferences
- No network permissions; no data leaves the device
## Build
```bash
# Debug APK
./gradlew assembleDebug
# Install on connected device
./gradlew installDebug
# Unit tests
./gradlew test
# Lint
./gradlew lint
```
Release signing requires a `keystore.properties` file at the project root:
```
storeFile=<path to .jks>
storePassword=<password>
keyAlias=<alias>
keyPassword=<password>
```
## Tech Stack
- Kotlin + Jetpack Compose + Navigation Compose
- Material 3 with dynamic color (Android 12+)
- Argon2id via Bouncy Castle (`bcprov-jdk15on`)
- EncryptedSharedPreferences (`security-crypto`)
- QR export/import via ZXing
## License
GNU General Public License v3.0 — see [LICENSE](LICENSE).

View File

@ -1,8 +1,15 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
val keystoreProps = Properties().also { props ->
val f = rootProject.file("keystore.properties")
if (f.exists()) props.load(f.inputStream())
}
android { android {
namespace = "cz.bugsy.passwordzebra" namespace = "cz.bugsy.passwordzebra"
compileSdk = 35 compileSdk = 35
@ -17,9 +24,19 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
create("release") {
storeFile = file(keystoreProps["storeFile"] as String)
storePassword = keystoreProps["storePassword"] as String
keyAlias = keystoreProps["keyAlias"] as String
keyPassword = keystoreProps["keyPassword"] as String
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@ -35,6 +52,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.10" kotlinCompilerExtensionVersion = "1.5.10"

View File

@ -2,13 +2,13 @@ package cz.bugsy.passwordzebra
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager.LayoutParams.FLAG_SECURE import android.view.WindowManager.LayoutParams.FLAG_SECURE
import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(FLAG_SECURE) window.addFlags(FLAG_SECURE)

View File

@ -19,10 +19,7 @@ data class DeterministicState(
val masterPassword: CharArray = CharArray(0), val masterPassword: CharArray = CharArray(0),
val masterPasswordVisible: Boolean = false, val masterPasswordVisible: Boolean = false,
val generatedPassword: String = "", val generatedPassword: String = "",
val isLocked: Boolean = false,
val clipboardTimerSeconds: Int = 0, val clipboardTimerSeconds: Int = 0,
val biometricEnabled: Boolean = false,
val lockTimeoutMinutes: Int = 5,
val showRotateWarning: Boolean = false, val showRotateWarning: Boolean = false,
val serviceHistory: List<String> = emptyList(), val serviceHistory: List<String> = emptyList(),
) { ) {
@ -34,10 +31,7 @@ data class DeterministicState(
masterPassword.contentEquals(other.masterPassword) && masterPassword.contentEquals(other.masterPassword) &&
masterPasswordVisible == other.masterPasswordVisible && masterPasswordVisible == other.masterPasswordVisible &&
generatedPassword == other.generatedPassword && generatedPassword == other.generatedPassword &&
isLocked == other.isLocked &&
clipboardTimerSeconds == other.clipboardTimerSeconds && clipboardTimerSeconds == other.clipboardTimerSeconds &&
biometricEnabled == other.biometricEnabled &&
lockTimeoutMinutes == other.lockTimeoutMinutes &&
showRotateWarning == other.showRotateWarning && showRotateWarning == other.showRotateWarning &&
serviceHistory == other.serviceHistory serviceHistory == other.serviceHistory
} }
@ -47,10 +41,7 @@ data class DeterministicState(
result = 31 * result + masterPassword.contentHashCode() result = 31 * result + masterPassword.contentHashCode()
result = 31 * result + masterPasswordVisible.hashCode() result = 31 * result + masterPasswordVisible.hashCode()
result = 31 * result + generatedPassword.hashCode() result = 31 * result + generatedPassword.hashCode()
result = 31 * result + isLocked.hashCode()
result = 31 * result + clipboardTimerSeconds result = 31 * result + clipboardTimerSeconds
result = 31 * result + biometricEnabled.hashCode()
result = 31 * result + lockTimeoutMinutes
result = 31 * result + showRotateWarning.hashCode() result = 31 * result + showRotateWarning.hashCode()
result = 31 * result + serviceHistory.hashCode() result = 31 * result + serviceHistory.hashCode()
return result return result
@ -66,18 +57,10 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(DeterministicState()) private val _state = MutableStateFlow(DeterministicState())
val state: StateFlow<DeterministicState> = _state.asStateFlow() val state: StateFlow<DeterministicState> = _state.asStateFlow()
private val settingsPrefs = app.getSharedPreferences("det_settings", Context.MODE_PRIVATE)
private var clipboardClearJob: Job? = null private var clipboardClearJob: Job? = null
init { init {
_state.update { _state.update { it.copy(serviceHistory = historyRepository.getNames()) }
it.copy(
biometricEnabled = settingsPrefs.getBoolean("biometric_enabled", false),
lockTimeoutMinutes = settingsPrefs.getInt("lock_timeout_minutes", 5),
serviceHistory = historyRepository.getNames(),
)
}
} }
fun updateServiceName(name: String) = _state.update { it.copy(serviceName = name.lowercase()) } fun updateServiceName(name: String) = _state.update { it.copy(serviceName = name.lowercase()) }
@ -143,27 +126,6 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
fun lock() = _state.update { it.copy(isLocked = true) }
fun unlock() = _state.update { it.copy(isLocked = false) }
fun onAppForeground(lastBackgroundTimeMs: Long) {
val timeoutMs = _state.value.lockTimeoutMinutes * 60 * 1000L
if (timeoutMs > 0 && System.currentTimeMillis() - lastBackgroundTimeMs >= timeoutMs) {
lock()
}
}
fun setBiometricEnabled(enabled: Boolean) {
settingsPrefs.edit().putBoolean("biometric_enabled", enabled).apply()
_state.update { it.copy(biometricEnabled = enabled) }
}
fun setLockTimeoutMinutes(minutes: Int) {
settingsPrefs.edit().putInt("lock_timeout_minutes", minutes).apply()
_state.update { it.copy(lockTimeoutMinutes = minutes) }
}
fun getDeviceSecret(): ByteArray = deviceSecretManager.exportSecret() fun getDeviceSecret(): ByteArray = deviceSecretManager.exportSecret()
fun importDeviceSecret(secret: ByteArray) = deviceSecretManager.importSecret(secret) fun importDeviceSecret(secret: ByteArray) = deviceSecretManager.importSecret(secret)

View File

@ -180,7 +180,6 @@ fun AppNavigation() {
popExitTransition = { slideOutHorizontally(tween(300)) { it } }, popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) { ) {
SettingsScreen( SettingsScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToExportImport = { navController.navigate(ROUTE_EXPORT_IMPORT) }, onNavigateToExportImport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
) )

View File

@ -81,10 +81,6 @@ fun DeterministicScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
BackHandler { (context as? android.app.Activity)?.finish() } BackHandler { (context as? android.app.Activity)?.finish() }
if (state.isLocked) {
LockScreen(viewModel = viewModel)
return
}
var showRotateConfirmDialog by remember { mutableStateOf(false) } var showRotateConfirmDialog by remember { mutableStateOf(false) }
val masterPasswordFocus = remember { FocusRequester() } val masterPasswordFocus = remember { FocusRequester() }

View File

@ -1,38 +1,32 @@
package cz.bugsy.passwordzebra.ui.screens package cz.bugsy.passwordzebra.ui.screens
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import org.json.JSONArray import org.json.JSONArray
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -47,8 +41,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
@ -56,17 +51,36 @@ import com.journeyapps.barcodescanner.ScanOptions
import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExportImportScreen( fun ExportImportScreen(
viewModel: DeterministicViewModel, viewModel: DeterministicViewModel,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
val context = LocalContext.current
var selectedTab by rememberSaveable { mutableIntStateOf(0) } var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var showConfirmDialog by remember { mutableStateOf(false) }
var pendingImportSecret by remember { mutableStateOf<ByteArray?>(null) } fun launchImportAuth(secret: ByteArray) {
var confirmPassword by remember { mutableStateOf("") } val activity = context as? FragmentActivity ?: return
var confirmError by remember { mutableStateOf(false) } val executor = ContextCompat.getMainExecutor(context)
val prompt = BiometricPrompt(
activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
viewModel.importDeviceSecret(secret)
}
},
)
BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(R.string.det_import_confirm_title))
.setSubtitle(context.getString(R.string.det_import_biometric_subtitle))
.setAllowedAuthenticators(AUTH)
.build()
.let { prompt.authenticate(it) }
}
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
val rawValue = result.contents ?: return@rememberLauncherForActivityResult val rawValue = result.contents ?: return@rememberLauncherForActivityResult
@ -77,8 +91,6 @@ fun ExportImportScreen(
Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
} catch (_: Exception) { null } } catch (_: Exception) { null }
if (decoded != null && decoded.size == 32) { if (decoded != null && decoded.size == 32) {
pendingImportSecret = decoded
// Parse v2 services if present
val servicesParam = uri.getQueryParameter("services") val servicesParam = uri.getQueryParameter("services")
if (servicesParam != null) { if (servicesParam != null) {
try { try {
@ -92,7 +104,7 @@ fun ExportImportScreen(
} }
} catch (_: Exception) { /* ignore malformed services */ } } catch (_: Exception) { /* ignore malformed services */ }
} }
showConfirmDialog = true launchImportAuth(decoded)
} }
} }
} }
@ -136,60 +148,6 @@ fun ExportImportScreen(
} }
} }
} }
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = {
showConfirmDialog = false
pendingImportSecret = null
confirmPassword = ""
confirmError = false
},
title = { Text(stringResource(R.string.det_import_confirm_title)) },
text = {
Column {
Text(stringResource(R.string.det_import_confirm_body))
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it; confirmError = false },
label = { Text(stringResource(R.string.det_master_password)) },
visualTransformation = PasswordVisualTransformation(),
isError = confirmError,
supportingText = if (confirmError) {
{ Text(stringResource(R.string.det_import_wrong_password)) }
} else null,
)
}
},
confirmButton = {
TextButton(onClick = {
// We just require any non-empty master password as confirmation
if (confirmPassword.isNotEmpty()) {
pendingImportSecret?.let { viewModel.importDeviceSecret(it) }
showConfirmDialog = false
pendingImportSecret = null
confirmPassword = ""
confirmError = false
} else {
confirmError = true
}
}) {
Text(stringResource(R.string.det_import_confirm))
}
},
dismissButton = {
TextButton(onClick = {
showConfirmDialog = false
pendingImportSecret = null
confirmPassword = ""
confirmError = false
}) {
Text(stringResource(R.string.cancel))
}
},
)
}
} }
@Composable @Composable

View File

@ -1,97 +0,0 @@
package cz.bugsy.passwordzebra.ui.screens
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
@Composable
fun LockScreen(viewModel: DeterministicViewModel) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
fun launchBiometric() {
val activity = context as? FragmentActivity ?: return
val executor = ContextCompat.getMainExecutor(context)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
viewModel.unlock()
}
},
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(R.string.det_biometric_prompt_title))
.setSubtitle(context.getString(R.string.det_biometric_prompt_subtitle))
.setNegativeButtonText(context.getString(R.string.cancel))
.build()
prompt.authenticate(promptInfo)
}
LaunchedEffect(Unit) {
if (state.biometricEnabled) {
launchBiometric()
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.det_locked_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.det_locked_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(32.dp))
if (state.biometricEnabled) {
Button(onClick = { launchBiometric() }) {
Text(stringResource(R.string.det_unlock_biometric))
}
}
}
}
}

View File

@ -4,7 +4,6 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -15,51 +14,55 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable 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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import cz.bugsy.passwordzebra.BuildConfig
import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: DeterministicViewModel,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToExportImport: () -> Unit, onNavigateToExportImport: () -> Unit,
) { ) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val timeoutOptions = listOf(0, 1, 5, 15) fun launchExportImportAuth() {
var timeoutDropdownExpanded by remember { mutableStateOf(false) } val activity = context as? FragmentActivity ?: return
val executor = ContextCompat.getMainExecutor(context)
fun timeoutLabel(minutes: Int): String = when (minutes) { val prompt = BiometricPrompt(
0 -> context.getString(R.string.det_timeout_off) activity, executor,
else -> context.getString(R.string.det_timeout_minutes, minutes) object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onNavigateToExportImport()
}
},
)
BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(R.string.det_export_import_title))
.setSubtitle(context.getString(R.string.det_export_locked_body))
.setAllowedAuthenticators(AUTH)
.build()
.let { prompt.authenticate(it) }
} }
Scaffold( Scaffold(
@ -84,77 +87,6 @@ fun SettingsScreen(
) { ) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Biometric toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.det_biometric_toggle),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.det_biometric_toggle_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = state.biometricEnabled,
onCheckedChange = { enabled ->
if (enabled) {
val bio = BiometricManager.from(context)
if (bio.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
) {
viewModel.setBiometricEnabled(true)
}
} else {
viewModel.setBiometricEnabled(false)
}
},
)
}
HorizontalDivider()
// Lock timeout
Text(
text = stringResource(R.string.det_lock_timeout),
style = MaterialTheme.typography.bodyLarge,
)
ExposedDropdownMenuBox(
expanded = timeoutDropdownExpanded,
onExpandedChange = { timeoutDropdownExpanded = it },
) {
OutlinedTextField(
value = timeoutLabel(state.lockTimeoutMinutes),
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeoutDropdownExpanded) },
)
ExposedDropdownMenu(
expanded = timeoutDropdownExpanded,
onDismissRequest = { timeoutDropdownExpanded = false },
) {
timeoutOptions.forEach { minutes ->
DropdownMenuItem(
text = { Text(timeoutLabel(minutes)) },
onClick = {
viewModel.setLockTimeoutMinutes(minutes)
timeoutDropdownExpanded = false
},
)
}
}
}
HorizontalDivider()
// Argon2 info // Argon2 info
Text( Text(
text = stringResource(R.string.det_argon2_info_title), text = stringResource(R.string.det_argon2_info_title),
@ -170,12 +102,77 @@ fun SettingsScreen(
// Export / Import link // Export / Import link
Button( Button(
onClick = onNavigateToExportImport, onClick = { launchExportImportAuth() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.det_export_import_title)) Text(stringResource(R.string.det_export_import_title))
} }
HorizontalDivider()
// Privacy
Text(
text = stringResource(R.string.privacy_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.privacy_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// About / License
Text(
text = stringResource(R.string.about_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_license_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_license_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_oss_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_oss_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// Contributing
Text(
text = stringResource(R.string.about_contributing_title),
style = MaterialTheme.typography.titleSmall,
)
TextButton(
onClick = { uriHandler.openUri("https://git.bugsy.cz/beval/password_zebra") },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.about_source_code))
}
TextButton(
onClick = { uriHandler.openUri("https://git.bugsy.cz/beval/password_zebra/issues") },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.about_issues))
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }

View File

@ -58,18 +58,23 @@
<!-- Nastavení --> <!-- Nastavení -->
<string name="det_settings_title">Nastavení</string> <string name="det_settings_title">Nastavení</string>
<string name="det_biometric_toggle">Biometrické odemčení</string>
<string name="det_biometric_toggle_desc">Odemkněte trezor otiskem prstu nebo tváří</string>
<string name="det_lock_timeout">Automatické zamčení</string>
<string name="det_timeout_off">Vypnuto</string>
<string name="det_timeout_minutes">%d minut</string>
<string name="det_argon2_info_title">Parametry Argon2id</string> <string name="det_argon2_info_title">Parametry Argon2id</string>
<string name="det_argon2_info_body">Paměť: 64 MB · Iterace: 3 · Paralelismus: 1 · Výstup: 64 bajtů</string> <string name="det_argon2_info_body">Paměť: 64 MB · Iterace: 3 · Paralelismus: 1 · Výstup: 64 bajtů</string>
<string name="det_export_locked_body">Ověřte se pro zobrazení QR zálohy vašeho device secret.</string>
<string name="det_import_biometric_subtitle">Potvrzení nahrazení device secret</string>
<!-- Zamčená obrazovka --> <!-- Ochrana soukromí -->
<string name="det_locked_title">Trezor zamčen</string> <string name="privacy_title">Ochrana soukromí</string>
<string name="det_locked_body">Trezor byl zamčen z důvodu nečinnosti.</string> <string name="privacy_body">Password Zebra funguje zcela offline. Žádná data nejsou sbírána, přenášena ani ukládána mimo vaše zařízení. Přístup ke kameře slouží výhradně k lokálnímu skenování QR kódů. Biometrické ověření je plně zajišťováno operačním systémem Android; tato aplikace nemá přístup k žádným biometrickým datům.</string>
<string name="det_unlock_biometric">Odemknout biometrikou</string>
<string name="det_biometric_prompt_title">Odemknout trezor</string> <!-- O aplikaci / Licence -->
<string name="det_biometric_prompt_subtitle">Ověřte se pro přístup do trezoru</string> <string name="about_title">O aplikaci</string>
<string name="about_version">Verze %s</string>
<string name="about_license_title">Licence</string>
<string name="about_license_body">Tato aplikace je svobodný software šířený pod licencí GNU General Public License verze 3 (GPL-3.0-or-later). Můžete ji šířit a/nebo upravovat za podmínek GPL vydané Free Software Foundation, verze 3 nebo (dle vaší volby) jakékoli novější verze.\n\nTento program je šířen BEZ ZÁRUKY; bez jakékoli záruky prodejnosti nebo vhodnosti pro konkrétní účel.</string>
<string name="about_oss_title">Open-source komponenty</string>
<string name="about_oss_body">Bouncy Castle (MIT) · ZXing / ZXing Android Embedded (Apache 2.0) · Jetpack Compose, AndroidX, Material 3 (Apache 2.0)</string>
<string name="about_contributing_title">Přispívání</string>
<string name="about_source_code">Zdrojový kód</string>
<string name="about_issues">Nahlásit problém</string>
</resources> </resources>

View File

@ -57,18 +57,23 @@
<!-- Settings --> <!-- Settings -->
<string name="det_settings_title">Settings</string> <string name="det_settings_title">Settings</string>
<string name="det_biometric_toggle">Biometric Unlock</string>
<string name="det_biometric_toggle_desc">Use fingerprint or face to unlock the vault</string>
<string name="det_lock_timeout">Auto-lock timeout</string>
<string name="det_timeout_off">Disabled</string>
<string name="det_timeout_minutes">%d minutes</string>
<string name="det_argon2_info_title">Argon2id Parameters</string> <string name="det_argon2_info_title">Argon2id Parameters</string>
<string name="det_argon2_info_body">Memory: 64 MB · Iterations: 3 · Parallelism: 1 · Output: 64 bytes</string> <string name="det_argon2_info_body">Memory: 64 MB · Iterations: 3 · Parallelism: 1 · Output: 64 bytes</string>
<string name="det_export_locked_body">Authenticate to view your device secret QR backup.</string>
<string name="det_import_biometric_subtitle">Confirm replacing your device secret</string>
<!-- Lock screen --> <!-- Privacy -->
<string name="det_locked_title">Vault Locked</string> <string name="privacy_title">Privacy</string>
<string name="det_locked_body">The vault was locked due to inactivity.</string> <string name="privacy_body">Password Zebra operates entirely offline. No data is collected, transmitted, or stored outside your device. Camera access is used solely to scan QR codes locally. Biometric authentication is handled entirely by the Android operating system and no biometric data is accessible to this app.</string>
<string name="det_unlock_biometric">Unlock with Biometrics</string>
<string name="det_biometric_prompt_title">Unlock Vault</string> <!-- About / License -->
<string name="det_biometric_prompt_subtitle">Authenticate to access your vault</string> <string name="about_title">About</string>
<string name="about_version">Version %s</string>
<string name="about_license_title">License</string>
<string name="about_license_body">This app is free software licensed under the GNU General Public License v3.0 (GPL-3.0-or-later). You may redistribute and/or modify it under the terms of the GPL as published by the Free Software Foundation, version 3 or (at your option) any later version.\n\nThis program is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.</string>
<string name="about_oss_title">Open-source components</string>
<string name="about_oss_body">Bouncy Castle (MIT) · ZXing / ZXing Android Embedded (Apache 2.0) · Jetpack Compose, AndroidX, Material 3 (Apache 2.0)</string>
<string name="about_contributing_title">Contributing</string>
<string name="about_source_code">Source code</string>
<string name="about_issues">Report an issue</string>
</resources> </resources>

133
docs/privacy-policy.html Normal file
View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy Password Zebra</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 24px 60px;
color: #1a1a1a;
line-height: 1.7;
}
h1 { font-size: 1.8rem; margin-bottom: 0.25em; }
h2 { font-size: 1.1rem; margin-top: 2em; color: #333; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 2.5em; }
.lang-divider {
border: none;
border-top: 2px solid #e0e0e0;
margin: 3em 0;
}
@media (prefers-color-scheme: dark) {
body { background: #121212; color: #e0e0e0; }
h2 { color: #bbb; }
.meta { color: #888; }
.lang-divider { border-color: #333; }
}
</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p class="meta">Password Zebra &nbsp;|&nbsp; Last updated: 2026-03-06</p>
<h2>Overview</h2>
<p>
Password Zebra is a fully <strong>offline</strong> application.
It does not connect to the internet, does not collect any personal data,
and does not transmit any information to external servers or third parties.
</p>
<h2>Data stored on your device</h2>
<p>The following data is stored <em>locally on your device only</em>,
encrypted via the Android Keystore system:</p>
<ul>
<li><strong>Device secret</strong> — a random value used together with your master password
to derive deterministic passwords. It never leaves the device unless you explicitly
export it via the QR backup feature.</li>
<li><strong>Service names and rotation counters</strong> — the list of services you have
used and any password rotation counts. Stored in encrypted SharedPreferences.</li>
</ul>
<p>Your master password is <strong>never stored</strong>. It is held in memory only
for the duration of password generation and immediately wiped afterwards.</p>
<h2>Camera</h2>
<p>Camera access is requested solely to scan QR codes for the import feature.
No images are saved, transmitted, or processed outside the app.</p>
<h2>Biometric authentication</h2>
<p>Biometric authentication (fingerprint, face unlock, device PIN/pattern) is handled
entirely by the Android operating system via the BiometricPrompt API.
The app receives only a success/failure result and has no access to any biometric data.</p>
<h2>Permissions</h2>
<ul>
<li><code>USE_BIOMETRIC</code> / <code>USE_FINGERPRINT</code> — biometric lock</li>
<li><code>CAMERA</code> — QR code scanning (import feature)</li>
</ul>
<p>No network, location, contacts, or storage permissions are used.</p>
<h2>Third-party libraries</h2>
<p>Password Zebra uses only open-source libraries that run locally and perform no
network activity: Bouncy Castle (cryptography), ZXing (QR codes), Jetpack / AndroidX.</p>
<h2>Contact</h2>
<p>Questions or concerns: open an issue at
<a href="https://git.bugsy.cz/beval/password_zebra/issues">git.bugsy.cz/beval/password_zebra/issues</a>.
</p>
<hr class="lang-divider">
<h1>Zásady ochrany soukromí</h1>
<p class="meta">Password Zebra &nbsp;|&nbsp; Poslední aktualizace: 2026-03-06</p>
<h2>Přehled</h2>
<p>
Password Zebra je zcela <strong>offline</strong> aplikace.
Nepřipojuje se k internetu, neshromažďuje žádné osobní údaje
a nepřenáší žádné informace na externí servery ani třetím stranám.
</p>
<h2>Data uložená ve vašem zařízení</h2>
<p>Následující data jsou uložena <em>výhradně ve vašem zařízení</em>,
šifrována prostřednictvím systému Android Keystore:</p>
<ul>
<li><strong>Device secret</strong> — náhodná hodnota používaná spolu s vaším hlavním heslem
k odvozování deterministických hesel. Zařízení nikdy neopustí, pokud jej explicitně
neexportujete prostřednictvím funkce QR zálohy.</li>
<li><strong>Názvy služeb a čítače rotací</strong> — seznam použitých služeb a případné
čítače rotace hesel. Uloženo v šifrovaných SharedPreferences.</li>
</ul>
<p>Vaše hlavní heslo <strong>není nikdy uloženo</strong>. Je drženo v paměti pouze
po dobu generování hesla a poté okamžitě vymazáno.</p>
<h2>Kamera</h2>
<p>Přístup ke kameře je vyžadován výhradně ke skenování QR kódů pro funkci importu.
Žádné snímky nejsou ukládány, přenášeny ani zpracovávány mimo aplikaci.</p>
<h2>Biometrické ověření</h2>
<p>Biometrické ověření (otisk prstu, odemčení obličejem, PIN/gesto) je plně zajišťováno
operačním systémem Android prostřednictvím rozhraní BiometricPrompt API.
Aplikace obdrží pouze výsledek úspěch/selhání a nemá přístup k žádným biometrickým datům.</p>
<h2>Oprávnění</h2>
<ul>
<li><code>USE_BIOMETRIC</code> / <code>USE_FINGERPRINT</code> — biometrický zámek</li>
<li><code>CAMERA</code> — skenování QR kódů (funkce importu)</li>
</ul>
<p>Nejsou použita žádná síťová, polohová, kontaktní ani úložišční oprávnění.</p>
<h2>Open-source knihovny</h2>
<p>Password Zebra používá pouze open-source knihovny fungující lokálně bez síťové aktivity:
Bouncy Castle (kryptografie), ZXing (QR kódy), Jetpack / AndroidX.</p>
<h2>Kontakt</h2>
<p>Dotazy nebo připomínky: otevřete issue na
<a href="https://git.bugsy.cz/beval/password_zebra/issues">git.bugsy.cz/beval/password_zebra/issues</a>.
</p>
</body>
</html>