diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 4c27c27..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Build - -# Every push builds and tests everything; a tag like v1.3.0 also publishes a -# release with the Android APK, the Linux packages and the Windows installer. -on: - push: - branches: [main] - tags: ["v*"] - pull_request: - workflow_dispatch: - -permissions: - contents: write - -jobs: - android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: 17 } - - uses: gradle/actions/setup-gradle@v4 - - name: Version from tag - shell: bash - run: | - if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi - - name: Signing key - shell: bash - env: - KEYSTORE_B64: ${{ secrets.NEXOPASS_KEYSTORE_B64 }} - run: | - # Same key as the APKs built before, so the app updates in place. - if [[ -n $KEYSTORE_B64 ]]; then - echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/release.keystore" - echo "NEXOPASS_KEYSTORE=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV" - fi - - name: Build APK - env: - NEXOPASS_KEYSTORE_PASSWORD: ${{ secrets.NEXOPASS_KEYSTORE_PASSWORD }} - NEXOPASS_KEY_ALIAS: ${{ secrets.NEXOPASS_KEY_ALIAS }} - NEXOPASS_KEY_PASSWORD: ${{ secrets.NEXOPASS_KEY_PASSWORD }} - run: ./gradlew :android:assembleRelease $GRADLE_ARGS - - name: Collect - shell: bash - run: | - mkdir dist - cp android/build/outputs/apk/release/android-release.apk "dist/NexoPass-${GITHUB_REF_NAME#v}-android.apk" - - uses: actions/upload-artifact@v4 - with: { name: android, path: dist/* } - - linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: 17 } - - uses: gradle/actions/setup-gradle@v4 - - run: sudo apt-get update && sudo apt-get install -y rpm - - name: Version from tag - shell: bash - run: | - if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi - - name: Test (passwords must match the script) - run: ./gradlew :desktop:test $GRADLE_ARGS - - name: Build Linux packages - run: ./gradlew :desktop:packageDeb :desktop:packageRpm :desktop:packageUberJarForCurrentOS $GRADLE_ARGS - - name: Collect - shell: bash - run: | - mkdir dist - cp desktop/build/compose/binaries/main/deb/*.deb dist/ - cp desktop/build/compose/binaries/main/rpm/*.rpm dist/ - cp desktop/build/compose/jars/*.jar dist/ - - uses: actions/upload-artifact@v4 - with: { name: linux, path: dist/* } - - windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: 17 } - - uses: gradle/actions/setup-gradle@v4 - - name: Version from tag - shell: bash - run: | - if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi - - name: Build installer and portable version - shell: bash - run: ./gradlew :desktop:packageMsi :desktop:createDistributable $GRADLE_ARGS - - name: Collect - shell: pwsh - run: | - New-Item -ItemType Directory dist | Out-Null - Copy-Item desktop/build/compose/binaries/main/msi/*.msi dist/ - $v = "$env:GITHUB_REF_NAME".TrimStart("v") - Compress-Archive -Path desktop/build/compose/binaries/main/app/NexoPass -DestinationPath "dist/NexoPass-$v-windows-portable.zip" - - uses: actions/upload-artifact@v4 - with: { name: windows, path: dist/* } - - release: - if: startsWith(github.ref, 'refs/tags/v') - needs: [android, linux, windows] - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v4 - with: { path: dist, merge-multiple: true } - - uses: softprops/action-gh-release@v2 - with: - files: dist/* - generate_release_notes: true diff --git a/README.md b/README.md index a191e40..2d92209 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -

- # NexoPass -Passwords derived from a master, nothing stored in plain text. For Android, Linux and Windows, and as the `nexopass` command on NixOS. +Passwords derived from a master, nothing stored in plain text. Android app, compatible with the `nexopass` script for NixOS. *Made by **Nexoniarz*** @@ -14,68 +12,29 @@ Every password is computed, never saved: password = Argon2id(master, site + version + type + login) ``` -The same master gives the same password on every device. What NexoPass does store is the site list of each account (which sites, their current version, an archive of old versions), encrypted with a key derived from that account's master. +The same master gives the same password on the phone and on the PC. What the app does store is the site list of each account (which sites, their current version, an archive of old versions), encrypted with AES-256-GCM under a key derived from that account's master. -- **Accounts.** Several accounts, each with its own master and site list. Switch in one click. -- **App unlock.** A password or PIN (on the phone also a pattern and fingerprint/face) unlocks every account; a master is only typed to create or recover an account. Too many wrong tries remove it, and you recover with the master. +- **Accounts.** Several accounts, each with its own master and site list. Switch between them in one tap. +- **App unlock.** A PIN, password or pattern (required), plus fingerprint/face if you want. It unlocks every account; the master is only typed to create or recover an account. The unlock data is encrypted twice, once with the Android Keystore (hardware), and too many wrong tries remove it. - **Data breach?** Rotate the site to a new version; the old one goes to the archive. - **Passwords** of 5-15 words or 12-64 characters, chosen per site. - **Typos** like "discrod" get a "did you mean discord?". -- **Export/import** with encrypted OpenPGP files, the same format everywhere, to move a site list between phone and PC. -- Clipboard cleared after a while (and hidden from KDE's clipboard history), auto-lock, screenshots blocked on the phone, no internet access at all. +- **Export/import**: OpenPGP files, the same format as the script, so the site list moves between phone and PC. +- No internet permission, screenshots blocked, clipboard cleared after a while, auto-lock, no cloud backup. -## Download - -Grab the files from [Releases](../../releases): - -| | File | -|---|---| -| Android 8.0+ | `NexoPass--android.apk` | -| Windows | `NexoPass-.msi` (installer) or `...-windows-portable.zip` | -| Debian/Ubuntu | `nexopass-gui__amd64.deb` | -| Fedora/openSUSE | `nexopass-gui-.x86_64.rpm` | -| NixOS | see [nexoniarz-config-nixos](https://github.com/Nexoniarz/nexoniarz-config-nixos) | - -The PC versions bring their own Java runtime: nothing else to install. - -## The PC app and the `nexopass` command - -On Linux the window and the command use the same files, so accounts, site lists and the app password are shared: - -``` -~/.local/share/nexopass/accounts//vault.gpg site list -~/.local/share/nexopass/keyring.gpg + keyring.meta app password -~/.config/nexopass/config settings -``` - -On Windows the same layout lives in `%APPDATA%\NexoPass`. - -## Project layout - -``` -shared/ code both apps compile - src/.../core/ passwords, vault, export (no UI) - src/.../app/ app state and the platform interface - src/.../ui/ Compose UI: every screen - resources/ word list, license -android/ Android app: Keystore unlock, biometrics, file pickers -desktop/ Linux/Windows app (Compose Multiplatform): script-compatible storage -``` +Android 8.0 or newer. Material 3, light and dark theme, animated background. ## Build ``` -./gradlew :android:assembleRelease # APK -./gradlew :desktop:run # run the PC app -./gradlew :desktop:packageDeb # or packageRpm, packageMsi (on Windows) -./gradlew :desktop:test # passwords must match the script +./gradlew assembleRelease ``` -GitHub Actions builds everything on each push; a `v*` tag publishes a release. +The APK lands in `app/build/outputs/apk/release/`. Release builds are signed with the debug key so they install right away; use your own key if you publish the app. ## Compatibility -`shared/src/.../core/Core.kt` must stay byte-for-byte compatible with the script: same Argon2id parameters, salts, word list (EFF Large Wordlist, checked by SHA-256), site-name rules and password layout. `CompatibilityTest` holds values produced by the script; changing any of them changes every password. +`Core.kt` must stay byte-for-byte compatible with the script: same Argon2id parameters, salts, word list (EFF Large Wordlist, checked by SHA-256), site-name rules and password layout. Changing any of them changes every password. ## License diff --git a/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt b/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt deleted file mode 100644 index b66b235..0000000 --- a/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.nexoniarz.nexopass.android - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.SharedPreferences -import android.content.pm.ApplicationInfo -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.PersistableBundle -import android.os.SystemClock -import android.provider.Settings -import android.view.WindowManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.nexoniarz.nexopass.app.AppState -import com.nexoniarz.nexopass.app.KeyValue -import com.nexoniarz.nexopass.app.LocalPlatform -import com.nexoniarz.nexopass.app.Platform -import com.nexoniarz.nexopass.app.Prefs -import com.nexoniarz.nexopass.core.AesVaultCodec -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.ui.App -import com.nexoniarz.nexopass.ui.NexoTheme -import com.nexoniarz.nexopass.ui.ParticleBackground -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -// FragmentActivity because the system biometric prompt needs one. -class MainActivity : FragmentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - // No screenshots, no preview in the recent apps screen. Debug builds allow - // screenshots so the UI can be checked during development. - if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) { - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - } - val platform = AndroidPlatform(this) - val app = AppState(platform, NexoPass.loadWords(platform.resource("eff_large_wordlist.txt"))) - setContent { - val dark = when (platform.prefs.theme) { - "dark" -> true - "light" -> false - else -> isSystemInDarkTheme() - } - CompositionLocalProvider(LocalPlatform provides platform) { - NexoTheme(dark) { - ParticleBackground(dark = dark, animated = platform.prefs.particles) { App(app) } - } - } - } - } -} - -private class SharedPrefsKV(private val sp: SharedPreferences) : KeyValue { - override fun getInt(key: String, def: Int) = sp.getInt(key, def) - override fun putInt(key: String, value: Int) = sp.edit().putInt(key, value).apply() - override fun getString(key: String, def: String) = sp.getString(key, def) ?: def - override fun putString(key: String, value: String) = sp.edit().putString(key, value).apply() - override fun getBool(key: String, def: Boolean) = sp.getBoolean(key, def) - override fun putBool(key: String, value: Boolean) = sp.edit().putBoolean(key, value).apply() -} - -class AndroidPlatform(private val activity: FragmentActivity) : Platform { - override val prefs = Prefs(SharedPrefsKV(activity.getSharedPreferences("nexopass", Context.MODE_PRIVATE))) - override val accountsDir = File(activity.filesDir, "accounts") - override val codec = AesVaultCodec - override val lock = AndroidLock(activity, activity.filesDir, prefs) - override val migrated = migrate() - override val aboutText = "Same passwords as nexopass on the PC. Nothing leaves this phone: the app has no internet permission." - - /** 1.0/1.1 kept one vault in filesDir; it becomes the account "main". */ - private fun migrate(): Boolean { - val old = File(activity.filesDir, "vault.bin") - if (!old.exists() || File(accountsDir, "main").exists()) return false - File(accountsDir, "main").mkdirs() - old.renameTo(File(accountsDir, "main/vault.bin")) - File(activity.filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak")) - lock.wipe() // the old format held a single master - prefs.currentAccount = "main" - return true - } - - override fun resource(name: String) = activity.assets.open(name).use { it.readBytes() } - - override val animationsEnabled = - Settings.Global.getFloat(activity.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f - - /** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */ - override fun copySecret(text: String, seconds: Int) { - val cm = activity.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("NexoPass", text) - clip.description.extras = PersistableBundle().apply { - putBoolean("android.content.extra.IS_SENSITIVE", true) - } - cm.setPrimaryClip(clip) - Handler(Looper.getMainLooper()).postDelayed({ - if (Build.VERSION.SDK_INT >= 28) cm.clearPrimaryClip() else cm.setPrimaryClip(ClipData.newPlainText("", "")) - }, seconds * 1000L) - } - - @Composable - override fun BackHandler(enabled: Boolean, onBack: () -> Unit) = - androidx.activity.compose.BackHandler(enabled, onBack) - - @Composable - override fun AutoLock(app: AppState) { - val owner = LocalLifecycleOwner.current - DisposableEffect(owner) { - var stoppedAt = 0L - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> stoppedAt = SystemClock.elapsedRealtime() - Lifecycle.Event.ON_START -> { - val limit = app.prefs.autoLockMinutes * 60_000L - // Never lock in the middle of the required app-unlock setup. - if (stoppedAt > 0 && !app.needsAppSetup && SystemClock.elapsedRealtime() - stoppedAt >= limit) app.lock() - } - else -> {} - } - } - owner.lifecycle.addObserver(observer) - onDispose { owner.lifecycle.removeObserver(observer) } - } - } - - @Composable - override fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (String) -> Unit { - val scope = rememberCoroutineScope() - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - scope.launch { - val ok = withContext(Dispatchers.IO) { - runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(content()) } }.isSuccess - } - onDone(ok) - } - } - return { launcher.launch(it) } - } - - @Composable - override fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit { - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - onBytes(runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()) - } - return { launcher.launch(arrayOf("*/*")) } - } -} diff --git a/android/build.gradle.kts b/app/build.gradle.kts similarity index 62% rename from android/build.gradle.kts rename to app/build.gradle.kts index 1057c2c..bfad54f 100644 --- a/android/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,38 +12,17 @@ android { applicationId = "com.nexoniarz.nexopass" minSdk = 26 targetSdk = 35 - val v = rootProject.extra["appVersion"] as String - versionName = v - // 1.3.0 -> 10300 - versionCode = v.split(".").map { it.toInt() }.let { it[0] * 10000 + it[1] * 100 + it[2] } - } - - sourceSets["main"].apply { - java.srcDirs("src/main/java", "../shared/src") - assets.srcDirs("../shared/resources") - } - - signingConfigs { - // CI signs with the same key as local builds (from secrets), so the - // app updates in place. Without it, the debug key is used. - create("release") { - val ks = System.getenv("NEXOPASS_KEYSTORE") - if (ks != null) { - storeFile = file(ks) - storePassword = System.getenv("NEXOPASS_KEYSTORE_PASSWORD") - keyAlias = System.getenv("NEXOPASS_KEY_ALIAS") - keyPassword = System.getenv("NEXOPASS_KEY_PASSWORD") - } else { - initWith(getByName("debug")) - } - } + versionCode = 3 + versionName = "1.2.0" } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - signingConfig = signingConfigs.getByName("release") + // Signed with the debug key so a release build installs right away. + // Replace with your own key if you ever publish the app. + signingConfig = signingConfigs.getByName("debug") } } compileOptions { diff --git a/android/proguard-rules.pro b/app/proguard-rules.pro similarity index 100% rename from android/proguard-rules.pro rename to app/proguard-rules.pro diff --git a/android/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml similarity index 95% rename from android/src/main/AndroidManifest.xml rename to app/src/main/AndroidManifest.xml index 5e1ed4e..5fda0b0 100644 --- a/android/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:supportsRtl="true" android:theme="@style/Theme.NexoPass"> diff --git a/shared/resources/LICENSE b/app/src/main/assets/LICENSE similarity index 100% rename from shared/resources/LICENSE rename to app/src/main/assets/LICENSE diff --git a/shared/resources/eff_large_wordlist.txt b/app/src/main/assets/eff_large_wordlist.txt similarity index 100% rename from shared/resources/eff_large_wordlist.txt rename to app/src/main/assets/eff_large_wordlist.txt diff --git a/shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt b/app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt similarity index 94% rename from shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt rename to app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt index c1ef1d4..b52e156 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt @@ -1,7 +1,4 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable @@ -19,9 +16,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -163,24 +162,21 @@ fun StrengthBar(s: NexoPass.Strength) { /** Required right after unlocking with a master: the app unlock replaces typing the master. */ @Composable fun AppUnlockSetupScreen(app: AppState) { + val activity = LocalContext.current as FragmentActivity val scope = rememberCoroutineScope() var method by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var askBio by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - val bioOptions = remember { app.lock.bioOptions() } + val bioOptions = remember { QuickUnlock.bioOptions(activity) } CenteredColumn { Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.") GlassCard { if (!askBio) { - app.lock.methods.forEach { m -> - when (m) { - "pin" -> MethodCard(Icons.Rounded.Pin, "PIN", "Quick to type.") { method = m } - "password" -> MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = m } - "pattern" -> MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = m } - } - } + MethodCard(Icons.Rounded.Pin, "PIN", "Fast. 4+ digits, 6 is better.") { method = "pin" } + MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = "password" } + MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = "pattern" } Text( "Too many wrong tries remove it; then you recover with your master. Keep the master in your head.", style = MaterialTheme.typography.bodySmall, @@ -190,13 +186,13 @@ fun AppUnlockSetupScreen(app: AppState) { } else { Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium) Text( - "Your ${methodName(app.lock.method)} stays as the backup.", + "Your ${methodName(app.quick.method)} stays as the backup.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), ) GradientButton("Turn on", enabled = true) { - app.lock.enableBio(bioOptions.first(), app.innerKey()) { err -> + app.quick.enableBio(activity, bioOptions.first(), app.innerKey()) { err -> if (err == null) app.finishAppSetup() else if (err.isNotEmpty()) error = err } } diff --git a/shared/src/com/nexoniarz/nexopass/ui/Components.kt b/app/src/main/java/com/nexoniarz/nexopass/Components.kt similarity index 81% rename from shared/src/com/nexoniarz/nexopass/ui/Components.kt rename to app/src/main/java/com/nexoniarz/nexopass/Components.kt index 2eab0e8..08c11bb 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/Components.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Components.kt @@ -1,8 +1,12 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PersistableBundle import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -109,3 +113,16 @@ fun EmptyState(text: String) { } fun modeLabel(mode: String) = NexoPass.wordCount(mode)?.let { "$it words" } ?: "${mode.removePrefix("chars")} chars" + +/** Copies without a clipboard preview or keyboard history, and clears it after [seconds]. */ +fun copySecret(context: Context, text: String, seconds: Int) { + val cm = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("NexoPass", text) + clip.description.extras = PersistableBundle().apply { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + cm.setPrimaryClip(clip) + Handler(Looper.getMainLooper()).postDelayed({ + if (Build.VERSION.SDK_INT >= 28) cm.clearPrimaryClip() else cm.setPrimaryClip(ClipData.newPlainText("", "")) + }, seconds * 1000L) +} diff --git a/shared/src/com/nexoniarz/nexopass/core/Core.kt b/app/src/main/java/com/nexoniarz/nexopass/Core.kt similarity index 84% rename from shared/src/com/nexoniarz/nexopass/core/Core.kt rename to app/src/main/java/com/nexoniarz/nexopass/Core.kt index 936fd62..c9c84f6 100644 --- a/shared/src/com/nexoniarz/nexopass/core/Core.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Core.kt @@ -1,4 +1,4 @@ -package com.nexoniarz.nexopass.core +package com.nexoniarz.nexopass import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.params.Argon2Parameters @@ -141,19 +141,16 @@ object NexoPass { .toList() } - fun derive(master: ByteArray, salt: String, bytes: Int) = argon2(master, salt, ARGON_T, ARGON_M_POW, bytes) - - /** Argon2id like the `argon2` command: salt as text, memory 2^[memPow] KiB, one lane. */ - fun argon2(password: ByteArray, salt: String, iterations: Int, memPow: Int, bytes: Int): ByteArray { + fun derive(master: ByteArray, salt: String, bytes: Int): ByteArray { val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) .withVersion(Argon2Parameters.ARGON2_VERSION_13) - .withIterations(iterations) - .withMemoryPowOfTwo(memPow) + .withIterations(ARGON_T) + .withMemoryPowOfTwo(ARGON_M_POW) .withParallelism(ARGON_P) .withSalt(salt.toByteArray()) .build() val out = ByteArray(bytes) - Argon2BytesGenerator().apply { init(params) }.generateBytes(password, out) + Argon2BytesGenerator().apply { init(params) }.generateBytes(master, out) return out } @@ -200,7 +197,8 @@ object NexoPass { fun vaultKey(master: ByteArray) = derive(master, "$SALT_PREFIX:vault", 32) /** OpenPGP passphrase for export files, the same one the script uses. */ - fun exportPassphrase(master: ByteArray) = hex(derive(master, "$SALT_PREFIX:export", 32)).toCharArray() + fun exportPassphrase(master: ByteArray) = + derive(master, "$SALT_PREFIX:export", 32).joinToString("") { "%02x".format(it) }.toCharArray() fun newMaster(words: List, count: Int = 6): String { val rnd = SecureRandom() @@ -222,55 +220,11 @@ data class Rec( fun same(o: Rec) = site == o.site && user == o.user } -/** How one account's site list is turned into bytes on disk. */ -interface VaultCodec { - /** File name inside the account folder. */ - val fileName: String - fun encode(key: ByteArray, text: String): ByteArray - /** null when the key is wrong or the data is damaged. */ - fun decode(key: ByteArray, data: ByteArray): String? -} - -/** Phone format: AES-256-GCM, "NXP1" + IV + ciphertext. */ -object AesVaultCodec : VaultCodec { - private val MAGIC = "NXP1".toByteArray() - override val fileName = "vault.bin" - - override fun encode(key: ByteArray, text: String): ByteArray { - val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } - return MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, key, iv).doFinal(text.toByteArray()) - } - - override fun decode(key: ByteArray, data: ByteArray): String? { - if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return null - return try { - cipher(Cipher.DECRYPT_MODE, key, data.copyOfRange(MAGIC.size, MAGIC.size + 12)) - .doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12).decodeToString() - } catch (e: javax.crypto.AEADBadTagException) { - null - } - } - - private fun cipher(mode: Int, key: ByteArray, iv: ByteArray) = Cipher.getInstance("AES/GCM/NoPadding").apply { - init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) - updateAAD(MAGIC) - } -} - -/** Script format: OpenPGP (AES-256), passphrase = the vault key as hex, like `gpg` in nexopass. */ -object PgpVaultCodec : VaultCodec { - override val fileName = "vault.gpg" - override fun encode(key: ByteArray, text: String) = Export.encrypt(hex(key).toCharArray(), text.toByteArray()) - override fun decode(key: ByteArray, data: ByteArray) = Export.decrypt(hex(key).toCharArray(), data)?.decodeToString() -} - -fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } - /** - * Site list with version archive, encrypted with a key derived from the - * master. Holds no passwords, only what is needed to derive them again. + * Site list with version archive, AES-256-GCM encrypted with a key derived from + * the master. Holds no passwords, only what is needed to derive them again. */ -class Vault(private val file: File, private val key: ByteArray, private val codec: VaultCodec) { +class Vault(private val file: File, private val key: ByteArray) { val records = mutableListOf() fun exists() = file.exists() @@ -279,13 +233,22 @@ class Vault(private val file: File, private val key: ByteArray, private val code fun load(): Boolean { records.clear() if (!file.exists()) return true - val text = codec.decode(key, file.readBytes()) ?: return false - records += parse(text) + val data = file.readBytes() + if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return false + val plain = try { + cipher(Cipher.DECRYPT_MODE, data.copyOfRange(MAGIC.size, MAGIC.size + 12)) + .doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12) + } catch (e: javax.crypto.AEADBadTagException) { + return false + } + records += parse(plain.decodeToString()) return true } fun save() { - val out = codec.encode(key, text(records)) + val text = text(records) + val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } + val out = MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, iv).doFinal(text.toByteArray()) file.parentFile?.mkdirs() val tmp = File(file.parentFile, file.name + ".tmp") tmp.writeBytes(out) @@ -293,6 +256,11 @@ class Vault(private val file: File, private val key: ByteArray, private val code check(tmp.renameTo(file)) { "Could not save the vault" } } + private fun cipher(mode: Int, iv: ByteArray) = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + updateAAD(MAGIC) + } + fun current(site: String, user: String) = records.firstOrNull { it.site == site && it.user == user && it.current } fun currentList() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user })) @@ -337,6 +305,7 @@ class Vault(private val file: File, private val key: ByteArray, private val code private fun today() = LocalDate.now().toString() companion object { + private val MAGIC = "NXP1".toByteArray() private const val HEADER = "# nexopass vault v1" private const val SEP = '\u001f' diff --git a/shared/src/com/nexoniarz/nexopass/core/Export.kt b/app/src/main/java/com/nexoniarz/nexopass/Export.kt similarity index 98% rename from shared/src/com/nexoniarz/nexopass/core/Export.kt rename to app/src/main/java/com/nexoniarz/nexopass/Export.kt index 981f967..aa379c4 100644 --- a/shared/src/com/nexoniarz/nexopass/core/Export.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Export.kt @@ -1,4 +1,4 @@ -package com.nexoniarz.nexopass.core +package com.nexoniarz.nexopass import org.bouncycastle.bcpg.HashAlgorithmTags import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags diff --git a/shared/src/com/nexoniarz/nexopass/ui/Inputs.kt b/app/src/main/java/com/nexoniarz/nexopass/Inputs.kt similarity index 95% rename from shared/src/com/nexoniarz/nexopass/ui/Inputs.kt rename to app/src/main/java/com/nexoniarz/nexopass/Inputs.kt index 7ce30ab..879a300 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/Inputs.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Inputs.kt @@ -1,7 +1,4 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures @@ -237,10 +234,16 @@ fun SecretEntry(method: String, enabled: Boolean, clearKey: Any, onSubmit: (Stri } } +/** Minimum for each method; null when fine. */ +fun secretProblem(method: String, s: String) = when (method) { + "pin" -> if (s.length < 4) "Use at least 4 digits (6 is better)." else null + "pattern" -> if (s.length < 4) "Connect at least 4 dots." else null + else -> if (s.length < 6) "Use at least 6 characters." else null +} + /** New PIN/password/pattern: enter it, then repeat it. */ @Composable fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) { - val lock = LocalPlatform.current.lock var first by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var attempt by remember { mutableIntStateOf(0) } @@ -252,7 +255,8 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> style = MaterialTheme.typography.headlineSmall, ) Text( - "Opens NexoPass and all your accounts. Your masters keep working for recovery.", + if (method == "pin") "Unlocks NexoPass on this phone. Your master keeps working too." + else "Used to unlock NexoPass on this phone.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), @@ -262,7 +266,7 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> attempt++ val f = first when { - f == null -> lock.problem(method, s)?.let { error = it } ?: run { first = s; error = null } + f == null -> secretProblem(method, s)?.let { error = it } ?: run { first = s; error = null } f != s -> { first = null; error = "They didn't match, start again." } else -> onDone(s) } diff --git a/shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt b/app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt similarity index 92% rename from shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt rename to app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt index a7b3923..d5a3c7f 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt @@ -1,7 +1,4 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* @@ -11,15 +8,18 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable fun LockScreen(app: AppState) { + val activity = LocalContext.current as FragmentActivity val scope = rememberCoroutineScope() - val quick = app.lock + val quick = app.quick // Without an app unlock (update, too many wrong tries) or on request: a master. var recover by remember { mutableStateOf(!quick.isSet) } var account by remember { mutableStateOf(app.current) } @@ -33,12 +33,12 @@ fun LockScreen(app: AppState) { scope.launch { val r = withContext(Dispatchers.Default) { quick.unlock(secret) } when (r) { - is UnlockResult.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) } - is UnlockResult.Wrong -> { + is QuickUnlock.Result.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) } + is QuickUnlock.Result.Wrong -> { attempt++ error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." } - UnlockResult.Wiped -> { + QuickUnlock.Result.Wiped -> { error = "Too many wrong attempts: the app unlock was removed. Recover with your master." recover = true } @@ -49,7 +49,7 @@ fun LockScreen(app: AppState) { fun unlockBio() { error = null - quick.unlockBio("Use ${methodName(quick.method)}", onOk = { r -> + quick.unlockBio(activity, "Use ${methodName(quick.method)}", onOk = { r -> busy = true scope.launch { withContext(Dispatchers.Default) { app.unlockApp(r) } diff --git a/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt b/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt new file mode 100644 index 0000000..09c6617 --- /dev/null +++ b/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt @@ -0,0 +1,349 @@ +package com.nexoniarz.nexopass + +import android.content.pm.ApplicationInfo +import android.os.Bundle +import android.os.SystemClock +import android.view.WindowManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.* +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// FragmentActivity because the system biometric prompt needs one. +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + // No screenshots, no preview in the recent apps screen. Debug builds allow + // screenshots so the UI can be checked during development. + if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) { + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } + val words = NexoPass.loadWords(assets.open("eff_large_wordlist.txt").use { it.readBytes() }) + val prefs = Prefs(this) + val app = AppState(filesDir, words, prefs) + setContent { + val dark = when (prefs.theme) { + "dark" -> true + "light" -> false + else -> isSystemInDarkTheme() + } + NexoTheme(dark) { + ParticleBackground(dark = dark, animated = prefs.particles) { App(app) } + } + } + } +} + +/** + * The whole app: accounts on disk, the app unlock, and the keyring (every + * account's vault key and master) while unlocked. Nothing secret is written + * anywhere except encrypted. + */ +class AppState(filesDir: File, val words: List, val prefs: Prefs) { + private val accountsDir = File(filesDir, "accounts") + val quick = QuickUnlock(filesDir, prefs) + + var accounts by mutableStateOf(listOf()) + private set + var appUnlocked by mutableStateOf(false) + private set + /** Unlocked with a master (recovery, first run, update): an app unlock must be set now. */ + var needsAppSetup by mutableStateOf(false) + private set + var session by mutableStateOf(null) + private set + /** Set when the account's master isn't in the keyring yet. */ + var needsMaster by mutableStateOf(null) + private set + /** True after moving a pre-accounts vault into the account "main". */ + var migrated by mutableStateOf(false) + private set + var firstRunCheck by mutableStateOf(null) + + private var keyring: MutableMap = LinkedHashMap() + private var innerKey: ByteArray? = null + + init { + // 1.0/1.1 kept one vault in filesDir; it becomes the account "main". + val old = File(filesDir, "vault.bin") + if (old.exists() && !File(accountsDir, "main").exists()) { + File(accountsDir, "main").mkdirs() + old.renameTo(File(accountsDir, "main/vault.bin")) + File(filesDir, "vault.bin.bak").takeIf { it.exists() }?.renameTo(File(accountsDir, "main/vault.bin.bak")) + quick.wipe() // the old format held a single master + prefs.currentAccount = "main" + migrated = true + } + refreshAccounts() + } + + val current get() = prefs.currentAccount + + fun refreshAccounts() { + accounts = accountsDir.listFiles()?.filter { File(it, "vault.bin").exists() }?.map { it.name }?.sorted() ?: emptyList() + if (accounts.isNotEmpty() && current !in accounts) prefs.currentAccount = accounts.first() + } + + private fun vaultFile(name: String) = File(accountsDir, "$name/vault.bin") + + /** After the PIN/password/pattern or biometrics. */ + fun unlockApp(r: QuickUnlock.Result.Ok) { + innerKey = r.innerKey + keyring = r.keyring + needsAppSetup = false + openAccount(current) + appUnlocked = true + } + + /** + * Opens an account with its master (recovery, update, or an account not in + * the keyring yet). Slow. False when the master doesn't open it. + */ + fun unlockWithMaster(name: String, master: ByteArray): Boolean { + val u = Unlocked(NexoPass.vaultKey(master), master) + if (!Vault(vaultFile(name), u.vaultKey).load()) return false + keyring[name] = u + if (innerKey == null) needsAppSetup = true else saveKeyring() + needsMaster = null + open(name, u) + appUnlocked = true + return true + } + + /** New account: empty vault, master into the keyring. Slow. */ + fun createAccount(name: String, master: ByteArray) { + val u = Unlocked(NexoPass.vaultKey(master), master) + Vault(vaultFile(name), u.vaultKey).save() + keyring[name] = u + refreshAccounts() + firstRunCheck = NexoPass.checkWords(master, words) + if (innerKey == null) needsAppSetup = true else saveKeyring() + needsMaster = null + open(name, u) + appUnlocked = true + } + + fun openAccount(name: String) { + prefs.currentAccount = name + session?.close() + session = null + val u = keyring[name] + needsMaster = if (u != null && open(name, u)) null else name + } + + private fun open(name: String, u: Unlocked): Boolean { + val s = Session(name, vaultFile(name), words, prefs, u) + if (!s.load()) return false + session?.close() + session = s + prefs.currentAccount = name + return true + } + + /** Slow (Argon2). */ + fun setupApp(method: String, secret: String) { + innerKey = quick.setup(method, secret, keyring) + } + + fun finishAppSetup() { + needsAppSetup = false + } + + fun innerKey() = innerKey!! + + fun masterOf(name: String) = keyring[name]?.master?.decodeToString() + + fun renameAccount(old: String, new: String) { + session?.close() + session = null + File(accountsDir, old).renameTo(File(accountsDir, new)) + keyring.remove(old)?.let { keyring[new] = it } + saveKeyring() + refreshAccounts() + openAccount(new) + } + + /** True when [master] opens [name]. Slow. */ + fun checkMaster(name: String, master: ByteArray) = Vault(vaultFile(name), NexoPass.vaultKey(master)).load() + + fun detachAccount(name: String) { + if (session?.name == name) { + session?.close() + session = null + } + File(accountsDir, name).deleteRecursively() + keyring.remove(name)?.let { it.master.fill(0); it.vaultKey.fill(0) } + saveKeyring() + refreshAccounts() + if (accounts.isEmpty()) { + // Nothing left: start over. + quick.wipe() + lock() + } else { + openAccount(accounts.first()) + } + } + + private fun saveKeyring() { + innerKey?.let { quick.save(it, keyring) } + } + + fun lock() { + session?.close() + session = null + keyring.values.forEach { it.master.fill(0); it.vaultKey.fill(0) } + keyring = LinkedHashMap() + innerKey?.fill(0) + innerKey = null + needsMaster = null + needsAppSetup = false + appUnlocked = false + migrated = false + } +} + +/** One open account. Nothing here is written to disk except its vault. */ +class Session(val name: String, file: File, val words: List, val prefs: Prefs, u: Unlocked) { + private val master = u.master.copyOf() + private val vault = Vault(file, u.vaultKey.copyOf()) + private val cache = ConcurrentHashMap() + var records by mutableStateOf(listOf()) + private set + + fun load(): Boolean { + if (!vault.load()) return false + records = vault.records.toList() + return true + } + + fun close() { + master.fill(0) + cache.clear() + } + + val current get() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user })) + + fun password(r: Rec) = cache.getOrPut(r) { + NexoPass.password(master, words, r.site, r.user, r.version, r.mode) + } + + fun checkWords() = NexoPass.checkWords(master, words) + + fun add(site: String, user: String, version: Int, mode: String) = edit { add(site, user, version, mode) } + fun rotate(r: Rec, mode: String) = edit { rotate(r, mode) } + fun remove(r: Rec) = edit { remove(r); r } + fun removeVersion(r: Rec) = edit { removeVersion(r); r } + + /** Encrypted copy of the site list; slow (Argon2). */ + fun exportBytes(): ByteArray = + Export.encrypt(NexoPass.exportPassphrase(master), Vault.text(vault.records).toByteArray()) + + /** Merges an export into the list. Number of new entries, or null if it doesn't open. Slow. */ + fun importBytes(data: ByteArray): Int? { + val plain = Export.decrypt(NexoPass.exportPassphrase(master), data) ?: return null + val n = vault.mergeIn(Vault.parse(plain.decodeToString())) + records = vault.records.toList() + return n + } + + private fun edit(block: Vault.() -> Rec): Rec { + val r = vault.block() + records = vault.records.toList() + return r + } +} + +sealed interface Screen { + data object List : Screen + data object Add : Screen + data object Settings : Screen + data object NewAccount : Screen + data class Detail(val site: String, val user: String) : Screen +} + +@Composable +private fun App(app: AppState) { + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + var stoppedAt = 0L + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> stoppedAt = SystemClock.elapsedRealtime() + Lifecycle.Event.ON_START -> { + val limit = app.prefs.autoLockMinutes * 60_000L + // Never lock in the middle of the required app-unlock setup. + if (stoppedAt > 0 && !app.needsAppSetup && SystemClock.elapsedRealtime() - stoppedAt >= limit) app.lock() + } + else -> {} + } + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + val stage = when { + !app.appUnlocked -> 0 + app.needsAppSetup -> 1 + else -> 2 + } + AnimatedContent( + targetState = stage, + transitionSpec = { (fadeIn(tween(400)) + scaleIn(initialScale = .96f)) togetherWith fadeOut(tween(200)) }, + label = "stage", + ) { st -> + when (st) { + 0 -> LockScreen(app) + 1 -> AppUnlockSetupScreen(app) + else -> UnlockedApp(app) + } + } +} + +@Composable +private fun UnlockedApp(app: AppState) { + var screen by remember { mutableStateOf(Screen.List) } + BackHandler(screen != Screen.List) { screen = Screen.List } + val needs = app.needsMaster + val session = app.session + if (screen == Screen.NewAccount) { + NewAccountScreen(app, onBack = { screen = Screen.List }, onDone = { screen = Screen.List }) + return + } + if (needs != null || session == null) { + AccountMasterScreen(app, needs ?: app.current, onAddAccount = { screen = Screen.NewAccount }) + return + } + AnimatedContent( + targetState = screen, + transitionSpec = { + val forward = targetState != Screen.List + (slideInHorizontally { if (forward) it / 4 else -it / 4 } + fadeIn()) togetherWith + (slideOutHorizontally { if (forward) -it / 4 else it / 4 } + fadeOut()) + }, + label = "screen", + ) { s -> + when (s) { + Screen.List -> ListScreen( + app, session, + onOpen = { screen = Screen.Detail(it.site, it.user) }, + onAdd = { screen = Screen.Add }, + onSettings = { screen = Screen.Settings }, + onNewAccount = { screen = Screen.NewAccount }, + ) + Screen.Add -> AddScreen(session, onBack = { screen = Screen.List }, onOpen = { screen = Screen.Detail(it.site, it.user) }) + Screen.Settings -> SettingsScreen(app, session, onBack = { screen = Screen.List }) + Screen.NewAccount -> {} + is Screen.Detail -> DetailScreen(session, s.site, s.user, onBack = { screen = Screen.List }) + } + } +} diff --git a/shared/src/com/nexoniarz/nexopass/ui/Particles.kt b/app/src/main/java/com/nexoniarz/nexopass/Particles.kt similarity index 91% rename from shared/src/com/nexoniarz/nexopass/ui/Particles.kt rename to app/src/main/java/com/nexoniarz/nexopass/Particles.kt index f626f8e..52b0023 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/Particles.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Particles.kt @@ -1,8 +1,6 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass +import android.provider.Settings import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import kotlin.math.sqrt import kotlin.random.Random @@ -30,7 +29,10 @@ private const val LINK_DP = 110f fun ParticleBackground(dark: Boolean, animated: Boolean, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { val bg = MaterialTheme.colorScheme.background val dot = if (dark) Color.White else MaterialTheme.colorScheme.primary - val systemAnimations = LocalPlatform.current.animationsEnabled + val context = LocalContext.current + val systemAnimations = remember { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f + } val animate = animated && systemAnimations val link = with(LocalDensity.current) { LINK_DP.dp2px(density) } diff --git a/shared/src/com/nexoniarz/nexopass/app/Prefs.kt b/app/src/main/java/com/nexoniarz/nexopass/Prefs.kt similarity index 68% rename from shared/src/com/nexoniarz/nexopass/app/Prefs.kt rename to app/src/main/java/com/nexoniarz/nexopass/Prefs.kt index 32bb7fc..39c658d 100644 --- a/shared/src/com/nexoniarz/nexopass/app/Prefs.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Prefs.kt @@ -1,62 +1,55 @@ -package com.nexoniarz.nexopass.app +package com.nexoniarz.nexopass +import android.content.Context import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -/** Where settings live: SharedPreferences on Android, the script's config files on the PC. */ -interface KeyValue { - fun getInt(key: String, def: Int): Int - fun putInt(key: String, value: Int) - fun getString(key: String, def: String): String - fun putString(key: String, value: String) - fun getBool(key: String, def: Boolean): Boolean - fun putBool(key: String, value: Boolean) -} - /** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */ -class Prefs(private val kv: KeyValue) { +class Prefs(context: Context) { + private val sp = context.getSharedPreferences("nexopass", Context.MODE_PRIVATE) + /** system, dark, light */ var theme by StringPref("theme", "system") var particles by BoolPref("particles", true) /** Minutes in the background before the app locks; 0 = as soon as you leave. */ var autoLockMinutes by IntPref("auto_lock", 5) var clipSeconds by IntPref("clip_seconds", 30) - /** words, words or chars */ + /** words, or chars */ var defaultMode by StringPref("default_mode", "words") var maxAttempts by IntPref("max_attempts", 5) var failedAttempts by IntPref("failed", 0) - /** pin, password, pattern (Android; only meaningful while the unlock is set) */ + /** pin, password, pattern (only meaningful while quick.bin exists) */ var quickMethod by StringPref("quick_method", "none") var currentAccount by StringPref("current_account", "main") /** off, strong (fingerprint/face), device (biometrics or phone screen lock) */ var bioMode by StringPref("bio_mode", "off") private inner class IntPref(val key: String, def: Int) : ReadWriteProperty { - private val state = mutableIntStateOf(kv.getInt(key, def)) + private val state = mutableIntStateOf(sp.getInt(key, def)) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.intValue override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { state.intValue = value - kv.putInt(key, value) + sp.edit().putInt(key, value).apply() } } private inner class StringPref(val key: String, def: String) : ReadWriteProperty { - private val state = mutableStateOf(kv.getString(key, def)) + private val state = mutableStateOf(sp.getString(key, def) ?: def) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { state.value = value - kv.putString(key, value) + sp.edit().putString(key, value).apply() } } private inner class BoolPref(val key: String, def: Boolean) : ReadWriteProperty { - private val state = mutableStateOf(kv.getBool(key, def)) + private val state = mutableStateOf(sp.getBoolean(key, def)) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { state.value = value - kv.putBool(key, value) + sp.edit().putBoolean(key, value).apply() } } } diff --git a/android/src/main/java/com/nexoniarz/nexopass/android/Security.kt b/app/src/main/java/com/nexoniarz/nexopass/Security.kt similarity index 79% rename from android/src/main/java/com/nexoniarz/nexopass/android/Security.kt rename to app/src/main/java/com/nexoniarz/nexopass/Security.kt index 0747576..d397849 100644 --- a/android/src/main/java/com/nexoniarz/nexopass/android/Security.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Security.kt @@ -1,11 +1,4 @@ -package com.nexoniarz.nexopass.android - -import com.nexoniarz.nexopass.app.AppLock -import com.nexoniarz.nexopass.app.Prefs -import com.nexoniarz.nexopass.app.UnlockResult -import com.nexoniarz.nexopass.app.Unlocked -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.ui.methodName +package com.nexoniarz.nexopass import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -27,6 +20,9 @@ import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec +/** What opens one account: its vault key and its master. */ +class Unlocked(val vaultKey: ByteArray, val master: ByteArray) + /** Every account's keys, encrypted under the app unlock. */ object Keyring { private const val SEP = '\u001f' @@ -44,7 +40,7 @@ object Keyring { } /** - * Phone app unlock (PIN, password or pattern, plus optional biometrics). + * The app unlock (PIN, password or pattern, plus optional biometrics). * * quick.bin: salt, then the keyring encrypted twice. Inner layer: AES-GCM with * innerKey = Argon2id(secret, salt). Outer layer: AES-GCM with a key that lives @@ -54,23 +50,23 @@ object Keyring { * bio.bin: innerKey, encrypted with a Keystore key that only works right after * a fingerprint/face (or screen lock) check. New fingerprints invalidate it. */ -class AndroidLock(private val activity: FragmentActivity, dir: File, private val prefs: Prefs) : AppLock { +class QuickUnlock(dir: File, private val prefs: Prefs) { private val quickFile = File(dir, "quick.bin") private val bioFile = File(dir, "bio.bin") - override val isSet get() = quickFile.exists() - override val method: String get() = if (isSet) prefs.quickMethod else "none" - override val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off" - override val methods = listOf("pin", "password", "pattern") + val isSet get() = quickFile.exists() + val method: String get() = if (isSet) prefs.quickMethod else "none" + val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off" - override fun problem(method: String, secret: String) = when (method) { - "pin" -> if (secret.length < 4) "Use at least 4 digits (6 is better)." else null - "pattern" -> if (secret.length < 4) "Connect at least 4 dots." else null - else -> if (secret.length < 6) "Use at least 6 characters." else null + sealed interface Result { + class Ok(val innerKey: ByteArray, val keyring: MutableMap) : Result + class Wrong(val attemptsLeft: Int) : Result + /** Too many wrong attempts (or the hardware key is gone): recover with a master. */ + object Wiped : Result } /** New secret: new salt and key. Returns the inner key. Slow (Argon2). */ - override fun setup(method: String, secret: String, keyring: Map): ByteArray { + fun setup(method: String, secret: String, keyring: Map): ByteArray { disableBio() // it holds the old inner key val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } val key = innerKey(secret, salt) @@ -81,14 +77,14 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val } /** Re-encrypts the keyring after accounts changed. */ - override fun save(innerKey: ByteArray, keyring: Map) { + fun save(innerKey: ByteArray, keyring: Map) { write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring) } /** Slow (Argon2), run off the main thread. */ - override fun unlock(secret: String): UnlockResult { + fun unlock(secret: String): Result { val data = quickFile.readBytes() - val inner = outer(data) ?: run { wipe(); return UnlockResult.Wiped } + val inner = outer(data) ?: run { wipe(); return Result.Wiped } val key = innerKey(secret, data.copyOfRange(0, 16)) val plain = try { gcm(Cipher.DECRYPT_MODE, key, inner) @@ -97,12 +93,12 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val val left = prefs.maxAttempts - prefs.failedAttempts if (left <= 0) { wipe() - return UnlockResult.Wiped + return Result.Wiped } - return UnlockResult.Wrong(left) + return Result.Wrong(left) } prefs.failedAttempts = 0 - return UnlockResult.Ok(key, Keyring.decode(plain)) + return Result.Ok(key, Keyring.decode(plain)) } private fun openWith(key: ByteArray): MutableMap? { @@ -110,7 +106,7 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null } } - override fun wipe() { + fun wipe() { quickFile.delete() bioFile.delete() prefs.quickMethod = "none" @@ -121,17 +117,17 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val } } - override fun disableBio() { + fun disableBio() { bioFile.delete() prefs.bioMode = "off" KeyStore.getInstance(KEYSTORE).apply { load(null) }.run { if (containsAlias(ALIAS_BIO)) deleteEntry(ALIAS_BIO) } } /** Asks for a fingerprint/face (or screen lock) and stores the inner key under it. */ - override fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) { + fun enableBio(activity: FragmentActivity, mode: String, innerKey: ByteArray, done: (String?) -> Unit) { disableBio() val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) } - prompt(cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c -> + prompt(activity, cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c -> bioFile.writeBytes(c.iv + c.doFinal(innerKey)) prefs.bioMode = mode done(null) @@ -139,7 +135,7 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val } /** Fingerprint/face, then opens the keyring. Errors are "" when the user just cancelled. */ - override fun unlockBio(cancelText: String, onOk: (UnlockResult.Ok) -> Unit, onError: (String) -> Unit) { + fun unlockBio(activity: FragmentActivity, cancelText: String, onOk: (Result.Ok) -> Unit, onError: (String) -> Unit) { val data = bioFile.readBytes() val cipher = try { Cipher.getInstance(GCM).apply { @@ -150,11 +146,11 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.") return } - prompt(cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c -> + prompt(activity, cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c -> val key = c.doFinal(data, 12, data.size - 12) val keyring = openWith(key) if (keyring == null) onError("Biometric unlock no longer matches. Use your ${methodName(method)}.") - else onOk(UnlockResult.Ok(key, keyring)) + else onOk(Result.Ok(key, keyring)) }, onError = onError) } @@ -180,6 +176,7 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val } private fun prompt( + activity: FragmentActivity, cipher: Cipher, mode: String, title: String, @@ -252,21 +249,21 @@ class AndroidLock(private val activity: FragmentActivity, dir: File, private val .generateKey() } - /** Which biometric options this phone supports. */ - override fun bioOptions(): List { - val bm = BiometricManager.from(activity) - return buildList { - if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong") - if (Build.VERSION.SDK_INT >= 30 && - bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS - ) add("device") - } - } - companion object { private const val KEYSTORE = "AndroidKeyStore" private const val GCM = "AES/GCM/NoPadding" private const val ALIAS_QUICK = "nexopass.quick" private const val ALIAS_BIO = "nexopass.bio" + + /** Which biometric options this phone supports. */ + fun bioOptions(activity: FragmentActivity): List { + val bm = BiometricManager.from(activity) + return buildList { + if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong") + if (Build.VERSION.SDK_INT >= 30 && + bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS + ) add("device") + } + } } } diff --git a/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt b/app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt similarity index 86% rename from shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt rename to app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt index 4e63183..6649f97 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt @@ -1,8 +1,7 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -17,17 +16,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.fragment.app.FragmentActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.time.LocalDate -const val APP_VERSION = "1.3.1" +const val APP_VERSION = "1.2.0" private fun bioLabel(mode: String) = when (mode) { "strong" -> "Fingerprint or face" @@ -39,8 +40,8 @@ private fun bioLabel(mode: String) = when (mode) { @Composable fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { val prefs = app.prefs - val quick = app.lock - val platform = LocalPlatform.current + val quick = app.quick + val activity = LocalContext.current as FragmentActivity val scope = rememberCoroutineScope() val snackbar = remember { SnackbarHostState() } fun toast(msg: String) = scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar(msg) } @@ -49,24 +50,34 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { var setupMethod by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var shownMaster by remember { mutableStateOf(null) } - val bioOptions = remember { quick.bioOptions() } + val bioOptions = remember { QuickUnlock.bioOptions(activity) } // Read so the rows refresh after changes. val method = prefs.quickMethod.let { quick.method } val bio = prefs.bioMode.let { quick.bioMode } - val exportLauncher = platform.rememberExport({ session.exportBytes() }) { ok -> - toast(if (ok) "Exported. It opens only with this account's master, on the phone or the PC." else "Export failed.") - } - val importLauncher = platform.rememberImport { bytes -> - if (bytes == null) return@rememberImport + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult busy = true scope.launch { - val n = withContext(Dispatchers.Default) { session.importBytes(bytes) } + val ok = withContext(Dispatchers.IO) { + runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(session.exportBytes()) } }.isSuccess + } + busy = false + toast(if (ok) "Exported. It opens only with this account's master, here or with nexopass on PC." else "Export failed.") + } + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + busy = true + scope.launch { + val n = withContext(Dispatchers.IO) { + runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) } + } busy = false toast(if (n == null) "Can't open it: exported from another account (different master), or not a NexoPass export." else "Imported: $n new entr${if (n == 1) "y" else "ies"}.") } } - fun exportNow() = exportLauncher("nexopass-${session.name}-${LocalDate.now()}.pgp") + fun exportNow() = exportLauncher.launch("nexopass-${session.name}-${LocalDate.now()}.pgp") Scaffold( containerColor = Color.Transparent, @@ -87,18 +98,19 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { SettingRow(Icons.Rounded.DriveFileRenameOutline, "Rename", "Currently \"${session.name}\"") { dialog = "rename" } SettingRow(Icons.Rounded.Visibility, "Show master", "Needs your ${methodName(method)}") { dialog = "verify-master" } SettingRow(Icons.Rounded.Upload, "Export", "Encrypted copy of this account's site list") { exportNow() } - SettingRow(Icons.Rounded.Download, "Import", "Merge an export from the phone or the PC") { importLauncher() } - SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this device", danger = true) { dialog = "detach" } + SettingRow(Icons.Rounded.Download, "Import", "Merge an export from this phone or your PC") { importLauncher.launch(arrayOf("*/*")) } + SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this phone", danger = true) { dialog = "detach" } } SectionTitle("App unlock") GlassCard(padding = 4.dp) { SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" } - if (bioOptions.isNotEmpty()) { - SettingRow(Icons.Rounded.Fingerprint, "Biometrics", bioLabel(bio)) { dialog = "bio" } - } - val autoLock = maxOf(prefs.autoLockMinutes, platform.minAutoLockMinutes) - SettingRow(Icons.Rounded.Timer, "Auto-lock", if (autoLock == 0) "As soon as you leave the app" else "$autoLock min after leaving") { dialog = "autolock" } + SettingRow( + Icons.Rounded.Fingerprint, "Biometrics", + if (bioOptions.isEmpty()) "Not available on this phone" else bioLabel(bio), + enabled = bioOptions.isNotEmpty(), + ) { dialog = "bio" } + SettingRow(Icons.Rounded.Timer, "Auto-lock", if (prefs.autoLockMinutes == 0) "As soon as you leave the app" else "${prefs.autoLockMinutes} min after leaving") { dialog = "autolock" } SettingRow(Icons.Rounded.GppMaybe, "Wrong attempts allowed", "${prefs.maxAttempts}, then you recover with a master") { dialog = "attempts" } } @@ -125,7 +137,7 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { } Spacer(Modifier.height(10.dp)) Text( - platform.aboutText, + "Same passwords as nexopass on NixOS. Nothing leaves this phone: the app has no internet permission.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -146,7 +158,7 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { "verify-change" -> VerifyDialog(app, "to change how you unlock", onDismiss = { dialog = null }) { dialog = "method" } "method" -> ChoiceDialog( "Unlock with", - quick.methods.map { it to methodName(it).replaceFirstChar { c -> c.uppercase() } }, + listOf("pin" to "PIN", "password" to "Password", "pattern" to "Pattern"), method, onDismiss = { dialog = null }, ) { picked -> @@ -159,14 +171,14 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { quick.disableBio() toast("Biometrics off.") } else { - quick.enableBio(picked, app.innerKey()) { err -> + quick.enableBio(activity, picked, app.innerKey()) { err -> if (err == null) toast("Biometric unlock is on.") else if (err.isNotEmpty()) toast(err) } } } "autolock" -> ChoiceDialog( "Auto-lock", - listOf(0, 1, 5, 15, 30, 60).filter { it >= platform.minAutoLockMinutes }.map { it.toString() to if (it == 0) "Immediately" else "After $it min" }, + listOf(0, 1, 5, 15, 30, 60).map { it.toString() to if (it == 0) "Immediately" else "After $it min" }, prefs.autoLockMinutes.toString(), onDismiss = { dialog = null }, ) { prefs.autoLockMinutes = it.toInt(); dialog = null } @@ -210,7 +222,7 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { toast("You now unlock with your ${methodName(m)}.") // The biometric copy holds the old key: set it up again right away. if (hadBio != "off") { - quick.enableBio(hadBio, app.innerKey()) { err -> + quick.enableBio(activity, hadBio, app.innerKey()) { err -> if (err != null && err.isNotEmpty()) toast(err) else if (err != null) toast("Biometrics are off now; turn them on again any time.") } } @@ -241,7 +253,8 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { /** Confirms it's you with the app unlock (or biometrics). */ @Composable private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, onOk: () -> Unit) { - val quick = app.lock + val quick = app.quick + val activity = LocalContext.current as FragmentActivity val scope = rememberCoroutineScope() var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } @@ -264,16 +277,16 @@ private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, o busy = false attempt++ when (r) { - is UnlockResult.Ok -> onOk() - is UnlockResult.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." - UnlockResult.Wiped -> app.lock() + is QuickUnlock.Result.Ok -> onOk() + is QuickUnlock.Result.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." + QuickUnlock.Result.Wiped -> app.lock() } } } if (quick.bioMode != "off") { Spacer(Modifier.height(12.dp)) BioButton { - quick.unlockBio("Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it }) + quick.unlockBio(activity, "Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it }) } } if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp)) @@ -385,8 +398,8 @@ private fun DetachDialog(app: AppState, name: String, onExport: () -> Unit, onDi @Composable private fun LicenseDialog(onDismiss: () -> Unit) { - val platform = LocalPlatform.current - val text = remember { platform.resource("LICENSE").decodeToString() } + val context = LocalContext.current + val text = remember { context.assets.open("LICENSE").use { it.readBytes().decodeToString() } } AlertDialog( onDismissRequest = onDismiss, title = { Text("Apache License 2.0") }, diff --git a/shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt b/app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt similarity index 99% rename from shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt rename to app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt index 78ed094..64708ae 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt @@ -1,7 +1,4 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -27,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -313,10 +311,10 @@ fun DetailScreen(session: Session, site: String, user: String, onBack: () -> Uni var deleteVersion by remember { mutableStateOf(null) } val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val platform = LocalPlatform.current + val context = LocalContext.current val seconds = session.prefs.clipSeconds val onCopy: (String) -> Unit = { pw -> - platform.copySecret(pw, seconds) + copySecret(context, pw, seconds) scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar("Copied. Clipboard clears in $seconds s.") } } diff --git a/shared/src/com/nexoniarz/nexopass/ui/Theme.kt b/app/src/main/java/com/nexoniarz/nexopass/Theme.kt similarity index 96% rename from shared/src/com/nexoniarz/nexopass/ui/Theme.kt rename to app/src/main/java/com/nexoniarz/nexopass/Theme.kt index ebc8f21..84630f0 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/Theme.kt +++ b/app/src/main/java/com/nexoniarz/nexopass/Theme.kt @@ -1,7 +1,4 @@ -package com.nexoniarz.nexopass.ui - -import com.nexoniarz.nexopass.app.* -import com.nexoniarz.nexopass.core.* +package com.nexoniarz.nexopass import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor diff --git a/android/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from android/src/main/res/drawable/ic_launcher_background.xml rename to app/src/main/res/drawable/ic_launcher_background.xml diff --git a/android/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from android/src/main/res/drawable/ic_launcher_foreground.xml rename to app/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml similarity index 100% rename from android/src/main/res/values-night/colors.xml rename to app/src/main/res/values-night/colors.xml diff --git a/android/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml similarity index 100% rename from android/src/main/res/values-night/themes.xml rename to app/src/main/res/values-night/themes.xml diff --git a/android/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml similarity index 100% rename from android/src/main/res/values/colors.xml rename to app/src/main/res/values/colors.xml diff --git a/android/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml similarity index 100% rename from android/src/main/res/values/themes.xml rename to app/src/main/res/values/themes.xml diff --git a/android/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from android/src/main/res/xml/data_extraction_rules.xml rename to app/src/main/res/xml/data_extraction_rules.xml diff --git a/build.gradle.kts b/build.gradle.kts index dbf65c0..1b4481a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,5 @@ plugins { id("com.android.application") version "8.7.3" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.jvm") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false - id("org.jetbrains.compose") version "1.7.3" apply false } - -// One version for every app; CI passes -PappVersion=X.Y.Z from the git tag. -val appVersion = (findProperty("appVersion") as String?) ?: "1.3.1" -extra["appVersion"] = appVersion diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts deleted file mode 100644 index 23b28e8..0000000 --- a/desktop/build.gradle.kts +++ /dev/null @@ -1,84 +0,0 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - id("org.jetbrains.kotlin.jvm") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.compose") -} - -val appVersion = rootProject.extra["appVersion"] as String - -kotlin { - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } -} -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -sourceSets { - main { - kotlin.srcDir("../shared/src") - resources.srcDir("../shared/resources") - } -} - -dependencies { - implementation(compose.desktop.currentOs) - implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0") - implementation("org.bouncycastle:bcprov-jdk18on:1.79") - implementation("org.bouncycastle:bcpg-jdk18on:1.79") - testImplementation(kotlin("test")) -} - -tasks.test { maxHeapSize = "1g" } - -compose.desktop { - application { - mainClass = "com.nexoniarz.nexopass.desktop.MainKt" - // Argon2 for the app password uses 256 MiB. - jvmArgs += listOf("-Xmx1g") - - nativeDistributions { - targetFormats(TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - packageName = "NexoPass" - packageVersion = appVersion - vendor = "Nexoniarz" - description = "Passwords derived from a master, nothing stored in plain text" - copyright = "Copyright 2026 Nexoniarz. Apache License 2.0." - licenseFile.set(rootProject.file("LICENSE")) - // Bundled Java runtime: nothing to install, nothing taken from the system. - modules("java.instrument", "java.naming", "java.sql", "jdk.unsupported", "jdk.crypto.ec") - - linux { - packageName = "nexopass-gui" - iconFile.set(project.file("icons/nexopass.png")) - debMaintainer = "nexoniarz@users.noreply.github.com" - menuGroup = "Utility" - appCategory = "Utility" - shortcut = true - } - windows { - iconFile.set(project.file("icons/nexopass.ico")) - menu = true - menuGroup = "NexoPass" - shortcut = true - perUserInstall = true - dirChooser = true - // Keeps updates installing over older versions. - upgradeUuid = "5d3b8f0e-6a2c-4b8e-9f3a-2c7d1e4b6a90" - } - } - - buildTypes.release.proguard { isEnabled.set(false) } - } -} - -// Bouncy Castle jars are signed; merged into one jar those signatures no -// longer match and Java refuses to start. The single jar doesn't need them. -tasks.matching { it.name.startsWith("packageUberJar") || it.name.startsWith("packageReleaseUberJar") }.configureEach { - (this as org.gradle.jvm.tasks.Jar).exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.EC") -} diff --git a/desktop/icons/nexopass.ico b/desktop/icons/nexopass.ico deleted file mode 100644 index d946f71..0000000 Binary files a/desktop/icons/nexopass.ico and /dev/null differ diff --git a/desktop/icons/nexopass.png b/desktop/icons/nexopass.png deleted file mode 100644 index 5531368..0000000 Binary files a/desktop/icons/nexopass.png and /dev/null differ diff --git a/desktop/icons/nexopass.svg b/desktop/icons/nexopass.svg deleted file mode 100644 index fafdde8..0000000 --- a/desktop/icons/nexopass.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt b/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt deleted file mode 100644 index 12a08c6..0000000 --- a/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.nexoniarz.nexopass.desktop - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.widthIn -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import com.nexoniarz.nexopass.app.AppState -import com.nexoniarz.nexopass.app.LocalPlatform -import com.nexoniarz.nexopass.app.Platform -import com.nexoniarz.nexopass.app.Prefs -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.core.PgpVaultCodec -import com.nexoniarz.nexopass.ui.App -import com.nexoniarz.nexopass.ui.NexoTheme -import com.nexoniarz.nexopass.ui.ParticleBackground -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.awt.Dimension -import java.awt.FileDialog -import java.awt.Frame -import java.awt.Toolkit -import java.awt.datatransfer.Clipboard -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.StringSelection -import java.awt.datatransfer.SystemFlavorMap -import java.awt.datatransfer.Transferable -import java.io.File -import java.util.Timer -import kotlin.concurrent.schedule - -fun main() = application { - val platform = remember { DesktopPlatform() } - val app = remember { AppState(platform, NexoPass.loadWords(platform.resource("eff_large_wordlist.txt"))) } - val state = rememberWindowState(size = DpSize(500.dp, 880.dp), position = WindowPosition(Alignment.Center)) - Window( - onCloseRequest = ::exitApplication, - title = "NexoPass", - state = state, - icon = painterResource("nexopass.png"), - onPreviewKeyEvent = { e -> - // Esc goes back, like the back gesture on the phone. - e.key == Key.Escape && e.type == KeyEventType.KeyDown && platform.back() - }, - ) { - LaunchedEffect(Unit) { - window.minimumSize = Dimension(420, 640) - platform.window = window - } - val dark = when (platform.prefs.theme) { - "dark" -> true - "light" -> false - else -> isSystemInDarkTheme() - } - CompositionLocalProvider(LocalPlatform provides platform) { - NexoTheme(dark) { - ParticleBackground(dark = dark, animated = platform.prefs.particles) { - // Phone-sized column in the middle of a wide window. - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Box(Modifier.widthIn(max = 640.dp).fillMaxHeight()) { App(app) } - } - } - } - } - } -} - -class DesktopPlatform : Platform { - override val prefs = Prefs(ConfigKV()) - override val lock = ScriptLock(prefs) - override val codec = PgpVaultCodec - override val accountsDir: File = Paths.accounts - override val migrated = migrate() - override val minAutoLockMinutes = 1 - override val animationsEnabled = true - override val aboutText = - if (Paths.windows) "Same passwords as the phone app and the nexopass command on Linux." - else "Shares accounts, site lists and the app password with the nexopass command. Same passwords as the phone app." - - var window: Frame? = null - private var onBack: (() -> Unit)? = null - - fun back(): Boolean { - val b = onBack ?: return false - b() - return true - } - - /** Older nexopass kept one vault in the data folder; it becomes the account "main" (like the script does). */ - private fun migrate(): Boolean { - val old = File(Paths.data, "vault.gpg") - if (!old.exists() || File(Paths.accounts, "main").exists()) return false - File(Paths.accounts, "main").mkdirs() - old.renameTo(File(Paths.accounts, "main/vault.gpg")) - File(Paths.data, "vault.gpg.bak").takeIf { it.exists() }?.renameTo(File(Paths.accounts, "main/vault.gpg.bak")) - File(Paths.data, "quick.gpg").delete() - File(Paths.data, "quick.meta").delete() - return true - } - - override fun resource(name: String): ByteArray = - DesktopPlatform::class.java.getResourceAsStream("/$name")!!.use { it.readBytes() } - - override fun copySecret(text: String, seconds: Int) { - val clipboard: Clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(SecretSelection(text), null) - Timer(true).schedule(seconds * 1000L) { - runCatching { - if (clipboard.getData(DataFlavor.stringFlavor) == text) clipboard.setContents(StringSelection(""), null) - } - } - } - - @Composable - override fun BackHandler(enabled: Boolean, onBack: () -> Unit) { - val current by rememberUpdatedState(onBack) - DisposableEffect(enabled) { - val handler = { current() } - if (enabled) this@DesktopPlatform.onBack = handler - onDispose { if (this@DesktopPlatform.onBack === handler) this@DesktopPlatform.onBack = null } - } - } - - @Composable - override fun AutoLock(app: AppState) { - val focused by rememberUpdatedState(LocalWindowInfo.current.isWindowFocused) - LaunchedEffect(Unit) { - var awaySince = 0L - while (true) { - delay(2_000) - val limit = maxOf(prefs.autoLockMinutes, minAutoLockMinutes) * 60_000L - if (focused) { - awaySince = 0 - } else { - if (awaySince == 0L) awaySince = System.currentTimeMillis() - if (app.appUnlocked && !app.needsAppSetup && System.currentTimeMillis() - awaySince >= limit) app.lock() - } - } - } - } - - @Composable - override fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (String) -> Unit { - val scope = rememberCoroutineScope() - return { name -> - val d = FileDialog(window, "Export site list", FileDialog.SAVE).apply { - file = name - isVisible = true - } - if (d.file != null) { - val target = File(d.directory, d.file) - scope.launch { - val ok = withContext(Dispatchers.IO) { runCatching { writeAtomically(target, content()) }.isSuccess } - onDone(ok) - } - } - } - } - - @Composable - override fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit = { - val d = FileDialog(window, "Import site list", FileDialog.LOAD).apply { isVisible = true } - if (d.file != null) onBytes(runCatching { File(d.directory, d.file).readBytes() }.getOrNull()) - } -} - -/** - * Plain text plus KDE's password hint, so Klipper keeps the password out of - * its clipboard history. Other desktops just see text. - */ -private class SecretSelection(private val text: String) : Transferable { - private val hint = DataFlavor("application/x-kde-passwordManagerHint;class=java.io.InputStream") - - init { - (SystemFlavorMap.getDefaultFlavorMap() as SystemFlavorMap).apply { - addUnencodedNativeForFlavor(hint, "x-kde-passwordManagerHint") - addFlavorForUnencodedNative("x-kde-passwordManagerHint", hint) - } - } - - override fun getTransferDataFlavors() = arrayOf(DataFlavor.stringFlavor, hint) - override fun isDataFlavorSupported(flavor: DataFlavor) = flavor == DataFlavor.stringFlavor || flavor == hint - override fun getTransferData(flavor: DataFlavor): Any = when (flavor) { - DataFlavor.stringFlavor -> text - else -> "secret".byteInputStream() - } -} diff --git a/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/ScriptData.kt b/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/ScriptData.kt deleted file mode 100644 index 53aa2e0..0000000 --- a/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/ScriptData.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.nexoniarz.nexopass.desktop - -import com.nexoniarz.nexopass.app.AppLock -import com.nexoniarz.nexopass.app.KeyValue -import com.nexoniarz.nexopass.app.Prefs -import com.nexoniarz.nexopass.app.UnlockResult -import com.nexoniarz.nexopass.app.Unlocked -import com.nexoniarz.nexopass.core.Export -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.core.hex -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermissions -import java.security.SecureRandom - -/* - * The PC app reads and writes exactly the files of the `nexopass` script, so - * the window and the terminal share accounts, site lists and the app password: - * - * /accounts//vault.gpg site list (OpenPGP, see PgpVaultCodec) - * /keyring.gpg "name<0x1F>master" lines, OpenPGP - * /keyring.meta method, salt, failed tries - * the script's settings (key=value) - * desktop.conf next to it settings only the window uses - */ - -object Paths { - val windows = System.getProperty("os.name").startsWith("Windows") - private val home = System.getProperty("user.home") - - val data: File = System.getenv("NEXOPASS_HOME")?.let(::File) - ?: if (windows) File(System.getenv("APPDATA") ?: home, "NexoPass") - else File(System.getenv("XDG_DATA_HOME") ?: "$home/.local/share", "nexopass") - - val config: File = System.getenv("NEXOPASS_CONFIG")?.let(::File) - ?: if (windows) File(data, "config") - else File(System.getenv("XDG_CONFIG_HOME") ?: "$home/.config", "nexopass/config") - - val desktopConfig = File(config.parentFile, "desktop.conf") - val accounts = File(data, "accounts") -} - -/** Owner-only permissions where the file system supports it (Linux). */ -fun privateFile(f: File) { - runCatching { Files.setPosixFilePermissions(f.toPath(), PosixFilePermissions.fromString("rw-------")) } -} - -/** Writes through a temp file so a crash never leaves half a file. */ -fun writeAtomically(f: File, bytes: ByteArray) { - f.parentFile?.mkdirs() - val tmp = File(f.parentFile, f.name + ".tmp") - tmp.writeBytes(bytes) - privateFile(tmp) - if (!tmp.renameTo(f)) { - f.delete() - check(tmp.renameTo(f)) { "Could not save ${f.name}" } - } -} - -private fun readKeyValues(f: File): LinkedHashMap { - val map = LinkedHashMap() - if (f.exists()) f.readLines().forEach { line -> - if (!line.startsWith("#") && '=' in line) map[line.substringBefore('=')] = line.substringAfter('=') - } - return map -} - -/** Rewrites one key, keeping every other line (the script's own keys too). */ -private fun writeKeyValue(f: File, key: String, value: String, header: String) { - val lines = if (f.exists()) f.readLines().toMutableList() else mutableListOf(header) - val i = lines.indexOfFirst { it.startsWith("$key=") } - if (i >= 0) lines[i] = "$key=$value" else lines += "$key=$value" - f.parentFile?.mkdirs() - writeAtomically(f, (lines.joinToString("\n") + "\n").toByteArray()) -} - -/** Settings shared with the script go to its config file; the rest to desktop.conf. */ -class ConfigKV : KeyValue { - private val scriptKeys = setOf("clip_seconds", "default_mode", "max_attempts", "current_account") - private fun file(key: String) = if (key in scriptKeys) Paths.config else Paths.desktopConfig - private fun header(key: String) = - if (key in scriptKeys) "# NexoPass settings, change with: nexopass settings" else "# NexoPass window settings" - - private fun get(key: String) = readKeyValues(file(key))[key] - private fun put(key: String, value: String) = writeKeyValue(file(key), key, value, header(key)) - - override fun getInt(key: String, def: Int) = get(key)?.toIntOrNull() ?: def - override fun putInt(key: String, value: Int) = put(key, value.toString()) - override fun getString(key: String, def: String) = get(key) ?: def - override fun putString(key: String, value: String) = put(key, value) - override fun getBool(key: String, def: Boolean) = get(key)?.toBooleanStrictOrNull() ?: def - override fun putBool(key: String, value: Boolean) = put(key, value.toString()) -} - -/** - * The script's app password: keyring.gpg encrypted with - * Argon2id(password, random salt; 4 passes, 256 MiB) as hex. - */ -class ScriptLock(private val prefs: Prefs) : AppLock { - private val keyringFile = File(Paths.data, "keyring.gpg") - private val metaFile = File(Paths.data, "keyring.meta") - - override val isSet get() = keyringFile.exists() && metaFile.exists() - override val method get() = if (isSet) meta()["method"] ?: "password" else "none" - override val methods = listOf("password", "pin") - - override fun problem(method: String, secret: String) = when (method) { - "pin" -> if (!secret.matches(Regex("[0-9]{6,32}"))) "A PIN is 6 to 32 digits (this PC has no TPM chip, so short PINs are weak)." else null - else -> if (secret.length < 8) "Use at least 8 characters." else null - } - - private fun meta() = readKeyValues(metaFile) - - private fun writeMeta(method: String, salt: String, failed: Int) { - writeAtomically(metaFile, "method=$method\nsalt=$salt\nfailed=$failed\n".toByteArray()) - } - - private fun key(secret: String, salt: String) = - hex(NexoPass.argon2(secret.toByteArray(), salt, 4, 18, 32)).toByteArray() - - override fun setup(method: String, secret: String, keyring: Map): ByteArray { - val salt = hex(ByteArray(16).also { SecureRandom().nextBytes(it) }) - val k = key(secret, salt) - writeMeta(method, salt, 0) - save(k, keyring) - return k - } - - override fun save(innerKey: ByteArray, keyring: Map) { - val text = keyring.entries.joinToString("") { (name, u) -> name + "\u001f" + u.master.decodeToString() + "\n" } - writeAtomically(keyringFile, Export.encrypt(innerKey.decodeToString().toCharArray(), text.toByteArray())) - } - - override fun unlock(secret: String): UnlockResult { - val m = meta() - val k = key(secret, m["salt"] ?: "") - val plain = Export.decrypt(k.decodeToString().toCharArray(), keyringFile.readBytes()) - if (plain == null) { - val failed = (m["failed"]?.toIntOrNull() ?: 0) + 1 - val left = prefs.maxAttempts - failed - if (left <= 0) { - wipe() - return UnlockResult.Wiped - } - writeMeta(m["method"] ?: "password", m["salt"] ?: "", failed) - return UnlockResult.Wrong(left) - } - writeMeta(m["method"] ?: "password", m["salt"] ?: "", 0) - // Vault keys aren't stored: derive them (a fraction of a second each). - val keyring = LinkedHashMap() - plain.decodeToString().lines().filter { '\u001f' in it }.forEach { line -> - val master = line.substringAfter('\u001f').toByteArray() - keyring[line.substringBefore('\u001f')] = Unlocked(NexoPass.vaultKey(master), master) - } - return UnlockResult.Ok(k, keyring) - } - - override fun wipe() { - keyringFile.delete() - metaFile.delete() - } -} diff --git a/desktop/src/main/resources/nexopass.png b/desktop/src/main/resources/nexopass.png deleted file mode 100644 index 5531368..0000000 Binary files a/desktop/src/main/resources/nexopass.png and /dev/null differ diff --git a/desktop/src/test/kotlin/com/nexoniarz/nexopass/CompatibilityTest.kt b/desktop/src/test/kotlin/com/nexoniarz/nexopass/CompatibilityTest.kt deleted file mode 100644 index 0ed820f..0000000 --- a/desktop/src/test/kotlin/com/nexoniarz/nexopass/CompatibilityTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.nexoniarz.nexopass - -import com.nexoniarz.nexopass.core.Export -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.core.PgpVaultCodec -import com.nexoniarz.nexopass.core.Rec -import com.nexoniarz.nexopass.core.Vault -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** - * Expected values come from the nexopass script. If any of these change, - * every user's passwords change: never "fix" a test by editing the numbers. - */ -class CompatibilityTest { - private val words = NexoPass.loadWords(javaClass.getResourceAsStream("/eff_large_wordlist.txt")!!.readBytes()) - private val master = NexoPass.normalizeMaster(" Test Master\tZmyslony ").toByteArray() - - @Test fun masterNormalization() { - assertEquals("test master zmyslony", master.decodeToString()) - assertEquals("ąść żółw synapse", NexoPass.normalizeMaster("ĄŚĆ Żółw SYNAPSE")) - } - - @Test fun checkWords() = assertEquals("swiftly remarry", NexoPass.checkWords(master, words)) - - @Test fun passwords() { - assertEquals("Gnat-Timing-Green-Myth-Guileless3", NexoPass.password(master, words, "discord", "", 1, "words")) - assertEquals("Unsaved-Outsell-Appear-Perplexed-Cringe5", NexoPass.password(master, words, "discord", "", 2, "words")) - assertEquals("t=sYCgU2W5e!Y*37tKyj", NexoPass.password(master, words, "discord", "", 3, "chars20")) - assertEquals("UQSFpi=5t=Tt&@CA", NexoPass.password(master, words, "allegro", "", 1, "chars16")) - assertEquals("Reference-Map-Garnet-Pantry-Headset8", NexoPass.password(master, words, "allegro", "drugie", 1, "words")) - assertEquals("Survivor-Ooze-Afflicted-Activity-Passivism-Obstinate-Childless9", NexoPass.password(master, words, "steam", "", 1, "words7")) - assertEquals( - "Crowbar-Scolding-Richness-Onscreen-Recall-Empathy-Kleenex-Malt-Entourage-Edging2", - NexoPass.password(master, words, "discord", "", 2, "words10"), - ) - } - - @Test fun otherAccount() { - val work = "work master phrase here".toByteArray() - assertEquals("legible lively", NexoPass.checkWords(work, words)) - assertEquals("Capillary-Hush-Angelfish-Moonshine-Aloha8", NexoPass.password(work, words, "github", "", 1, "words")) - } - - @Test fun siteNames() { - mapOf( - "https://www.Discord.com/login" to "discord", - "allegro.pl" to "allegro", - "onet.com.pl" to "onet", - "store.steampowered.com" to "steampowered", - "user@mail.example.com:443/x" to "example", - ).forEach { (raw, want) -> assertEquals(want, NexoPass.normalizeSite(raw), raw) } - assertNull(NexoPass.normalizeSite("a..b")) - assertNull(NexoPass.normalizeSite("ąę.pl")) - } - - @Test fun vaultAndExportRoundTrip() { - val dir = kotlin.io.path.createTempDirectory().toFile() - val key = NexoPass.vaultKey(master) - val v = Vault(dir.resolve("vault.gpg"), key, PgpVaultCodec) - v.add("discord", "", 1, "words") - val again = Vault(dir.resolve("vault.gpg"), key, PgpVaultCodec) - assertEquals(true, again.load()) - assertEquals(listOf("discord"), again.records.map(Rec::site)) - assertEquals(false, Vault(dir.resolve("vault.gpg"), NexoPass.vaultKey("wrong one here".toByteArray()), PgpVaultCodec).load()) - - val pass = NexoPass.exportPassphrase(master) - assertEquals("hello", Export.decrypt(pass, Export.encrypt(pass, "hello".toByteArray()))?.decodeToString()) - dir.deleteRecursively() - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b19497..28997f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,7 +13,4 @@ dependencyResolutionManagement { } } rootProject.name = "NexoPass" - -// shared/ holds the code both apps compile: core (passwords, vault, export) -// and the Compose UI. Each app adds it as a source folder. -include(":android", ":desktop") +include(":app") diff --git a/shared/src/com/nexoniarz/nexopass/app/AppState.kt b/shared/src/com/nexoniarz/nexopass/app/AppState.kt deleted file mode 100644 index e9bdc8c..0000000 --- a/shared/src/com/nexoniarz/nexopass/app/AppState.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.nexoniarz.nexopass.app - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.nexoniarz.nexopass.core.Export -import com.nexoniarz.nexopass.core.NexoPass -import com.nexoniarz.nexopass.core.Rec -import com.nexoniarz.nexopass.core.Vault -import java.io.File -import java.util.concurrent.ConcurrentHashMap - -/** - * The whole app: accounts on disk, the app unlock, and the keyring (every - * account's vault key and master) while unlocked. Nothing secret is written - * anywhere except encrypted. - */ -class AppState(val platform: Platform, val words: List) { - val prefs = platform.prefs - val lock = platform.lock - private val accountsDir = platform.accountsDir - - var accounts by mutableStateOf(listOf()) - private set - var appUnlocked by mutableStateOf(false) - private set - /** Unlocked with a master (recovery, first run, update): an app unlock must be set now. */ - var needsAppSetup by mutableStateOf(false) - private set - var session by mutableStateOf(null) - private set - /** Set when the account's master isn't in the keyring yet. */ - var needsMaster by mutableStateOf(null) - private set - var migrated by mutableStateOf(platform.migrated) - private set - var firstRunCheck by mutableStateOf(null) - - private var keyring: MutableMap = LinkedHashMap() - private var innerKey: ByteArray? = null - - init { - refreshAccounts() - } - - val current get() = prefs.currentAccount - - fun refreshAccounts() { - accounts = accountsDir.listFiles() - ?.filter { File(it, platform.codec.fileName).exists() } - ?.map { it.name }?.sorted() ?: emptyList() - if (accounts.isNotEmpty() && current !in accounts) prefs.currentAccount = accounts.first() - } - - private fun vault(name: String, key: ByteArray) = Vault(File(accountsDir, "$name/${platform.codec.fileName}"), key, platform.codec) - - /** After the PIN/password/pattern or biometrics. */ - fun unlockApp(r: UnlockResult.Ok) { - innerKey = r.innerKey - keyring = r.keyring - needsAppSetup = false - openAccount(current) - appUnlocked = true - } - - /** - * Opens an account with its master (recovery, update, or an account not in - * the keyring yet). Slow. False when the master doesn't open it. - */ - fun unlockWithMaster(name: String, master: ByteArray): Boolean { - val u = Unlocked(NexoPass.vaultKey(master), master) - if (!vault(name, u.vaultKey).load()) return false - keyring[name] = u - if (innerKey == null) needsAppSetup = true else saveKeyring() - needsMaster = null - open(name, u) - appUnlocked = true - return true - } - - /** New account: empty vault, master into the keyring. Slow. */ - fun createAccount(name: String, master: ByteArray) { - val u = Unlocked(NexoPass.vaultKey(master), master) - vault(name, u.vaultKey).save() - keyring[name] = u - refreshAccounts() - firstRunCheck = NexoPass.checkWords(master, words) - if (innerKey == null) needsAppSetup = true else saveKeyring() - needsMaster = null - open(name, u) - appUnlocked = true - } - - fun openAccount(name: String) { - prefs.currentAccount = name - session?.close() - session = null - val u = keyring[name] - needsMaster = if (u != null && open(name, u)) null else name - } - - private fun open(name: String, u: Unlocked): Boolean { - val s = Session(name, vault(name, u.vaultKey.copyOf()), words, prefs, u.master) - if (!s.load()) return false - session?.close() - session = s - prefs.currentAccount = name - return true - } - - /** Slow (Argon2). */ - fun setupApp(method: String, secret: String) { - innerKey = lock.setup(method, secret, keyring) - } - - fun finishAppSetup() { - needsAppSetup = false - } - - fun innerKey() = innerKey!! - - fun masterOf(name: String) = keyring[name]?.master?.decodeToString() - - fun renameAccount(old: String, new: String) { - session?.close() - session = null - File(accountsDir, old).renameTo(File(accountsDir, new)) - keyring.remove(old)?.let { keyring[new] = it } - saveKeyring() - refreshAccounts() - openAccount(new) - } - - /** True when [master] opens [name]. Slow. */ - fun checkMaster(name: String, master: ByteArray) = vault(name, NexoPass.vaultKey(master)).load() - - fun detachAccount(name: String) { - if (session?.name == name) { - session?.close() - session = null - } - File(accountsDir, name).deleteRecursively() - keyring.remove(name)?.let { it.master.fill(0); it.vaultKey.fill(0) } - saveKeyring() - refreshAccounts() - if (accounts.isEmpty()) { - // Nothing left: start over. - lock.wipe() - lock() - } else { - openAccount(accounts.first()) - } - } - - private fun saveKeyring() { - innerKey?.let { lock.save(it, keyring) } - } - - fun lock() { - session?.close() - session = null - keyring.values.forEach { it.master.fill(0); it.vaultKey.fill(0) } - keyring = LinkedHashMap() - innerKey?.fill(0) - innerKey = null - needsMaster = null - needsAppSetup = false - appUnlocked = false - migrated = false - } -} - -/** One open account. Nothing here is written to disk except its vault. */ -class Session(val name: String, private val vault: Vault, val words: List, val prefs: Prefs, master: ByteArray) { - private val master = master.copyOf() - private val cache = ConcurrentHashMap() - var records by mutableStateOf(listOf()) - private set - - fun load(): Boolean { - if (!vault.load()) return false - records = vault.records.toList() - return true - } - - fun close() { - master.fill(0) - cache.clear() - } - - val current get() = records.filter { it.current }.sortedWith(compareBy({ it.site }, { it.user })) - - fun password(r: Rec) = cache.getOrPut(r) { - NexoPass.password(master, words, r.site, r.user, r.version, r.mode) - } - - fun checkWords() = NexoPass.checkWords(master, words) - - fun add(site: String, user: String, version: Int, mode: String) = edit { add(site, user, version, mode) } - fun rotate(r: Rec, mode: String) = edit { rotate(r, mode) } - fun remove(r: Rec) = edit { remove(r); r } - fun removeVersion(r: Rec) = edit { removeVersion(r); r } - - /** Encrypted copy of the site list; slow (Argon2). */ - fun exportBytes(): ByteArray = - Export.encrypt(NexoPass.exportPassphrase(master), Vault.text(vault.records).toByteArray()) - - /** Merges an export into the list. Number of new entries, or null if it doesn't open. Slow. */ - fun importBytes(data: ByteArray): Int? { - val plain = Export.decrypt(NexoPass.exportPassphrase(master), data) ?: return null - val n = vault.mergeIn(Vault.parse(plain.decodeToString())) - records = vault.records.toList() - return n - } - - private fun edit(block: Vault.() -> Rec): Rec { - val r = vault.block() - records = vault.records.toList() - return r - } -} - -sealed interface Screen { - data object List : Screen - data object Add : Screen - data object Settings : Screen - data object NewAccount : Screen - data class Detail(val site: String, val user: String) : Screen -} diff --git a/shared/src/com/nexoniarz/nexopass/app/Platform.kt b/shared/src/com/nexoniarz/nexopass/app/Platform.kt deleted file mode 100644 index ed1e101..0000000 --- a/shared/src/com/nexoniarz/nexopass/app/Platform.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.nexoniarz.nexopass.app - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.staticCompositionLocalOf -import com.nexoniarz.nexopass.core.VaultCodec -import java.io.File - -/** What opens one account: its vault key and its master. */ -class Unlocked(val vaultKey: ByteArray, val master: ByteArray) - -sealed interface UnlockResult { - class Ok(val innerKey: ByteArray, val keyring: MutableMap) : UnlockResult - class Wrong(val attemptsLeft: Int) : UnlockResult - /** Too many wrong attempts (or the stored data is gone): recover with a master. */ - object Wiped : UnlockResult -} - -/** - * The app unlock: a secret (PIN, password, pattern) that opens the keyring - * holding every account's master. Phone and PC store it differently. - */ -interface AppLock { - val isSet: Boolean - /** pin, password, pattern; "none" when not set. */ - val method: String - /** Methods this platform offers, best first. */ - val methods: List - /** Why [secret] is not acceptable for [method], or null. */ - fun problem(method: String, secret: String): String? - - /** New secret; returns the key that encrypts the keyring. Slow. */ - fun setup(method: String, secret: String, keyring: Map): ByteArray - /** Re-encrypts the keyring after accounts changed. */ - fun save(innerKey: ByteArray, keyring: Map) - /** Slow. Wrong guesses are counted; too many wipe the unlock. */ - fun unlock(secret: String): UnlockResult - fun wipe() - - /** off, strong, device. */ - val bioMode: String get() = "off" - fun bioOptions(): List = emptyList() - fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) = done("Not available") - fun disableBio() {} - /** Errors are "" when the user just cancelled. */ - fun unlockBio(cancelText: String, onOk: (UnlockResult.Ok) -> Unit, onError: (String) -> Unit) = onError("") -} - -/** Everything that differs between Android and the PC. */ -interface Platform { - val lock: AppLock - val codec: VaultCodec - /** Folder with one sub-folder per account. */ - val accountsDir: File - val prefs: Prefs - /** True once after an older single-vault layout was moved into the account "main". */ - val migrated: Boolean - /** Shown in About. */ - val aboutText: String - /** PC: auto-lock never goes below this, so file dialogs don't lock the app. */ - val minAutoLockMinutes: Int get() = 0 - - fun resource(name: String): ByteArray - fun copySecret(text: String, seconds: Int) - val animationsEnabled: Boolean - - @Composable fun BackHandler(enabled: Boolean, onBack: () -> Unit) - /** Locks the app after the configured time away. */ - @Composable fun AutoLock(app: AppState) - /** Returns a function that asks where to save, then writes [content]. */ - @Composable fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (suggestedName: String) -> Unit - /** Returns a function that asks for a file and passes its bytes (null if none/unreadable). */ - @Composable fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit -} - -val LocalPlatform = staticCompositionLocalOf { error("No platform") } diff --git a/shared/src/com/nexoniarz/nexopass/ui/App.kt b/shared/src/com/nexoniarz/nexopass/ui/App.kt deleted file mode 100644 index 040b154..0000000 --- a/shared/src/com/nexoniarz/nexopass/ui/App.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.nexoniarz.nexopass.ui - -import androidx.compose.animation.* -import androidx.compose.animation.core.tween -import androidx.compose.runtime.* -import com.nexoniarz.nexopass.app.AppState -import com.nexoniarz.nexopass.app.LocalPlatform -import com.nexoniarz.nexopass.app.Screen - -/** The whole UI, same on the phone and the PC. */ -@Composable -fun App(app: AppState) { - val platform = LocalPlatform.current - platform.AutoLock(app) - - val stage = when { - !app.appUnlocked -> 0 - app.needsAppSetup -> 1 - else -> 2 - } - AnimatedContent( - targetState = stage, - transitionSpec = { (fadeIn(tween(400)) + scaleIn(initialScale = .96f)) togetherWith fadeOut(tween(200)) }, - label = "stage", - ) { st -> - when (st) { - 0 -> LockScreen(app) - 1 -> AppUnlockSetupScreen(app) - else -> UnlockedApp(app) - } - } -} - -@Composable -private fun UnlockedApp(app: AppState) { - val platform = LocalPlatform.current - var screen by remember { mutableStateOf(Screen.List) } - platform.BackHandler(screen != Screen.List) { screen = Screen.List } - val needs = app.needsMaster - val session = app.session - if (screen == Screen.NewAccount) { - NewAccountScreen(app, onBack = { screen = Screen.List }, onDone = { screen = Screen.List }) - return - } - if (needs != null || session == null) { - AccountMasterScreen(app, needs ?: app.current, onAddAccount = { screen = Screen.NewAccount }) - return - } - AnimatedContent( - targetState = screen, - transitionSpec = { - val forward = targetState != Screen.List - (slideInHorizontally { if (forward) it / 4 else -it / 4 } + fadeIn()) togetherWith - (slideOutHorizontally { if (forward) -it / 4 else it / 4 } + fadeOut()) - }, - label = "screen", - ) { s -> - when (s) { - Screen.List -> ListScreen( - app, session, - onOpen = { screen = Screen.Detail(it.site, it.user) }, - onAdd = { screen = Screen.Add }, - onSettings = { screen = Screen.Settings }, - onNewAccount = { screen = Screen.NewAccount }, - ) - Screen.Add -> AddScreen(session, onBack = { screen = Screen.List }, onOpen = { screen = Screen.Detail(it.site, it.user) }) - Screen.Settings -> SettingsScreen(app, session, onBack = { screen = Screen.List }) - Screen.NewAccount -> {} - is Screen.Detail -> DetailScreen(session, s.site, s.user, onBack = { screen = Screen.List }) - } - } -}