NexoPass 1.3.0: Linux and Windows app, project split into shared/android/desktop

The PC app (Compose Multiplatform) uses the nexopass script's files, so the
window and the terminal share accounts, site lists and the app password.
Clipboard copies carry KDE's password hint. GitHub Actions builds the APK,
.deb, .rpm, .msi and a portable Windows zip, and checks that passwords
still match the script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-08 14:59:04 +02:00
commit 987cea431f
42 changed files with 1475 additions and 557 deletions

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

@ -0,0 +1,78 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
}
val appVersion = rootProject.extra["appVersion"] as String
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
main {
kotlin.srcDir("../shared/src")
resources.srcDir("../shared/resources")
}
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
implementation("org.bouncycastle:bcpg-jdk18on:1.79")
testImplementation(kotlin("test"))
}
tasks.test { maxHeapSize = "1g" }
compose.desktop {
application {
mainClass = "com.nexoniarz.nexopass.desktop.MainKt"
// Argon2 for the app password uses 256 MiB.
jvmArgs += listOf("-Xmx1g")
nativeDistributions {
targetFormats(TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
packageName = "NexoPass"
packageVersion = appVersion
vendor = "Nexoniarz"
description = "Passwords derived from a master, nothing stored in plain text"
copyright = "Copyright 2026 Nexoniarz. Apache License 2.0."
licenseFile.set(rootProject.file("LICENSE"))
// Bundled Java runtime: nothing to install, nothing taken from the system.
modules("java.instrument", "java.naming", "java.sql", "jdk.unsupported", "jdk.crypto.ec")
linux {
packageName = "nexopass-gui"
iconFile.set(project.file("icons/nexopass.png"))
debMaintainer = "nexoniarz@users.noreply.github.com"
menuGroup = "Utility"
appCategory = "Utility"
shortcut = true
}
windows {
iconFile.set(project.file("icons/nexopass.ico"))
menu = true
menuGroup = "NexoPass"
shortcut = true
perUserInstall = true
dirChooser = true
// Keeps updates installing over older versions.
upgradeUuid = "5d3b8f0e-6a2c-4b8e-9f3a-2c7d1e4b6a90"
}
}
buildTypes.release.proguard { isEnabled.set(false) }
}
}

BIN
desktop/icons/nexopass.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
desktop/icons/nexopass.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

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

After

Width:  |  Height:  |  Size: 595 B

View file

@ -0,0 +1,204 @@
package com.nexoniarz.nexopass.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.nexoniarz.nexopass.app.AppState
import com.nexoniarz.nexopass.app.LocalPlatform
import com.nexoniarz.nexopass.app.Platform
import com.nexoniarz.nexopass.app.Prefs
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.core.PgpVaultCodec
import com.nexoniarz.nexopass.ui.App
import com.nexoniarz.nexopass.ui.NexoTheme
import com.nexoniarz.nexopass.ui.ParticleBackground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Dimension
import java.awt.FileDialog
import java.awt.Frame
import java.awt.Toolkit
import java.awt.datatransfer.Clipboard
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.SystemFlavorMap
import java.awt.datatransfer.Transferable
import java.io.File
import java.util.Timer
import kotlin.concurrent.schedule
fun main() = application {
val platform = remember { DesktopPlatform() }
val app = remember { AppState(platform, NexoPass.loadWords(platform.resource("eff_large_wordlist.txt"))) }
val state = rememberWindowState(size = DpSize(500.dp, 880.dp), position = WindowPosition(Alignment.Center))
Window(
onCloseRequest = ::exitApplication,
title = "NexoPass",
state = state,
icon = painterResource("nexopass.png"),
onPreviewKeyEvent = { e ->
// Esc goes back, like the back gesture on the phone.
e.key == Key.Escape && e.type == KeyEventType.KeyDown && platform.back()
},
) {
LaunchedEffect(Unit) {
window.minimumSize = Dimension(420, 640)
platform.window = window
}
val dark = when (platform.prefs.theme) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
CompositionLocalProvider(LocalPlatform provides platform) {
NexoTheme(dark) {
ParticleBackground(dark = dark, animated = platform.prefs.particles) {
// Phone-sized column in the middle of a wide window.
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Box(Modifier.widthIn(max = 640.dp).fillMaxHeight()) { App(app) }
}
}
}
}
}
}
class DesktopPlatform : Platform {
override val prefs = Prefs(ConfigKV())
override val lock = ScriptLock(prefs)
override val codec = PgpVaultCodec
override val accountsDir: File = Paths.accounts
override val migrated = migrate()
override val minAutoLockMinutes = 1
override val animationsEnabled = true
override val aboutText =
if (Paths.windows) "Same passwords as the phone app and the nexopass command on Linux."
else "Shares accounts, site lists and the app password with the nexopass command. Same passwords as the phone app."
var window: Frame? = null
private var onBack: (() -> Unit)? = null
fun back(): Boolean {
val b = onBack ?: return false
b()
return true
}
/** Older nexopass kept one vault in the data folder; it becomes the account "main" (like the script does). */
private fun migrate(): Boolean {
val old = File(Paths.data, "vault.gpg")
if (!old.exists() || File(Paths.accounts, "main").exists()) return false
File(Paths.accounts, "main").mkdirs()
old.renameTo(File(Paths.accounts, "main/vault.gpg"))
File(Paths.data, "vault.gpg.bak").takeIf { it.exists() }?.renameTo(File(Paths.accounts, "main/vault.gpg.bak"))
File(Paths.data, "quick.gpg").delete()
File(Paths.data, "quick.meta").delete()
return true
}
override fun resource(name: String): ByteArray =
DesktopPlatform::class.java.getResourceAsStream("/$name")!!.use { it.readBytes() }
override fun copySecret(text: String, seconds: Int) {
val clipboard: Clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(SecretSelection(text), null)
Timer(true).schedule(seconds * 1000L) {
runCatching {
if (clipboard.getData(DataFlavor.stringFlavor) == text) clipboard.setContents(StringSelection(""), null)
}
}
}
@Composable
override fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
val current by rememberUpdatedState(onBack)
DisposableEffect(enabled) {
val handler = { current() }
if (enabled) this@DesktopPlatform.onBack = handler
onDispose { if (this@DesktopPlatform.onBack === handler) this@DesktopPlatform.onBack = null }
}
}
@Composable
override fun AutoLock(app: AppState) {
val focused by rememberUpdatedState(LocalWindowInfo.current.isWindowFocused)
LaunchedEffect(Unit) {
var awaySince = 0L
while (true) {
delay(2_000)
val limit = maxOf(prefs.autoLockMinutes, minAutoLockMinutes) * 60_000L
if (focused) {
awaySince = 0
} else {
if (awaySince == 0L) awaySince = System.currentTimeMillis()
if (app.appUnlocked && !app.needsAppSetup && System.currentTimeMillis() - awaySince >= limit) app.lock()
}
}
}
}
@Composable
override fun rememberExport(content: () -> ByteArray, onDone: (Boolean) -> Unit): (String) -> Unit {
val scope = rememberCoroutineScope()
return { name ->
val d = FileDialog(window, "Export site list", FileDialog.SAVE).apply {
file = name
isVisible = true
}
if (d.file != null) {
val target = File(d.directory, d.file)
scope.launch {
val ok = withContext(Dispatchers.IO) { runCatching { writeAtomically(target, content()) }.isSuccess }
onDone(ok)
}
}
}
}
@Composable
override fun rememberImport(onBytes: (ByteArray?) -> Unit): () -> Unit = {
val d = FileDialog(window, "Import site list", FileDialog.LOAD).apply { isVisible = true }
if (d.file != null) onBytes(runCatching { File(d.directory, d.file).readBytes() }.getOrNull())
}
}
/**
* Plain text plus KDE's password hint, so Klipper keeps the password out of
* its clipboard history. Other desktops just see text.
*/
private class SecretSelection(private val text: String) : Transferable {
private val hint = DataFlavor("application/x-kde-passwordManagerHint;class=java.io.InputStream")
init {
(SystemFlavorMap.getDefaultFlavorMap() as SystemFlavorMap).apply {
addUnencodedNativeForFlavor(hint, "x-kde-passwordManagerHint")
addFlavorForUnencodedNative("x-kde-passwordManagerHint", hint)
}
}
override fun getTransferDataFlavors() = arrayOf(DataFlavor.stringFlavor, hint)
override fun isDataFlavorSupported(flavor: DataFlavor) = flavor == DataFlavor.stringFlavor || flavor == hint
override fun getTransferData(flavor: DataFlavor): Any = when (flavor) {
DataFlavor.stringFlavor -> text
else -> "secret".byteInputStream()
}
}

View file

@ -0,0 +1,162 @@
package com.nexoniarz.nexopass.desktop
import com.nexoniarz.nexopass.app.AppLock
import com.nexoniarz.nexopass.app.KeyValue
import com.nexoniarz.nexopass.app.Prefs
import com.nexoniarz.nexopass.app.UnlockResult
import com.nexoniarz.nexopass.app.Unlocked
import com.nexoniarz.nexopass.core.Export
import com.nexoniarz.nexopass.core.NexoPass
import com.nexoniarz.nexopass.core.hex
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermissions
import java.security.SecureRandom
/*
* The PC app reads and writes exactly the files of the `nexopass` script, so
* the window and the terminal share accounts, site lists and the app password:
*
* <data>/accounts/<name>/vault.gpg site list (OpenPGP, see PgpVaultCodec)
* <data>/keyring.gpg "name<0x1F>master" lines, OpenPGP
* <data>/keyring.meta method, salt, failed tries
* <config> the script's settings (key=value)
* desktop.conf next to it settings only the window uses
*/
object Paths {
val windows = System.getProperty("os.name").startsWith("Windows")
private val home = System.getProperty("user.home")
val data: File = System.getenv("NEXOPASS_HOME")?.let(::File)
?: if (windows) File(System.getenv("APPDATA") ?: home, "NexoPass")
else File(System.getenv("XDG_DATA_HOME") ?: "$home/.local/share", "nexopass")
val config: File = System.getenv("NEXOPASS_CONFIG")?.let(::File)
?: if (windows) File(data, "config")
else File(System.getenv("XDG_CONFIG_HOME") ?: "$home/.config", "nexopass/config")
val desktopConfig = File(config.parentFile, "desktop.conf")
val accounts = File(data, "accounts")
}
/** Owner-only permissions where the file system supports it (Linux). */
fun privateFile(f: File) {
runCatching { Files.setPosixFilePermissions(f.toPath(), PosixFilePermissions.fromString("rw-------")) }
}
/** Writes through a temp file so a crash never leaves half a file. */
fun writeAtomically(f: File, bytes: ByteArray) {
f.parentFile?.mkdirs()
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeBytes(bytes)
privateFile(tmp)
if (!tmp.renameTo(f)) {
f.delete()
check(tmp.renameTo(f)) { "Could not save ${f.name}" }
}
}
private fun readKeyValues(f: File): LinkedHashMap<String, String> {
val map = LinkedHashMap<String, String>()
if (f.exists()) f.readLines().forEach { line ->
if (!line.startsWith("#") && '=' in line) map[line.substringBefore('=')] = line.substringAfter('=')
}
return map
}
/** Rewrites one key, keeping every other line (the script's own keys too). */
private fun writeKeyValue(f: File, key: String, value: String, header: String) {
val lines = if (f.exists()) f.readLines().toMutableList() else mutableListOf(header)
val i = lines.indexOfFirst { it.startsWith("$key=") }
if (i >= 0) lines[i] = "$key=$value" else lines += "$key=$value"
f.parentFile?.mkdirs()
writeAtomically(f, (lines.joinToString("\n") + "\n").toByteArray())
}
/** Settings shared with the script go to its config file; the rest to desktop.conf. */
class ConfigKV : KeyValue {
private val scriptKeys = setOf("clip_seconds", "default_mode", "max_attempts", "current_account")
private fun file(key: String) = if (key in scriptKeys) Paths.config else Paths.desktopConfig
private fun header(key: String) =
if (key in scriptKeys) "# NexoPass settings, change with: nexopass settings" else "# NexoPass window settings"
private fun get(key: String) = readKeyValues(file(key))[key]
private fun put(key: String, value: String) = writeKeyValue(file(key), key, value, header(key))
override fun getInt(key: String, def: Int) = get(key)?.toIntOrNull() ?: def
override fun putInt(key: String, value: Int) = put(key, value.toString())
override fun getString(key: String, def: String) = get(key) ?: def
override fun putString(key: String, value: String) = put(key, value)
override fun getBool(key: String, def: Boolean) = get(key)?.toBooleanStrictOrNull() ?: def
override fun putBool(key: String, value: Boolean) = put(key, value.toString())
}
/**
* The script's app password: keyring.gpg encrypted with
* Argon2id(password, random salt; 4 passes, 256 MiB) as hex.
*/
class ScriptLock(private val prefs: Prefs) : AppLock {
private val keyringFile = File(Paths.data, "keyring.gpg")
private val metaFile = File(Paths.data, "keyring.meta")
override val isSet get() = keyringFile.exists() && metaFile.exists()
override val method get() = if (isSet) meta()["method"] ?: "password" else "none"
override val methods = listOf("password", "pin")
override fun problem(method: String, secret: String) = when (method) {
"pin" -> if (!secret.matches(Regex("[0-9]{6,32}"))) "A PIN is 6 to 32 digits (this PC has no TPM chip, so short PINs are weak)." else null
else -> if (secret.length < 8) "Use at least 8 characters." else null
}
private fun meta() = readKeyValues(metaFile)
private fun writeMeta(method: String, salt: String, failed: Int) {
writeAtomically(metaFile, "method=$method\nsalt=$salt\nfailed=$failed\n".toByteArray())
}
private fun key(secret: String, salt: String) =
hex(NexoPass.argon2(secret.toByteArray(), salt, 4, 18, 32)).toByteArray()
override fun setup(method: String, secret: String, keyring: Map<String, Unlocked>): ByteArray {
val salt = hex(ByteArray(16).also { SecureRandom().nextBytes(it) })
val k = key(secret, salt)
writeMeta(method, salt, 0)
save(k, keyring)
return k
}
override fun save(innerKey: ByteArray, keyring: Map<String, Unlocked>) {
val text = keyring.entries.joinToString("") { (name, u) -> name + "\u001f" + u.master.decodeToString() + "\n" }
writeAtomically(keyringFile, Export.encrypt(innerKey.decodeToString().toCharArray(), text.toByteArray()))
}
override fun unlock(secret: String): UnlockResult {
val m = meta()
val k = key(secret, m["salt"] ?: "")
val plain = Export.decrypt(k.decodeToString().toCharArray(), keyringFile.readBytes())
if (plain == null) {
val failed = (m["failed"]?.toIntOrNull() ?: 0) + 1
val left = prefs.maxAttempts - failed
if (left <= 0) {
wipe()
return UnlockResult.Wiped
}
writeMeta(m["method"] ?: "password", m["salt"] ?: "", failed)
return UnlockResult.Wrong(left)
}
writeMeta(m["method"] ?: "password", m["salt"] ?: "", 0)
// Vault keys aren't stored: derive them (a fraction of a second each).
val keyring = LinkedHashMap<String, Unlocked>()
plain.decodeToString().lines().filter { '\u001f' in it }.forEach { line ->
val master = line.substringAfter('\u001f').toByteArray()
keyring[line.substringBefore('\u001f')] = Unlocked(NexoPass.vaultKey(master), master)
}
return UnlockResult.Ok(k, keyring)
}
override fun wipe() {
keyringFile.delete()
metaFile.delete()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

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