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 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-08 14:59:04 +02:00
commit 987cea431f
42 changed files with 1475 additions and 557 deletions

111
.github/workflows/build.yml vendored Normal file
View file

@ -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

View file

@ -1,6 +1,8 @@
<p align="center"><img src="desktop/icons/nexopass.png" width="96" alt=""></p>
# 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-<version>-android.apk` |
| Windows | `NexoPass-<version>.msi` (installer) or `...-windows-portable.zip` |
| Debian/Ubuntu | `nexopass-gui_<version>_amd64.deb` |
| Fedora/openSUSE | `nexopass-gui-<version>.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/<name>/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

View file

@ -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 {

View file

@ -14,7 +14,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.NexoPass">
<activity
android:name=".MainActivity"
android:name=".android.MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density"
android:exported="true"
android:windowSoftInputMode="adjustResize">

View file

@ -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("*/*")) }
}
}

View file

@ -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<String, Unlocked>) : 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<String, Unlocked>): ByteArray {
override fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): 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<String, Unlocked>) {
override fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
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<String, Unlocked>? {
@ -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,14 +252,8 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
.generateKey()
}
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<String> {
override fun bioOptions(): List<String> {
val bm = BiometricManager.from(activity)
return buildList {
if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong")
@ -265,5 +262,11 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
) 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"
}
}

View file

@ -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<String>, val prefs: Prefs) {
private val accountsDir = File(filesDir, "accounts")
val quick = QuickUnlock(filesDir, prefs)
var accounts by mutableStateOf(listOf<String>())
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<Session?>(null)
private set
/** Set when the account's master isn't in the keyring yet. */
var needsMaster by mutableStateOf<String?>(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<String?>(null)
private var keyring: MutableMap<String, Unlocked> = 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<String>, val prefs: Prefs, u: Unlocked) {
private val master = u.master.copyOf()
private val vault = Vault(file, u.vaultKey.copyOf())
private val cache = ConcurrentHashMap<Rec, String>()
var records by mutableStateOf(listOf<Rec>())
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>(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 })
}
}
}

View file

@ -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

78
desktop/build.gradle.kts Normal file
View file

@ -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) }
}
}

BIN
desktop/icons/nexopass.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
desktop/icons/nexopass.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="512" height="512">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8B5CF6"/>
<stop offset="1" stop-color="#22D3EE"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="104" height="104" rx="26" fill="url(#g)"/>
<g transform="rotate(-45 54 54)" fill="#FFFFFF">
<path fill-rule="evenodd" d="M42,40a14,14 0,1 1,0 28a14,14 0,1 1,0 -28zM42,48.5a5.5,5.5 0,1 0,0 11a5.5,5.5 0,1 0,0 -11z"/>
<path d="M54,50.5h26v7h-4v7h-7v-7h-3v5h-6v-5h-6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 595 B

View file

@ -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()
}
}

View file

@ -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:
*
* <data>/accounts/<name>/vault.gpg site list (OpenPGP, see PgpVaultCodec)
* <data>/keyring.gpg "name<0x1F>master" lines, OpenPGP
* <data>/keyring.meta method, salt, failed tries
* <config> 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<String, String> {
val map = LinkedHashMap<String, String>()
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<String, Unlocked>): 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<String, Unlocked>) {
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<String, Unlocked>()
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()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,72 @@
package com.nexoniarz.nexopass
import com.nexoniarz.nexopass.core.Export
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.core.PgpVaultCodec
import com.nexoniarz.nexopass.core.Rec
import com.nexoniarz.nexopass.core.Vault
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Expected values come from the nexopass script. If any of these change,
* every user's passwords change: never "fix" a test by editing the numbers.
*/
class CompatibilityTest {
private val words = NexoPass.loadWords(javaClass.getResourceAsStream("/eff_large_wordlist.txt")!!.readBytes())
private val master = NexoPass.normalizeMaster(" Test Master\tZmyslony ").toByteArray()
@Test fun masterNormalization() {
assertEquals("test master zmyslony", master.decodeToString())
assertEquals("ąść żółw synapse", NexoPass.normalizeMaster("ĄŚĆ Żółw SYNAPSE"))
}
@Test fun checkWords() = assertEquals("swiftly remarry", NexoPass.checkWords(master, words))
@Test fun passwords() {
assertEquals("Gnat-Timing-Green-Myth-Guileless3", NexoPass.password(master, words, "discord", "", 1, "words"))
assertEquals("Unsaved-Outsell-Appear-Perplexed-Cringe5", NexoPass.password(master, words, "discord", "", 2, "words"))
assertEquals("t=sYCgU2W5e!Y*37tKyj", NexoPass.password(master, words, "discord", "", 3, "chars20"))
assertEquals("UQSFpi=5t=Tt&@CA", NexoPass.password(master, words, "allegro", "", 1, "chars16"))
assertEquals("Reference-Map-Garnet-Pantry-Headset8", NexoPass.password(master, words, "allegro", "drugie", 1, "words"))
assertEquals("Survivor-Ooze-Afflicted-Activity-Passivism-Obstinate-Childless9", NexoPass.password(master, words, "steam", "", 1, "words7"))
assertEquals(
"Crowbar-Scolding-Richness-Onscreen-Recall-Empathy-Kleenex-Malt-Entourage-Edging2",
NexoPass.password(master, words, "discord", "", 2, "words10"),
)
}
@Test fun otherAccount() {
val work = "work master phrase here".toByteArray()
assertEquals("legible lively", NexoPass.checkWords(work, words))
assertEquals("Capillary-Hush-Angelfish-Moonshine-Aloha8", NexoPass.password(work, words, "github", "", 1, "words"))
}
@Test fun siteNames() {
mapOf(
"https://www.Discord.com/login" to "discord",
"allegro.pl" to "allegro",
"onet.com.pl" to "onet",
"store.steampowered.com" to "steampowered",
"user@mail.example.com:443/x" to "example",
).forEach { (raw, want) -> assertEquals(want, NexoPass.normalizeSite(raw), raw) }
assertNull(NexoPass.normalizeSite("a..b"))
assertNull(NexoPass.normalizeSite("ąę.pl"))
}
@Test fun vaultAndExportRoundTrip() {
val dir = kotlin.io.path.createTempDirectory().toFile()
val key = NexoPass.vaultKey(master)
val v = Vault(dir.resolve("vault.gpg"), key, PgpVaultCodec)
v.add("discord", "", 1, "words")
val again = Vault(dir.resolve("vault.gpg"), key, PgpVaultCodec)
assertEquals(true, again.load())
assertEquals(listOf("discord"), again.records.map(Rec::site))
assertEquals(false, Vault(dir.resolve("vault.gpg"), NexoPass.vaultKey("wrong one here".toByteArray()), PgpVaultCodec).load())
val pass = NexoPass.exportPassphrase(master)
assertEquals("hello", Export.decrypt(pass, Export.encrypt(pass, "hello".toByteArray()))?.decodeToString())
dir.deleteRecursively()
}
}

View file

@ -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")

View file

@ -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<String>) {
val prefs = platform.prefs
val lock = platform.lock
private val accountsDir = platform.accountsDir
var accounts by mutableStateOf(listOf<String>())
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<Session?>(null)
private set
/** Set when the account's master isn't in the keyring yet. */
var needsMaster by mutableStateOf<String?>(null)
private set
var migrated by mutableStateOf(platform.migrated)
private set
var firstRunCheck by mutableStateOf<String?>(null)
private var keyring: MutableMap<String, Unlocked> = 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<String>, val prefs: Prefs, master: ByteArray) {
private val master = master.copyOf()
private val cache = ConcurrentHashMap<Rec, String>()
var records by mutableStateOf(listOf<Rec>())
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
}

View file

@ -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<String, Unlocked>) : 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<String>
/** 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<String, Unlocked>): ByteArray
/** Re-encrypts the keyring after accounts changed. */
fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>)
/** 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<String> = 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<Platform> { error("No platform") }

View file

@ -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<N> */
/** words, words<N> or chars<N> */
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<Any?, Int> {
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<Any?, String> {
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<Any?, Boolean> {
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)
}
}
}

View file

@ -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<String>, 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<Rec>()
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'

View file

@ -1,4 +1,4 @@
package com.nexoniarz.nexopass
package com.nexoniarz.nexopass.core
import org.bouncycastle.bcpg.HashAlgorithmTags
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags

View file

@ -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<String?>(null) }
var busy by remember { mutableStateOf(false) }
var askBio by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(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
}
}

View file

@ -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>(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 })
}
}
}

View file

@ -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)
}

View file

@ -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<String?>(null) }
var error by remember { mutableStateOf<String?>(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)
}

View file

@ -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) }

View file

@ -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) }

View file

@ -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<String?>(null) }
var busy by remember { mutableStateOf(false) }
var shownMaster by remember { mutableStateOf<String?>(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
val exportLauncher = platform.rememberExport({ session.exportBytes() }) { ok ->
toast(if (ok) "Exported. It opens only with this account's master, on the phone or the PC." else "Export failed.")
}
val importLauncher = platform.rememberImport { bytes ->
if (bytes == null) return@rememberImport
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 importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
busy = true
scope.launch {
val n = withContext(Dispatchers.IO) {
runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) }
}
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<String?>(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") },

View file

@ -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<Rec?>(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.") }
}

View file

@ -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