mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-03 04:24:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94d43ce77a | ||
|
|
987cea431f |
42 changed files with 1481 additions and 557 deletions
111
.github/workflows/build.yml
vendored
Normal file
111
.github/workflows/build.yml
vendored
Normal 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
|
||||||
61
README.md
61
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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("*/*")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
84
desktop/build.gradle.kts
Normal 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
BIN
desktop/icons/nexopass.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
BIN
desktop/icons/nexopass.png
Normal file
BIN
desktop/icons/nexopass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
13
desktop/icons/nexopass.svg
Normal file
13
desktop/icons/nexopass.svg
Normal 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 |
204
desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt
Normal file
204
desktop/src/main/kotlin/com/nexoniarz/nexopass/desktop/Main.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
desktop/src/main/resources/nexopass.png
Normal file
BIN
desktop/src/main/resources/nexopass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
229
shared/src/com/nexoniarz/nexopass/app/AppState.kt
Normal file
229
shared/src/com/nexoniarz/nexopass/app/AppState.kt
Normal 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
|
||||||
|
}
|
||||||
75
shared/src/com/nexoniarz/nexopass/app/Platform.kt
Normal file
75
shared/src/com/nexoniarz/nexopass/app/Platform.kt
Normal 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") }
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
72
shared/src/com/nexoniarz/nexopass/ui/App.kt
Normal file
72
shared/src/com/nexoniarz/nexopass/ui/App.kt
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
|
@ -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) }
|
||||||
|
|
||||||
|
|
@ -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") },
|
||||||
|
|
@ -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.") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in a new issue