mirror of
https://github.com/Nexoniarz/NexoPass.git
synced 2026-11-03 20:44:27 +00:00
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:
parent
b38efcaea0
commit
987cea431f
42 changed files with 1475 additions and 557 deletions
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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue