Compare commits

...
Author SHA1 Message Date
Nexoniarz
94d43ce77a Fix the single-jar Linux build: drop Bouncy Castle signature files
Merged into one jar their signatures no longer match and Java refused to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:12 +02:00
Nexoniarz
987cea431f 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>
2026-10-08 14:59:04 +02:00
42 changed files with 1481 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@v5
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- name: Version from tag
shell: bash
run: |
if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi
- name: Signing key
shell: bash
env:
KEYSTORE_B64: ${{ secrets.NEXOPASS_KEYSTORE_B64 }}
run: |
# Same key as the APKs built before, so the app updates in place.
if [[ -n $KEYSTORE_B64 ]]; then
echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "NEXOPASS_KEYSTORE=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
fi
- name: Build APK
env:
NEXOPASS_KEYSTORE_PASSWORD: ${{ secrets.NEXOPASS_KEYSTORE_PASSWORD }}
NEXOPASS_KEY_ALIAS: ${{ secrets.NEXOPASS_KEY_ALIAS }}
NEXOPASS_KEY_PASSWORD: ${{ secrets.NEXOPASS_KEY_PASSWORD }}
run: ./gradlew :android:assembleRelease $GRADLE_ARGS
- name: Collect
shell: bash
run: |
mkdir dist
cp android/build/outputs/apk/release/android-release.apk "dist/NexoPass-${GITHUB_REF_NAME#v}-android.apk"
- uses: actions/upload-artifact@v4
with: { name: android, path: dist/* }
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- run: sudo apt-get update && sudo apt-get install -y rpm
- name: Version from tag
shell: bash
run: |
if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi
- name: Test (passwords must match the script)
run: ./gradlew :desktop:test $GRADLE_ARGS
- name: Build Linux packages
run: ./gradlew :desktop:packageDeb :desktop:packageRpm :desktop:packageUberJarForCurrentOS $GRADLE_ARGS
- name: Collect
shell: bash
run: |
mkdir dist
cp desktop/build/compose/binaries/main/deb/*.deb dist/
cp desktop/build/compose/binaries/main/rpm/*.rpm dist/
cp desktop/build/compose/jars/*.jar dist/
- uses: actions/upload-artifact@v4
with: { name: linux, path: dist/* }
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v4
- name: Version from tag
shell: bash
run: |
if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "GRADLE_ARGS=-PappVersion=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"; fi
- name: Build installer and portable version
shell: bash
run: ./gradlew :desktop:packageMsi :desktop:createDistributable $GRADLE_ARGS
- name: Collect
shell: pwsh
run: |
New-Item -ItemType Directory dist | Out-Null
Copy-Item desktop/build/compose/binaries/main/msi/*.msi dist/
$v = "$env:GITHUB_REF_NAME".TrimStart("v")
Compress-Archive -Path desktop/build/compose/binaries/main/app/NexoPass -DestinationPath "dist/NexoPass-$v-windows-portable.zip"
- uses: actions/upload-artifact@v4
with: { name: windows, path: dist/* }
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [android, linux, windows]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { path: dist, merge-multiple: true }
- uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true

View file

@ -1,6 +1,8 @@
<p align="center"><img src="desktop/icons/nexopass.png" width="96" alt=""></p>
# NexoPass # 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*** *Made by **Nexoniarz***
@ -12,29 +14,68 @@ Every password is computed, never saved:
password = Argon2id(master, site + version + type + login) 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. - **Accounts.** Several accounts, each with its own master and site list. Switch in one click.
- **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. - **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. - **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. - **Passwords** of 5-15 words or 12-64 characters, chosen per site.
- **Typos** like "discrod" get a "did you mean discord?". - **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. - **Export/import** with encrypted OpenPGP files, the same format everywhere, to move a site list between phone and PC.
- No internet permission, screenshots blocked, clipboard cleared after a while, auto-lock, no cloud backup. - 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 ## 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 ## 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 ## License

View file

@ -12,17 +12,38 @@ android {
applicationId = "com.nexoniarz.nexopass" applicationId = "com.nexoniarz.nexopass"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 val v = rootProject.extra["appVersion"] as String
versionName = "1.2.0" 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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Signed with the debug key so a release build installs right away. signingConfig = signingConfigs.getByName("release")
// Replace with your own key if you ever publish the app.
signingConfig = signingConfigs.getByName("debug")
} }
} }
compileOptions { compileOptions {

View file

@ -14,7 +14,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.NexoPass"> android:theme="@style/Theme.NexoPass">
<activity <activity
android:name=".MainActivity" android:name=".android.MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|uiMode|density"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize"> 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.os.Build
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
@ -20,9 +27,6 @@ import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec 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. */ /** Every account's keys, encrypted under the app unlock. */
object Keyring { object Keyring {
private const val SEP = '\u001f' 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 * 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 * 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 * bio.bin: innerKey, encrypted with a Keystore key that only works right after
* a fingerprint/face (or screen lock) check. New fingerprints invalidate it. * 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 quickFile = File(dir, "quick.bin")
private val bioFile = File(dir, "bio.bin") private val bioFile = File(dir, "bio.bin")
val isSet get() = quickFile.exists() override val isSet get() = quickFile.exists()
val method: String get() = if (isSet) prefs.quickMethod else "none" override val method: String get() = if (isSet) prefs.quickMethod else "none"
val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off" override val bioMode: String get() = if (isSet && bioFile.exists()) prefs.bioMode else "off"
override val methods = listOf("pin", "password", "pattern")
sealed interface Result { override fun problem(method: String, secret: String) = when (method) {
class Ok(val innerKey: ByteArray, val keyring: MutableMap<String, Unlocked>) : Result "pin" -> if (secret.length < 4) "Use at least 4 digits (6 is better)." else null
class Wrong(val attemptsLeft: Int) : Result "pattern" -> if (secret.length < 4) "Connect at least 4 dots." else null
/** Too many wrong attempts (or the hardware key is gone): recover with a master. */ else -> if (secret.length < 6) "Use at least 6 characters." else null
object Wiped : Result
} }
/** New secret: new salt and key. Returns the inner key. Slow (Argon2). */ /** 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 disableBio() // it holds the old inner key
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val key = innerKey(secret, salt) val key = innerKey(secret, salt)
@ -77,14 +81,14 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
} }
/** Re-encrypts the keyring after accounts changed. */ /** 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) write(quickFile.readBytes().copyOfRange(0, 16), innerKey, keyring)
} }
/** Slow (Argon2), run off the main thread. */ /** Slow (Argon2), run off the main thread. */
fun unlock(secret: String): Result { override fun unlock(secret: String): UnlockResult {
val data = quickFile.readBytes() 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 key = innerKey(secret, data.copyOfRange(0, 16))
val plain = try { val plain = try {
gcm(Cipher.DECRYPT_MODE, key, inner) gcm(Cipher.DECRYPT_MODE, key, inner)
@ -93,12 +97,12 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
val left = prefs.maxAttempts - prefs.failedAttempts val left = prefs.maxAttempts - prefs.failedAttempts
if (left <= 0) { if (left <= 0) {
wipe() wipe()
return Result.Wiped return UnlockResult.Wiped
} }
return Result.Wrong(left) return UnlockResult.Wrong(left)
} }
prefs.failedAttempts = 0 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>? { 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 } return try { Keyring.decode(gcm(Cipher.DECRYPT_MODE, key, inner)) } catch (e: Exception) { null }
} }
fun wipe() { override fun wipe() {
quickFile.delete() quickFile.delete()
bioFile.delete() bioFile.delete()
prefs.quickMethod = "none" prefs.quickMethod = "none"
@ -117,17 +121,17 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
} }
} }
fun disableBio() { override fun disableBio() {
bioFile.delete() bioFile.delete()
prefs.bioMode = "off" prefs.bioMode = "off"
KeyStore.getInstance(KEYSTORE).apply { load(null) }.run { if (containsAlias(ALIAS_BIO)) deleteEntry(ALIAS_BIO) } 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. */ /** 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() disableBio()
val cipher = Cipher.getInstance(GCM).apply { init(Cipher.ENCRYPT_MODE, keystoreKey(ALIAS_BIO, mode)) } 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)) bioFile.writeBytes(c.iv + c.doFinal(innerKey))
prefs.bioMode = mode prefs.bioMode = mode
done(null) 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. */ /** 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 data = bioFile.readBytes()
val cipher = try { val cipher = try {
Cipher.getInstance(GCM).apply { 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.") onError("Biometrics changed on this phone, so biometric unlock was turned off. Turn it on again in Settings.")
return 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 key = c.doFinal(data, 12, data.size - 12)
val keyring = openWith(key) val keyring = openWith(key)
if (keyring == null) onError("Biometric unlock no longer matches. Use your ${methodName(method)}.") 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) }, onError = onError)
} }
@ -176,7 +180,6 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
} }
private fun prompt( private fun prompt(
activity: FragmentActivity,
cipher: Cipher, cipher: Cipher,
mode: String, mode: String,
title: String, title: String,
@ -249,14 +252,8 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
.generateKey() .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. */ /** Which biometric options this phone supports. */
fun bioOptions(activity: FragmentActivity): List<String> { override fun bioOptions(): List<String> {
val bm = BiometricManager.from(activity) val bm = BiometricManager.from(activity)
return buildList { return buildList {
if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong") if (bm.canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) add("strong")
@ -265,5 +262,11 @@ class QuickUnlock(dir: File, private val prefs: Prefs) {
) add("device") ) 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 { plugins {
id("com.android.application") version "8.7.3" apply false 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.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.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.compose") version "1.7.3" apply false
} }
// One version for every app; CI passes -PappVersion=X.Y.Z from the git tag.
val appVersion = (findProperty("appVersion") as String?) ?: "1.3.1"
extra["appVersion"] = appVersion

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

@ -0,0 +1,84 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}
val appVersion = rootProject.extra["appVersion"] as String
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
main {
kotlin.srcDir("../shared/src")
resources.srcDir("../shared/resources")
}
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
implementation("org.bouncycastle:bcpg-jdk18on:1.79")
testImplementation(kotlin("test"))
}
tasks.test { maxHeapSize = "1g" }
compose.desktop {
application {
mainClass = "com.nexoniarz.nexopass.desktop.MainKt"
// Argon2 for the app password uses 256 MiB.
jvmArgs += listOf("-Xmx1g")
nativeDistributions {
targetFormats(TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
packageName = "NexoPass"
packageVersion = appVersion
vendor = "Nexoniarz"
description = "Passwords derived from a master, nothing stored in plain text"
copyright = "Copyright 2026 Nexoniarz. Apache License 2.0."
licenseFile.set(rootProject.file("LICENSE"))
// Bundled Java runtime: nothing to install, nothing taken from the system.
modules("java.instrument", "java.naming", "java.sql", "jdk.unsupported", "jdk.crypto.ec")
linux {
packageName = "nexopass-gui"
iconFile.set(project.file("icons/nexopass.png"))
debMaintainer = "nexoniarz@users.noreply.github.com"
menuGroup = "Utility"
appCategory = "Utility"
shortcut = true
}
windows {
iconFile.set(project.file("icons/nexopass.ico"))
menu = true
menuGroup = "NexoPass"
shortcut = true
perUserInstall = true
dirChooser = true
// Keeps updates installing over older versions.
upgradeUuid = "5d3b8f0e-6a2c-4b8e-9f3a-2c7d1e4b6a90"
}
}
buildTypes.release.proguard { isEnabled.set(false) }
}
}
// Bouncy Castle jars are signed; merged into one jar those signatures no
// longer match and Java refuses to start. The single jar doesn't need them.
tasks.matching { it.name.startsWith("packageUberJar") || it.name.startsWith("packageReleaseUberJar") }.configureEach {
(this as org.gradle.jvm.tasks.Jar).exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.EC")
}

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" 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** App settings. Nothing secret is stored here. Backed by Compose state so the UI follows changes. */ /** Where settings live: SharedPreferences on Android, the script's config files on the PC. */
class Prefs(context: Context) { interface KeyValue {
private val sp = context.getSharedPreferences("nexopass", Context.MODE_PRIVATE) 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 */ /** system, dark, light */
var theme by StringPref("theme", "system") var theme by StringPref("theme", "system")
var particles by BoolPref("particles", true) var particles by BoolPref("particles", true)
/** Minutes in the background before the app locks; 0 = as soon as you leave. */ /** Minutes in the background before the app locks; 0 = as soon as you leave. */
var autoLockMinutes by IntPref("auto_lock", 5) var autoLockMinutes by IntPref("auto_lock", 5)
var clipSeconds by IntPref("clip_seconds", 30) 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 defaultMode by StringPref("default_mode", "words")
var maxAttempts by IntPref("max_attempts", 5) var maxAttempts by IntPref("max_attempts", 5)
var failedAttempts by IntPref("failed", 0) 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 quickMethod by StringPref("quick_method", "none")
var currentAccount by StringPref("current_account", "main") var currentAccount by StringPref("current_account", "main")
/** off, strong (fingerprint/face), device (biometrics or phone screen lock) */ /** off, strong (fingerprint/face), device (biometrics or phone screen lock) */
var bioMode by StringPref("bio_mode", "off") var bioMode by StringPref("bio_mode", "off")
private inner class IntPref(val key: String, def: Int) : ReadWriteProperty<Any?, Int> { 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 getValue(thisRef: Any?, property: KProperty<*>) = state.intValue
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
state.intValue = value 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 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 getValue(thisRef: Any?, property: KProperty<*>) = state.value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
state.value = value 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 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 getValue(thisRef: Any?, property: KProperty<*>) = state.value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) { override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
state.value = value 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.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters import org.bouncycastle.crypto.params.Argon2Parameters
@ -141,16 +141,19 @@ object NexoPass {
.toList() .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) val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13) .withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(ARGON_T) .withIterations(iterations)
.withMemoryPowOfTwo(ARGON_M_POW) .withMemoryPowOfTwo(memPow)
.withParallelism(ARGON_P) .withParallelism(ARGON_P)
.withSalt(salt.toByteArray()) .withSalt(salt.toByteArray())
.build() .build()
val out = ByteArray(bytes) val out = ByteArray(bytes)
Argon2BytesGenerator().apply { init(params) }.generateBytes(master, out) Argon2BytesGenerator().apply { init(params) }.generateBytes(password, out)
return out return out
} }
@ -197,8 +200,7 @@ object NexoPass {
fun vaultKey(master: ByteArray) = derive(master, "$SALT_PREFIX:vault", 32) fun vaultKey(master: ByteArray) = derive(master, "$SALT_PREFIX:vault", 32)
/** OpenPGP passphrase for export files, the same one the script uses. */ /** OpenPGP passphrase for export files, the same one the script uses. */
fun exportPassphrase(master: ByteArray) = fun exportPassphrase(master: ByteArray) = hex(derive(master, "$SALT_PREFIX:export", 32)).toCharArray()
derive(master, "$SALT_PREFIX:export", 32).joinToString("") { "%02x".format(it) }.toCharArray()
fun newMaster(words: List<String>, count: Int = 6): String { fun newMaster(words: List<String>, count: Int = 6): String {
val rnd = SecureRandom() val rnd = SecureRandom()
@ -220,11 +222,55 @@ data class Rec(
fun same(o: Rec) = site == o.site && user == o.user 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 * Site list with version archive, encrypted with a key derived from the
* the master. Holds no passwords, only what is needed to derive them again. * 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>() val records = mutableListOf<Rec>()
fun exists() = file.exists() fun exists() = file.exists()
@ -233,22 +279,13 @@ class Vault(private val file: File, private val key: ByteArray) {
fun load(): Boolean { fun load(): Boolean {
records.clear() records.clear()
if (!file.exists()) return true if (!file.exists()) return true
val data = file.readBytes() val text = codec.decode(key, file.readBytes()) ?: return false
if (data.size < MAGIC.size + 12 || !data.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return false records += parse(text)
val plain = try {
cipher(Cipher.DECRYPT_MODE, data.copyOfRange(MAGIC.size, MAGIC.size + 12))
.doFinal(data, MAGIC.size + 12, data.size - MAGIC.size - 12)
} catch (e: javax.crypto.AEADBadTagException) {
return false
}
records += parse(plain.decodeToString())
return true return true
} }
fun save() { fun save() {
val text = text(records) val out = codec.encode(key, text(records))
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
val out = MAGIC + iv + cipher(Cipher.ENCRYPT_MODE, iv).doFinal(text.toByteArray())
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
val tmp = File(file.parentFile, file.name + ".tmp") val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeBytes(out) 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" } 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 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 })) 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() private fun today() = LocalDate.now().toString()
companion object { companion object {
private val MAGIC = "NXP1".toByteArray()
private const val HEADER = "# nexopass vault v1" private const val HEADER = "# nexopass vault v1"
private const val SEP = '\u001f' 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.HashAlgorithmTags
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags 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.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -16,11 +19,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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. */ /** Required right after unlocking with a master: the app unlock replaces typing the master. */
@Composable @Composable
fun AppUnlockSetupScreen(app: AppState) { fun AppUnlockSetupScreen(app: AppState) {
val activity = LocalContext.current as FragmentActivity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var method by remember { mutableStateOf<String?>(null) } var method by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var askBio by remember { mutableStateOf(false) } var askBio by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val bioOptions = remember { QuickUnlock.bioOptions(activity) } val bioOptions = remember { app.lock.bioOptions() }
CenteredColumn { CenteredColumn {
Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.") Header(if (askBio) "One more thing." else "Choose how you unlock NexoPass. You won't type your master anymore.")
GlassCard { GlassCard {
if (!askBio) { if (!askBio) {
MethodCard(Icons.Rounded.Pin, "PIN", "Fast. 4+ digits, 6 is better.") { method = "pin" } app.lock.methods.forEach { m ->
MethodCard(Icons.Rounded.Password, "Password", "The strongest option.") { method = "password" } when (m) {
MethodCard(Icons.Rounded.Pattern, "Pattern", "Connect 4+ dots.") { method = "pattern" } "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( Text(
"Too many wrong tries remove it; then you recover with your master. Keep the master in your head.", "Too many wrong tries remove it; then you recover with your master. Keep the master in your head.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -186,13 +190,13 @@ fun AppUnlockSetupScreen(app: AppState) {
} else { } else {
Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium) Text("Unlock with your fingerprint too?", style = MaterialTheme.typography.titleMedium)
Text( Text(
"Your ${methodName(app.quick.method)} stays as the backup.", "Your ${methodName(app.lock.method)} stays as the backup.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
) )
GradientButton("Turn on", enabled = true) { 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 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.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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" 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.Canvas
import androidx.compose.foundation.gestures.detectDragGestures 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. */ /** New PIN/password/pattern: enter it, then repeat it. */
@Composable @Composable
fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) { fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) -> Unit) {
val lock = LocalPlatform.current.lock
var first by remember { mutableStateOf<String?>(null) } var first by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var attempt by remember { mutableIntStateOf(0) } var attempt by remember { mutableIntStateOf(0) }
@ -255,8 +252,7 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) ->
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Text( Text(
if (method == "pin") "Unlocks NexoPass on this phone. Your master keeps working too." "Opens NexoPass and all your accounts. Your masters keep working for recovery.",
else "Used to unlock NexoPass on this phone.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
@ -266,7 +262,7 @@ fun SecretSetupDialog(method: String, onCancel: () -> Unit, onDone: (String) ->
attempt++ attempt++
val f = first val f = first
when { 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." } f != s -> { first = null; error = "They didn't match, start again." }
else -> onDone(s) 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.animation.AnimatedVisibility
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -8,18 +11,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Composable @Composable
fun LockScreen(app: AppState) { fun LockScreen(app: AppState) {
val activity = LocalContext.current as FragmentActivity
val scope = rememberCoroutineScope() 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. // Without an app unlock (update, too many wrong tries) or on request: a master.
var recover by remember { mutableStateOf(!quick.isSet) } var recover by remember { mutableStateOf(!quick.isSet) }
var account by remember { mutableStateOf(app.current) } var account by remember { mutableStateOf(app.current) }
@ -33,12 +33,12 @@ fun LockScreen(app: AppState) {
scope.launch { scope.launch {
val r = withContext(Dispatchers.Default) { quick.unlock(secret) } val r = withContext(Dispatchers.Default) { quick.unlock(secret) }
when (r) { when (r) {
is QuickUnlock.Result.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) } is UnlockResult.Ok -> withContext(Dispatchers.Default) { app.unlockApp(r) }
is QuickUnlock.Result.Wrong -> { is UnlockResult.Wrong -> {
attempt++ attempt++
error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." 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." error = "Too many wrong attempts: the app unlock was removed. Recover with your master."
recover = true recover = true
} }
@ -49,7 +49,7 @@ fun LockScreen(app: AppState) {
fun unlockBio() { fun unlockBio() {
error = null error = null
quick.unlockBio(activity, "Use ${methodName(quick.method)}", onOk = { r -> quick.unlockBio("Use ${methodName(quick.method)}", onOk = { r ->
busy = true busy = true
scope.launch { scope.launch {
withContext(Dispatchers.Default) { app.unlockApp(r) } 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.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.random.Random 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) { fun ParticleBackground(dark: Boolean, animated: Boolean, modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
val bg = MaterialTheme.colorScheme.background val bg = MaterialTheme.colorScheme.background
val dot = if (dark) Color.White else MaterialTheme.colorScheme.primary val dot = if (dark) Color.White else MaterialTheme.colorScheme.primary
val context = LocalContext.current val systemAnimations = LocalPlatform.current.animationsEnabled
val systemAnimations = remember {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
}
val animate = animated && systemAnimations val animate = animated && systemAnimations
val link = with(LocalDensity.current) { LINK_DP.dp2px(density) } 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -16,19 +17,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate import java.time.LocalDate
const val APP_VERSION = "1.2.0" const val APP_VERSION = "1.3.1"
private fun bioLabel(mode: String) = when (mode) { private fun bioLabel(mode: String) = when (mode) {
"strong" -> "Fingerprint or face" "strong" -> "Fingerprint or face"
@ -40,8 +39,8 @@ private fun bioLabel(mode: String) = when (mode) {
@Composable @Composable
fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) { fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) {
val prefs = app.prefs val prefs = app.prefs
val quick = app.quick val quick = app.lock
val activity = LocalContext.current as FragmentActivity val platform = LocalPlatform.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
fun toast(msg: String) = scope.launch { snackbar.currentSnackbarData?.dismiss(); snackbar.showSnackbar(msg) } 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 setupMethod by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var shownMaster by remember { mutableStateOf<String?>(null) } 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. // Read so the rows refresh after changes.
val method = prefs.quickMethod.let { quick.method } val method = prefs.quickMethod.let { quick.method }
val bio = prefs.bioMode.let { quick.bioMode } val bio = prefs.bioMode.let { quick.bioMode }
val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pgp-encrypted")) { uri -> val exportLauncher = platform.rememberExport({ session.exportBytes() }) { ok ->
if (uri == null) return@rememberLauncherForActivityResult 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 busy = true
scope.launch { scope.launch {
val ok = withContext(Dispatchers.IO) { val n = withContext(Dispatchers.Default) { session.importBytes(bytes) }
runCatching { activity.contentResolver.openOutputStream(uri)!!.use { it.write(session.exportBytes()) } }.isSuccess
}
busy = false
toast(if (ok) "Exported. It opens only with this account's master, here or with nexopass on PC." else "Export failed.")
}
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
busy = true
scope.launch {
val n = withContext(Dispatchers.IO) {
runCatching { activity.contentResolver.openInputStream(uri)!!.use { it.readBytes() } }.getOrNull()?.let { session.importBytes(it) }
}
busy = false 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"}.") 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( Scaffold(
containerColor = Color.Transparent, 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.DriveFileRenameOutline, "Rename", "Currently \"${session.name}\"") { dialog = "rename" }
SettingRow(Icons.Rounded.Visibility, "Show master", "Needs your ${methodName(method)}") { dialog = "verify-master" } 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.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.Download, "Import", "Merge an export from the phone or the PC") { importLauncher() }
SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this phone", danger = true) { dialog = "detach" } SettingRow(Icons.Rounded.LinkOff, "Detach this account", "Remove it from this device", danger = true) { dialog = "detach" }
} }
SectionTitle("App unlock") SectionTitle("App unlock")
GlassCard(padding = 4.dp) { GlassCard(padding = 4.dp) {
SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" } SettingRow(Icons.Rounded.Pin, "Unlock with", methodName(method).replaceFirstChar { it.uppercase() } + " · tap to change") { dialog = "verify-change" }
SettingRow( if (bioOptions.isNotEmpty()) {
Icons.Rounded.Fingerprint, "Biometrics", SettingRow(Icons.Rounded.Fingerprint, "Biometrics", bioLabel(bio)) { dialog = "bio" }
if (bioOptions.isEmpty()) "Not available on this phone" else bioLabel(bio), }
enabled = bioOptions.isNotEmpty(), val autoLock = maxOf(prefs.autoLockMinutes, platform.minAutoLockMinutes)
) { dialog = "bio" } 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.Timer, "Auto-lock", if (prefs.autoLockMinutes == 0) "As soon as you leave the app" else "${prefs.autoLockMinutes} min after leaving") { dialog = "autolock" }
SettingRow(Icons.Rounded.GppMaybe, "Wrong attempts allowed", "${prefs.maxAttempts}, then you recover with a master") { dialog = "attempts" } 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)) Spacer(Modifier.height(10.dp))
Text( Text(
"Same passwords as nexopass on NixOS. Nothing leaves this phone: the app has no internet permission.", platform.aboutText,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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" } "verify-change" -> VerifyDialog(app, "to change how you unlock", onDismiss = { dialog = null }) { dialog = "method" }
"method" -> ChoiceDialog( "method" -> ChoiceDialog(
"Unlock with", "Unlock with",
listOf("pin" to "PIN", "password" to "Password", "pattern" to "Pattern"), quick.methods.map { it to methodName(it).replaceFirstChar { c -> c.uppercase() } },
method, method,
onDismiss = { dialog = null }, onDismiss = { dialog = null },
) { picked -> ) { picked ->
@ -171,14 +159,14 @@ fun SettingsScreen(app: AppState, session: Session, onBack: () -> Unit) {
quick.disableBio() quick.disableBio()
toast("Biometrics off.") toast("Biometrics off.")
} else { } 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) if (err == null) toast("Biometric unlock is on.") else if (err.isNotEmpty()) toast(err)
} }
} }
} }
"autolock" -> ChoiceDialog( "autolock" -> ChoiceDialog(
"Auto-lock", "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(), prefs.autoLockMinutes.toString(),
onDismiss = { dialog = null }, onDismiss = { dialog = null },
) { prefs.autoLockMinutes = it.toInt(); 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)}.") toast("You now unlock with your ${methodName(m)}.")
// The biometric copy holds the old key: set it up again right away. // The biometric copy holds the old key: set it up again right away.
if (hadBio != "off") { 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.") 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). */ /** Confirms it's you with the app unlock (or biometrics). */
@Composable @Composable
private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, onOk: () -> Unit) { private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, onOk: () -> Unit) {
val quick = app.quick val quick = app.lock
val activity = LocalContext.current as FragmentActivity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
@ -277,16 +264,16 @@ private fun VerifyDialog(app: AppState, reason: String, onDismiss: () -> Unit, o
busy = false busy = false
attempt++ attempt++
when (r) { when (r) {
is QuickUnlock.Result.Ok -> onOk() is UnlockResult.Ok -> onOk()
is QuickUnlock.Result.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left." is UnlockResult.Wrong -> error = "Wrong ${methodName(quick.method)}. ${r.attemptsLeft} attempt(s) left."
QuickUnlock.Result.Wiped -> app.lock() UnlockResult.Wiped -> app.lock()
} }
} }
} }
if (quick.bioMode != "off") { if (quick.bioMode != "off") {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
BioButton { 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)) if (busy) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 12.dp))
@ -398,8 +385,8 @@ private fun DetachDialog(app: AppState, name: String, onExport: () -> Unit, onDi
@Composable @Composable
private fun LicenseDialog(onDismiss: () -> Unit) { private fun LicenseDialog(onDismiss: () -> Unit) {
val context = LocalContext.current val platform = LocalPlatform.current
val text = remember { context.assets.open("LICENSE").use { it.readBytes().decodeToString() } } val text = remember { platform.resource("LICENSE").decodeToString() }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Apache License 2.0") }, 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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@ -24,7 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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) } var deleteVersion by remember { mutableStateOf<Rec?>(null) }
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val platform = LocalPlatform.current
val seconds = session.prefs.clipSeconds val seconds = session.prefs.clipSeconds
val onCopy: (String) -> Unit = { pw -> 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.") } 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor