From 987cea431fea5dc51577ed61b74e6305e3e90758 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Thu, 8 Oct 2026 14:59:04 +0200 Subject: [PATCH 1/2] NexoPass 1.3.0: Linux and Windows app, project split into shared/android/desktop The PC app (Compose Multiplatform) uses the nexopass script's files, so the window and the terminal share accounts, site lists and the app password. Clipboard copies carry KDE's password hint. GitHub Actions builds the APK, .deb, .rpm, .msi and a portable Windows zip, and checks that passwords still match the script. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 111 ++++++ README.md | 61 ++- {app => android}/build.gradle.kts | 31 +- {app => android}/proguard-rules.pro | 0 {app => android}/src/main/AndroidManifest.xml | 2 +- .../nexopass/android/MainActivity.kt | 166 +++++++++ .../nexoniarz/nexopass/android}/Security.kt | 83 +++-- .../res/drawable/ic_launcher_background.xml | 0 .../res/drawable/ic_launcher_foreground.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../src/main/res/values-night/colors.xml | 0 .../src/main/res/values-night/themes.xml | 0 .../src/main/res/values/colors.xml | 0 .../src/main/res/values/themes.xml | 0 .../main/res/xml/data_extraction_rules.xml | 0 .../com/nexoniarz/nexopass/MainActivity.kt | 349 ------------------ build.gradle.kts | 6 + desktop/build.gradle.kts | 78 ++++ desktop/icons/nexopass.ico | Bin 0 -> 372526 bytes desktop/icons/nexopass.png | Bin 0 -> 25514 bytes desktop/icons/nexopass.svg | 13 + .../com/nexoniarz/nexopass/desktop/Main.kt | 204 ++++++++++ .../nexoniarz/nexopass/desktop/ScriptData.kt | 162 ++++++++ desktop/src/main/resources/nexopass.png | Bin 0 -> 25514 bytes .../nexoniarz/nexopass/CompatibilityTest.kt | 72 ++++ settings.gradle.kts | 5 +- .../main/assets => shared/resources}/LICENSE | 0 .../resources}/eff_large_wordlist.txt | 0 .../com/nexoniarz/nexopass/app/AppState.kt | 229 ++++++++++++ .../com/nexoniarz/nexopass/app/Platform.kt | 75 ++++ .../src/com/nexoniarz/nexopass/app}/Prefs.kt | 33 +- .../src/com/nexoniarz/nexopass/core}/Core.kt | 87 +++-- .../com/nexoniarz/nexopass/core}/Export.kt | 2 +- .../nexoniarz/nexopass/ui}/AccountScreens.kt | 24 +- shared/src/com/nexoniarz/nexopass/ui/App.kt | 72 ++++ .../com/nexoniarz/nexopass/ui}/Components.kt | 25 +- .../src/com/nexoniarz/nexopass/ui}/Inputs.kt | 18 +- .../com/nexoniarz/nexopass/ui}/LockScreen.kt | 18 +- .../com/nexoniarz/nexopass/ui}/Particles.kt | 12 +- .../nexoniarz/nexopass/ui}/SettingsScreen.kt | 79 ++-- .../com/nexoniarz/nexopass/ui}/SiteScreens.kt | 10 +- .../src/com/nexoniarz/nexopass/ui}/Theme.kt | 5 +- 42 files changed, 1475 insertions(+), 557 deletions(-) create mode 100644 .github/workflows/build.yml rename {app => android}/build.gradle.kts (62%) rename {app => android}/proguard-rules.pro (100%) rename {app => android}/src/main/AndroidManifest.xml (95%) create mode 100644 android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt rename {app/src/main/java/com/nexoniarz/nexopass => android/src/main/java/com/nexoniarz/nexopass/android}/Security.kt (79%) rename {app => android}/src/main/res/drawable/ic_launcher_background.xml (100%) rename {app => android}/src/main/res/drawable/ic_launcher_foreground.xml (100%) rename {app => android}/src/main/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {app => android}/src/main/res/values-night/colors.xml (100%) rename {app => android}/src/main/res/values-night/themes.xml (100%) rename {app => android}/src/main/res/values/colors.xml (100%) rename {app => android}/src/main/res/values/themes.xml (100%) rename {app => android}/src/main/res/xml/data_extraction_rules.xml (100%) delete mode 100644 app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt create mode 100644 desktop/build.gradle.kts create mode 100644 desktop/icons/nexopass.ico create mode 100644 desktop/icons/nexopass.png create mode 100644 desktop/icons/nexopass.svg create mode 100644 desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt create mode 100644 desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/ScriptData.kt create mode 100644 desktop/src/main/resources/nexopass.png create mode 100644 desktop/src/test/kotlin/com/nexoniarz/nexopass/CompatibilityTest.kt rename {app/src/main/assets => shared/resources}/LICENSE (100%) rename {app/src/main/assets => shared/resources}/eff_large_wordlist.txt (100%) create mode 100644 shared/src/com/nexoniarz/nexopass/app/AppState.kt create mode 100644 shared/src/com/nexoniarz/nexopass/app/Platform.kt rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/app}/Prefs.kt (68%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/core}/Core.kt (84%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/core}/Export.kt (98%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/AccountScreens.kt (94%) create mode 100644 shared/src/com/nexoniarz/nexopass/ui/App.kt rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/Components.kt (81%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/Inputs.kt (95%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/LockScreen.kt (92%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/Particles.kt (91%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/SettingsScreen.kt (86%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/SiteScreens.kt (99%) rename {app/src/main/java/com/nexoniarz/nexopass => shared/src/com/nexoniarz/nexopass/ui}/Theme.kt (96%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f462a76 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,111 @@ +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@v4 + 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@v4 + 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@v4 + 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 2d92209..a191e40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ +

+ # NexoPass -Passwords derived from a master, nothing stored in plain text. Android app, compatible with the `nexopass` script for NixOS. +Passwords derived from a master, nothing stored in plain text. For Android, Linux and Windows, and as the `nexopass` command on NixOS. *Made by **Nexoniarz*** @@ -12,29 +14,68 @@ Every password is computed, never saved: password = Argon2id(master, site + version + type + login) ``` -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. +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. -- **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. +- **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. - **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**: 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. +- **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. -Android 8.0 or newer. Material 3, light and dark theme, animated background. +## 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 +``` ## Build ``` -./gradlew assembleRelease +./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 ``` -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. +GitHub Actions builds everything on each push; a `v*` tag publishes a release. ## Compatibility -`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. +`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. ## License diff --git a/app/build.gradle.kts b/android/build.gradle.kts similarity index 62% rename from app/build.gradle.kts rename to android/build.gradle.kts index bfad54f..1057c2c 100644 --- a/app/build.gradle.kts +++ b/android/build.gradle.kts @@ -12,17 +12,38 @@ android { applicationId = "com.nexoniarz.nexopass" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "1.2.0" + 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")) + } + } } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - // 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") + signingConfig = signingConfigs.getByName("release") } } compileOptions { diff --git a/app/proguard-rules.pro b/android/proguard-rules.pro similarity index 100% rename from app/proguard-rules.pro rename to android/proguard-rules.pro diff --git a/app/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml similarity index 95% rename from app/src/main/AndroidManifest.xml rename to android/src/main/AndroidManifest.xml index 5fda0b0..5e1ed4e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:supportsRtl="true" android:theme="@style/Theme.NexoPass"> diff --git a/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt b/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt new file mode 100644 index 0000000..b66b235 --- /dev/null +++ b/android/src/main/java/com/nexoniarz/nexopass/android/MainActivity.kt @@ -0,0 +1,166 @@ +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/app/src/main/java/com/nexoniarz/nexopass/Security.kt b/android/src/main/java/com/nexoniarz/nexopass/android/Security.kt similarity index 79% rename from app/src/main/java/com/nexoniarz/nexopass/Security.kt rename to android/src/main/java/com/nexoniarz/nexopass/android/Security.kt index d397849..0747576 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Security.kt +++ b/android/src/main/java/com/nexoniarz/nexopass/android/Security.kt @@ -1,4 +1,11 @@ -package com.nexoniarz.nexopass +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 import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -20,9 +27,6 @@ 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' @@ -40,7 +44,7 @@ object Keyring { } /** - * The app unlock (PIN, password or pattern, plus optional biometrics). + * Phone 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 @@ -50,23 +54,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 QuickUnlock(dir: File, private val prefs: Prefs) { +class AndroidLock(private val activity: FragmentActivity, dir: File, private val prefs: Prefs) : AppLock { private val quickFile = File(dir, "quick.bin") private val bioFile = File(dir, "bio.bin") - 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 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") - 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 + 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 } /** New secret: new salt and key. Returns the inner key. Slow (Argon2). */ - fun setup(method: String, secret: String, keyring: Map): ByteArray { + override 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) @@ -77,14 +81,14 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { } /** Re-encrypts the keyring after accounts changed. */ - fun save(innerKey: ByteArray, keyring: Map) { + override fun save(innerKey: ByteArray, keyring: Map) { write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring) } /** Slow (Argon2), run off the main thread. */ - fun unlock(secret: String): Result { + override fun unlock(secret: String): UnlockResult { val data = quickFile.readBytes() - val inner = outer(data) ?: run { wipe(); return Result.Wiped } + val inner = outer(data) ?: run { wipe(); return UnlockResult.Wiped } val key = innerKey(secret, data.copyOfRange(0, 16)) val plain = try { gcm(Cipher.DECRYPT_MODE, key, inner) @@ -93,12 +97,12 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { val left = prefs.maxAttempts - prefs.failedAttempts if (left <= 0) { wipe() - return Result.Wiped + return UnlockResult.Wiped } - return Result.Wrong(left) + return UnlockResult.Wrong(left) } prefs.failedAttempts = 0 - return Result.Ok(key, Keyring.decode(plain)) + return UnlockResult.Ok(key, Keyring.decode(plain)) } private fun openWith(key: ByteArray): MutableMap? { @@ -106,7 +110,7 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null } } - fun wipe() { + override fun wipe() { quickFile.delete() bioFile.delete() prefs.quickMethod = "none" @@ -117,17 +121,17 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { } } - fun disableBio() { + override 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. */ - fun enableBio(activity: FragmentActivity, mode: String, innerKey: ByteArray, done: (String?) -> Unit) { + override fun enableBio(mode: String, innerKey: ByteArray, done: (String?) -> Unit) { disableBio() val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) } - prompt(activity, cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c -> + prompt(cipher, mode, "Turn on biometric unlock", "Cancel", onOk = { c -> bioFile.writeBytes(c.iv + c.doFinal(innerKey)) prefs.bioMode = mode done(null) @@ -135,7 +139,7 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { } /** Fingerprint/face, then opens the keyring. Errors are "" when the user just cancelled. */ - fun unlockBio(activity: FragmentActivity, cancelText: String, onOk: (Result.Ok) -> Unit, onError: (String) -> Unit) { + override fun unlockBio(cancelText: String, onOk: (UnlockResult.Ok) -> Unit, onError: (String) -> Unit) { val data = bioFile.readBytes() val cipher = try { Cipher.getInstance(GCM).apply { @@ -146,11 +150,11 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.") return } - prompt(activity, cipher, prefs.bioMode, "Unlock NexoPass", cancelText, onOk = { c -> + prompt(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(Result.Ok(key, keyring)) + else onOk(UnlockResult.Ok(key, keyring)) }, onError = onError) } @@ -176,7 +180,6 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { } private fun prompt( - activity: FragmentActivity, cipher: Cipher, mode: String, title: String, @@ -249,21 +252,21 @@ class QuickUnlock(dir: File, private val prefs: Prefs) { .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/app/src/main/res/drawable/ic_launcher_background.xml b/android/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to android/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground.xml rename to android/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/values-night/colors.xml b/android/src/main/res/values-night/colors.xml similarity index 100% rename from app/src/main/res/values-night/colors.xml rename to android/src/main/res/values-night/colors.xml diff --git a/app/src/main/res/values-night/themes.xml b/android/src/main/res/values-night/themes.xml similarity index 100% rename from app/src/main/res/values-night/themes.xml rename to android/src/main/res/values-night/themes.xml diff --git a/app/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml similarity index 100% rename from app/src/main/res/values/colors.xml rename to android/src/main/res/values/colors.xml diff --git a/app/src/main/res/values/themes.xml b/android/src/main/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to android/src/main/res/values/themes.xml diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/android/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from app/src/main/res/xml/data_extraction_rules.xml rename to android/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt b/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt deleted file mode 100644 index 09c6617..0000000 --- a/app/src/main/java/com/nexoniarz/nexopass/MainActivity.kt +++ /dev/null @@ -1,349 +0,0 @@ -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/build.gradle.kts b/build.gradle.kts index 1b4481a..78c6ea7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,11 @@ 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.0" +extra["appVersion"] = appVersion diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 0000000..b89c02d --- /dev/null +++ b/desktop/build.gradle.kts @@ -0,0 +1,78 @@ +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) } + } +} diff --git a/desktop/icons/nexopass.ico b/desktop/icons/nexopass.ico new file mode 100644 index 0000000000000000000000000000000000000000..d946f7145f87b79c6fd8b7997bdca2da3056caca GIT binary patch literal 372526 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 literal 0 HcmV?d00001 diff --git a/desktop/icons/nexopass.png b/desktop/icons/nexopass.png new file mode 100644 index 0000000000000000000000000000000000000000..5531368484cd459616bf9e4fad2af7f1bbbba31d GIT binary patch literal 25514 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 + + + + + + + + + + + + diff --git a/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt b/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt new file mode 100644 index 0000000..12a08c6 --- /dev/null +++ b/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt @@ -0,0 +1,204 @@ +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 new file mode 100644 index 0000000..53aa2e0 --- /dev/null +++ b/desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/ScriptData.kt @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..5531368484cd459616bf9e4fad2af7f1bbbba31d GIT binary patch literal 25514 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 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 28997f9..1b19497 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,4 +13,7 @@ dependencyResolutionManagement { } } rootProject.name = "NexoPass" -include(":app") + +// 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") diff --git a/app/src/main/assets/LICENSE b/shared/resources/LICENSE similarity index 100% rename from app/src/main/assets/LICENSE rename to shared/resources/LICENSE diff --git a/app/src/main/assets/eff_large_wordlist.txt b/shared/resources/eff_large_wordlist.txt similarity index 100% rename from app/src/main/assets/eff_large_wordlist.txt rename to shared/resources/eff_large_wordlist.txt diff --git a/shared/src/com/nexoniarz/nexopass/app/AppState.kt b/shared/src/com/nexoniarz/nexopass/app/AppState.kt new file mode 100644 index 0000000..e9bdc8c --- /dev/null +++ b/shared/src/com/nexoniarz/nexopass/app/AppState.kt @@ -0,0 +1,229 @@ +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 new file mode 100644 index 0000000..ed1e101 --- /dev/null +++ b/shared/src/com/nexoniarz/nexopass/app/Platform.kt @@ -0,0 +1,75 @@ +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/app/src/main/java/com/nexoniarz/nexopass/Prefs.kt b/shared/src/com/nexoniarz/nexopass/app/Prefs.kt similarity index 68% rename from app/src/main/java/com/nexoniarz/nexopass/Prefs.kt rename to shared/src/com/nexoniarz/nexopass/app/Prefs.kt index 39c658d..32bb7fc 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Prefs.kt +++ b/shared/src/com/nexoniarz/nexopass/app/Prefs.kt @@ -1,55 +1,62 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.app -import android.content.Context import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -/** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */ -class Prefs(context: Context) { - private val sp = context.getSharedPreferences("nexopass", Context.MODE_PRIVATE) +/** 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) { /** 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, or chars */ + /** words, 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 (only meaningful while quick.bin exists) */ + /** pin, password, pattern (Android; only meaningful while the unlock is set) */ 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(sp.getInt(key, def)) + private val state = mutableIntStateOf(kv.getInt(key, def)) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.intValue override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { state.intValue = value - sp.edit().putInt(key, value).apply() + kv.putInt(key, value) } } private inner class StringPref(val key: String, def: String) : ReadWriteProperty { - private val state = mutableStateOf(sp.getString(key, def) ?: def) + private val state = mutableStateOf(kv.getString(key, def)) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { state.value = value - sp.edit().putString(key, value).apply() + kv.putString(key, value) } } private inner class BoolPref(val key: String, def: Boolean) : ReadWriteProperty { - private val state = mutableStateOf(sp.getBoolean(key, def)) + private val state = mutableStateOf(kv.getBool(key, def)) override fun getValue(thisRef: Any?, property: KProperty<*>) = state.value override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { state.value = value - sp.edit().putBoolean(key, value).apply() + kv.putBool(key, value) } } } diff --git a/app/src/main/java/com/nexoniarz/nexopass/Core.kt b/shared/src/com/nexoniarz/nexopass/core/Core.kt similarity index 84% rename from app/src/main/java/com/nexoniarz/nexopass/Core.kt rename to shared/src/com/nexoniarz/nexopass/core/Core.kt index c9c84f6..936fd62 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Core.kt +++ b/shared/src/com/nexoniarz/nexopass/core/Core.kt @@ -1,4 +1,4 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.core import org.bouncycastle.crypto.generators.Argon2BytesGenerator import org.bouncycastle.crypto.params.Argon2Parameters @@ -141,16 +141,19 @@ object NexoPass { .toList() } - fun derive(master: ByteArray, salt: String, bytes: Int): ByteArray { + 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 { val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) .withVersion(Argon2Parameters.ARGON2_VERSION_13) - .withIterations(ARGON_T) - .withMemoryPowOfTwo(ARGON_M_POW) + .withIterations(iterations) + .withMemoryPowOfTwo(memPow) .withParallelism(ARGON_P) .withSalt(salt.toByteArray()) .build() val out = ByteArray(bytes) - Argon2BytesGenerator().apply { init(params) }.generateBytes(master, out) + Argon2BytesGenerator().apply { init(params) }.generateBytes(password, out) return out } @@ -197,8 +200,7 @@ 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) = - derive(master, "$SALT_PREFIX:export", 32).joinToString("") { "%02x".format(it) }.toCharArray() + fun exportPassphrase(master: ByteArray) = hex(derive(master, "$SALT_PREFIX:export", 32)).toCharArray() fun newMaster(words: List, count: Int = 6): String { val rnd = SecureRandom() @@ -220,11 +222,55 @@ 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, AES-256-GCM encrypted with a key derived from - * the master. Holds no passwords, only what is needed to derive them again. + * Site list with version archive, 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) { +class Vault(private val file: File, private val key: ByteArray, private val codec: VaultCodec) { val records = mutableListOf() fun exists() = file.exists() @@ -233,22 +279,13 @@ class Vault(private val file: File, private val key: ByteArray) { fun load(): Boolean { records.clear() if (!file.exists()) return true - 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()) + val text = codec.decode(key, file.readBytes()) ?: return false + records += parse(text) return true } fun save() { - val text = text(records) - val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } - val out = MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, iv).doFinal(text.toByteArray()) + val out = codec.encode(key, text(records)) file.parentFile?.mkdirs() val tmp = File(file.parentFile, file.name + ".tmp") tmp.writeBytes(out) @@ -256,11 +293,6 @@ class Vault(private val file: File, private val key: ByteArray) { 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 })) @@ -305,7 +337,6 @@ class Vault(private val file: File, private val key: ByteArray) { 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/app/src/main/java/com/nexoniarz/nexopass/Export.kt b/shared/src/com/nexoniarz/nexopass/core/Export.kt similarity index 98% rename from app/src/main/java/com/nexoniarz/nexopass/Export.kt rename to shared/src/com/nexoniarz/nexopass/core/Export.kt index aa379c4..981f967 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Export.kt +++ b/shared/src/com/nexoniarz/nexopass/core/Export.kt @@ -1,4 +1,4 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.core import org.bouncycastle.bcpg.HashAlgorithmTags import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags diff --git a/app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt b/shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt similarity index 94% rename from app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt rename to shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt index b52e156..c1ef1d4 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/AccountScreens.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/AccountScreens.kt @@ -1,4 +1,7 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable @@ -16,11 +19,9 @@ 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 @@ -162,21 +163,24 @@ 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 { QuickUnlock.bioOptions(activity) } + val bioOptions = remember { app.lock.bioOptions() } CenteredColumn { Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.") GlassCard { if (!askBio) { - 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" } + 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 } + } + } Text( "Too many wrong tries remove it; then you recover with your master. Keep the master in your head.", style = MaterialTheme.typography.bodySmall, @@ -186,13 +190,13 @@ fun AppUnlockSetupScreen(app: AppState) { } else { Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium) Text( - "Your ${methodName(app.quick.method)} stays as the backup.", + "Your ${methodName(app.lock.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.quick.enableBio(activity, bioOptions.first(), app.innerKey()) { err -> + app.lock.enableBio(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/App.kt b/shared/src/com/nexoniarz/nexopass/ui/App.kt new file mode 100644 index 0000000..040b154 --- /dev/null +++ b/shared/src/com/nexoniarz/nexopass/ui/App.kt @@ -0,0 +1,72 @@ +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 }) + } + } +} diff --git a/app/src/main/java/com/nexoniarz/nexopass/Components.kt b/shared/src/com/nexoniarz/nexopass/ui/Components.kt similarity index 81% rename from app/src/main/java/com/nexoniarz/nexopass/Components.kt rename to shared/src/com/nexoniarz/nexopass/ui/Components.kt index 08c11bb..2eab0e8 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Components.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/Components.kt @@ -1,12 +1,8 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* -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 @@ -113,16 +109,3 @@ 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/app/src/main/java/com/nexoniarz/nexopass/Inputs.kt b/shared/src/com/nexoniarz/nexopass/ui/Inputs.kt similarity index 95% rename from app/src/main/java/com/nexoniarz/nexopass/Inputs.kt rename to shared/src/com/nexoniarz/nexopass/ui/Inputs.kt index 879a300..7ce30ab 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Inputs.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/Inputs.kt @@ -1,4 +1,7 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures @@ -234,16 +237,10 @@ 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) } @@ -255,8 +252,7 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> style = MaterialTheme.typography.headlineSmall, ) Text( - if (method == "pin") "Unlocks NexoPass on this phone. Your master keeps working too." - else "Used to unlock NexoPass on this phone.", + "Opens NexoPass and all your accounts. Your masters keep working for recovery.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), @@ -266,7 +262,7 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> attempt++ val f = first when { - f == null -> secretProblem(method, s)?.let { error = it } ?: run { first = s; error = null } + f == null -> lock.problem(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/app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt b/shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt similarity index 92% rename from app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt rename to shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt index d5a3c7f..a7b3923 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/LockScreen.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/LockScreen.kt @@ -1,4 +1,7 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* @@ -8,18 +11,15 @@ 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.quick + val quick = app.lock // 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 QuickUnlock.Result.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) } - is QuickUnlock.Result.Wrong -> { + is UnlockResult.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) } + is UnlockResult.Wrong -> { attempt++ error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." } - QuickUnlock.Result.Wiped -> { + UnlockResult.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(activity, "Use ${methodName(quick.method)}", onOk = { r -> + quick.unlockBio("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/Particles.kt b/shared/src/com/nexoniarz/nexopass/ui/Particles.kt similarity index 91% rename from app/src/main/java/com/nexoniarz/nexopass/Particles.kt rename to shared/src/com/nexoniarz/nexopass/ui/Particles.kt index 52b0023..f626f8e 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Particles.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/Particles.kt @@ -1,6 +1,8 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* -import android.provider.Settings import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -12,7 +14,6 @@ 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 @@ -29,10 +30,7 @@ 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 context = LocalContext.current - val systemAnimations = remember { - Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f - } + val systemAnimations = LocalPlatform.current.animationsEnabled val animate = animated && systemAnimations val link = with(LocalDensity.current) { LINK_DP.dp2px(density) } diff --git a/app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt b/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt similarity index 86% rename from app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt rename to shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt index 6649f97..a347972 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/SettingsScreen.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt @@ -1,7 +1,8 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* -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 @@ -16,19 +17,17 @@ 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.2.0" +const val APP_VERSION = "1.3.0" private fun bioLabel(mode: String) = when (mode) { "strong" -> "Fingerprint or face" @@ -40,8 +39,8 @@ private fun bioLabel(mode: String) = when (mode) { @Composable fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { val prefs = app.prefs - val quick = app.quick - val activity = LocalContext.current as FragmentActivity + val quick = app.lock + val platform = LocalPlatform.current val scope = rememberCoroutineScope() val snackbar = remember { SnackbarHostState() } fun toast(msg: String) = scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar(msg) } @@ -50,34 +49,24 @@ 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 { QuickUnlock.bioOptions(activity) } + val bioOptions = remember { quick.bioOptions() } // Read so the rows refresh after changes. val method = prefs.quickMethod.let { quick.method } val bio = prefs.bioMode.let { quick.bioMode } - val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - busy = true - scope.launch { - 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 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 = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult + val importLauncher = platform.rememberImport { bytes -> + if (bytes == null) return@rememberImport busy = true scope.launch { - val n = withContext(Dispatchers.IO) { - runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) } - } + val n = withContext(Dispatchers.Default) { session.importBytes(bytes) } 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.launch("nexopass-${session.name}-${LocalDate.now()}.pgp") + fun exportNow() = exportLauncher("nexopass-${session.name}-${LocalDate.now()}.pgp") Scaffold( containerColor = Color.Transparent, @@ -98,19 +87,18 @@ 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 this phone or your PC") { importLauncher.launch(arrayOf("*/*")) } - SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this phone", danger = true) { dialog = "detach" } + 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" } } SectionTitle("App unlock") GlassCard(padding = 4.dp) { SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" } - 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" } + 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.GppMaybe, "Wrong attempts allowed", "${prefs.maxAttempts}, then you recover with a master") { dialog = "attempts" } } @@ -137,7 +125,7 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { } Spacer(Modifier.height(10.dp)) Text( - "Same passwords as nexopass on NixOS. Nothing leaves this phone: the app has no internet permission.", + platform.aboutText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -158,7 +146,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", - listOf("pin" to "PIN", "password" to "Password", "pattern" to "Pattern"), + quick.methods.map { it to methodName(it).replaceFirstChar { c -> c.uppercase() } }, method, onDismiss = { dialog = null }, ) { picked -> @@ -171,14 +159,14 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { quick.disableBio() toast("Biometrics off.") } else { - quick.enableBio(activity, picked, app.innerKey()) { err -> + quick.enableBio(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).map { it.toString() to if (it == 0) "Immediately" else "After $it min" }, + listOf(0, 1, 5, 15, 30, 60).filter { it >= platform.minAutoLockMinutes }.map { it.toString() to if (it == 0) "Immediately" else "After $it min" }, prefs.autoLockMinutes.toString(), onDismiss = { dialog = null }, ) { prefs.autoLockMinutes = it.toInt(); dialog = null } @@ -222,7 +210,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(activity, hadBio, app.innerKey()) { err -> + quick.enableBio(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.") } } @@ -253,8 +241,7 @@ 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.quick - val activity = LocalContext.current as FragmentActivity + val quick = app.lock val scope = rememberCoroutineScope() var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } @@ -277,16 +264,16 @@ private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, o busy = false attempt++ when (r) { - is QuickUnlock.Result.Ok -> onOk() - is QuickUnlock.Result.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." - QuickUnlock.Result.Wiped -> app.lock() + is UnlockResult.Ok -> onOk() + is UnlockResult.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." + UnlockResult.Wiped -> app.lock() } } } if (quick.bioMode != "off") { Spacer(Modifier.height(12.dp)) BioButton { - quick.unlockBio(activity, "Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it }) + quick.unlockBio("Cancel", onOk = { onOk() }, onError = { if (it.isNotEmpty()) error = it }) } } if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp)) @@ -398,8 +385,8 @@ private fun DetachDialog(app: AppState, name: String, onExport: () -> Unit, onDi @Composable private fun LicenseDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - val text = remember { context.assets.open("LICENSE").use { it.readBytes().decodeToString() } } + val platform = LocalPlatform.current + val text = remember { platform.resource("LICENSE").decodeToString() } AlertDialog( onDismissRequest = onDismiss, title = { Text("Apache License 2.0") }, diff --git a/app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt b/shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt similarity index 99% rename from app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt rename to shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt index 64708ae..78ed094 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/SiteScreens.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/SiteScreens.kt @@ -1,4 +1,7 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -24,7 +27,6 @@ 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 @@ -311,10 +313,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 context = LocalContext.current + val platform = LocalPlatform.current val seconds = session.prefs.clipSeconds val onCopy: (String) -> Unit = { pw -> - copySecret(context, pw, seconds) + platform.copySecret(pw, seconds) scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar("Copied. Clipboard clears in $seconds s.") } } diff --git a/app/src/main/java/com/nexoniarz/nexopass/Theme.kt b/shared/src/com/nexoniarz/nexopass/ui/Theme.kt similarity index 96% rename from app/src/main/java/com/nexoniarz/nexopass/Theme.kt rename to shared/src/com/nexoniarz/nexopass/ui/Theme.kt index 84630f0..ebc8f21 100644 --- a/app/src/main/java/com/nexoniarz/nexopass/Theme.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/Theme.kt @@ -1,4 +1,7 @@ -package com.nexoniarz.nexopass +package com.nexoniarz.nexopass.ui + +import com.nexoniarz.nexopass.app.* +import com.nexoniarz.nexopass.core.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor From 94d43ce77ae072759d6d2e17ab16b199a6cf6d71 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Thu, 8 Oct 2026 15:13:12 +0200 Subject: [PATCH 2/2] Fix the single-jar Linux build: drop Bouncy Castle signature files Merged into one jar their signatures no longer match and Java refused to start. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 6 +++--- build.gradle.kts | 2 +- desktop/build.gradle.kts | 6 ++++++ shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f462a76..4c27c27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: { distribution: temurin, java-version: 17 } - uses: gradle/actions/setup-gradle@v4 - name: Version from tag @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@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 @@ -78,7 +78,7 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: { distribution: temurin, java-version: 17 } - uses: gradle/actions/setup-gradle@v4 - name: Version from tag diff --git a/build.gradle.kts b/build.gradle.kts index 78c6ea7..dbf65c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,5 +7,5 @@ plugins { } // One version for every app; CI passes -PappVersion=X.Y.Z from the git tag. -val appVersion = (findProperty("appVersion") as String?) ?: "1.3.0" +val appVersion = (findProperty("appVersion") as String?) ?: "1.3.1" extra["appVersion"] = appVersion diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index b89c02d..23b28e8 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -76,3 +76,9 @@ compose.desktop { 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/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt b/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt index a347972..4e63183 100644 --- a/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt +++ b/shared/src/com/nexoniarz/nexopass/ui/SettingsScreen.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.time.LocalDate -const val APP_VERSION = "1.3.0" +const val APP_VERSION = "1.3.1" private fun bioLabel(mode: String) = when (mode) { "strong" -> "Fingerprint or face"